《深入理解java虚拟机 第三版》学习笔记一

第 2 章 Java 内存区域与内存溢出异常

2.2 运行时数据区域

Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。
《深入理解java虚拟机 第三版》学习笔记一_第1张图片

2.2.1 程序计数器-线程私有

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。

在 Java 虚拟机的概念模型里[1],字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于 Java 虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节
码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。

程序计数器是唯一一个在《Java 虚拟机规范》中没有规定任何OutOfMemoryError 情况的区域。

2.2.2 Java 虚拟机栈-线程私有

与程序计数器一样,Java 虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。
虚拟机栈描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧[1](Stack Frame)用于存储局部变量表操作数栈动态连接方法出口等信息。

每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

局部变量表存放了编译期可知的

  • 基本数据类型(boolean、byte、char、short、int、 float、long、double)
  • 对象引用(reference 类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)
  • returnAddress 类型(指向了一条字节码指令的地址)

这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中 64 位长度的 long 和 double 类型的数据会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。请读者注意,这里说的“大小”是指变量槽的数量,虚拟机真正使用多大的内存空间(譬如按照 1 个变量槽占用 32 个比特、64 个比特,或者更多)来实现一个变量槽,这是完全由具体的虚拟机实现自行决定的事情。

在《Java 虚拟机规范》中,对这个内存区域规定了两类异常状况:

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;
  • 如果 Java 虚拟机栈容量可以动态扩展[2],当栈扩展时无法申请到足够的内存会抛出 OutOfMemoryError 异常。

2.2.3 本地方法栈-线程私有

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而

本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

《Java 虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的 Java 虚拟机(譬如 Hot-Spot 虚拟机)直接就把本地方法栈和虚拟机栈合二为一。

  • 与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出 StackOverflowError 和OutOfMemoryError 异常。

2.2.4 Java 堆-线程共享

对于 Java 应用程序来说,Java 堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。

此内存区域的唯一目的就是存放对象实例,Java 世界里“几乎”所有的对象实例都在这里分配内存。

Java 堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作“GC 堆”(Garbage Collected Heap)。
从回收内存的角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以 Java 堆中经常会出现“新生代”“老年代”“永久代”“Eden 空间”“From Survivor 空间”“To Survivor 空间”等名词,这些区域划分仅仅是一部分垃圾收集器的共同特性或者说设计风格而已,而非某个 Java 虚拟机具体实现的固有内存布局,更不是《Java 虚拟机规范》里对 Java 堆的进一步细致划分。
如果从分配内存的角度看,所有线程共享的 Java 堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。不过无论从什么角度,无论如何划分,都不会改变 Java 堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将 Java 堆细分的目的只是为了更好地回收内存,或者更快地分配内存。
根据《Java 虚拟机规范》的规定,Java 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。但对于大对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。
Java 堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的 Java 虚拟机都是按照可扩展来实现的(通过参数-Xmx 和-Xms 设定)。

  • 如果在 Java 堆中没有内存完成实例分配,并且堆也无法再扩展时,Java 虚拟机将会抛出 OutOfMemoryError 异常。
从垃圾回收的角度划分java堆和方法区:

《深入理解java虚拟机 第三版》学习笔记一_第2张图片

  • 新生代(Young Generation):新生代主要用于存放新创建的对象。它通常被划分为三个部分:

    • Eden区(伊甸园区):这是新对象最初被分配的地方。大多数新对象都被分配到Eden区。
    • Survivor区(幸存区):Survivor区包括两个区域,通常称为From区和To区。当垃圾回收发生时,存活下来的对象会被移到一个Survivor区,然后在不同区域之间来回移动,最终进入老年代。
  • 老年代(Tenured Generation):老年代用于存放生命周期较长的对象,通常由Eden区和Survivor区中存活时间较长的对象填充而成。垃圾回收发生在这个区域,因为老年代中的对象更难以回收。

  • 永久代(Permanent Generation):在Java 7及之前的版本中,存在一个称为永久代的特殊区域,用于存放类的元数据、常量池等信息。但在Java 8及以后的版本中,永久代被元空间(Metaspace)所取代。元空间不再属于Java堆的一部分,它位于本地内存中。

  • 元空间(Metaspace):在Java 8及以后的版本中,元空间取代了永久代。它用于存储类的元数据,包括类的结构信息、方法信息等。元空间的大小可以动态扩展,而不受Java堆的限制。

垃圾回收算法和行为也会因不同区域而异,例如,新生代通常使用复制算法,而老年代使用标记-清理算法或标记-整理算法

永久代和元空间不属于java堆,它们存在于方法区中

2.2.5 方法区-线程共享

方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,

它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

说到方法区,不得不提一下“永久代”这个概念,尤其是在 JDK 8 以前,许多 Java 程序员都习惯在 HotSpot 虚拟机上开发、部署程序,很多人都更愿意把方法区称呼为“永久代”(Permanent Generation),或将两者混为一谈。本质上这两者并不是等价的,因为仅仅是当时的 HotSpot 虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得 HotSpot 的垃圾收集器能够像管理 Java 堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。但是对于其他虚拟机实现,譬如 BEA JRockit、IBM J9 等来说,是不存在永久代的概念的。
原则上如何实现方法区属于虚拟机实现细节,不受《Java 虚拟机规范》管束,并不要求统一。
但现在回头来看,当年使用永久代来实现方法区的决定并不是一个好主意,这种设计导致了 Java 应用更容易遇到内存溢出的问题(永久代有-XX:MaxPermSize 的上限,即使不设置也有默认大小,而 J9 和 JRockit 只要没有触碰到进程可用内存的上限,例如 32 位系统中的4GB 限制,就不会出问题),而且有极少数方法(例如 String::intern())会因永久代的原因而导致不同虚拟机下有不同的表现。

JDK 7 的 HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了 JDK 8,终于完全废弃了永久代的概念,改用在本地内存中实现的元空间(Metaspace)来代替,把 JDK 7 中永久代还剩余的内容(主要是类型信息)全部移到元空间中。

《Java 虚拟机规范》对方法区的约束是非常宽松的,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。
相对而言,垃圾收集行为在这个区域的确是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。

  • 根据《Java 虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出 OutOfMemoryError 异常。

2.2.6 运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。

Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中

Java 虚拟机对于 Class 文件每一部分(自然也包括常量池)的格式都有严格规定,如每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、加载和执行,但对于运行时常量池,《Java 虚拟机规范》并没有做任何细节的要求,不同提供商实现的虚拟机可以按照自己的需要来实现这个内存区域,不过一般来说,除了保存Class 文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。
运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern()方法。

  • 既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

2.2.7 直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java 虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError 异常出现,所以我们放到这里一起讲解。

在 JDK 1.4 中新加入了 NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP 分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常

2.3 HotSpot 虚拟机对象探秘

2.3.1 对象的创建

当 Java 虚拟机遇到一条字节码 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定(如何确定将在 2.3.2 节中介绍),为对象分配空间的任务实际上便等同于把一块确定大小的内存块从 Java 堆中划分出来。假设 Java 堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump The Pointer)。但如果 Java 堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此,当使用 Serial、ParNew 等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用 CMS 这种基于清除(Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。
除如何划分可用空间之外,还有另外一个需要考虑的问题:对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。解决这个问题有两种可选方案:一种是对分配内存空间的动作进行同步处理——实际上虚拟机是采用 CAS 配上失败重试的方式保证更新操作的原子性;另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。虚拟机是否使用 TLAB,可以通过-XX:+/-UseTLAB 参数来设定。
内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了 TLAB 的话,这一项工作也可以提前至 TLAB 分配时顺便进行。这步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。
接下来,Java 虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。关于对象头的具体内容,稍后会详细介绍。
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。但是从ava 程序的视角看来,对象创建才刚刚开始——构造函数,即 Class 文件中的()方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。一般来说(由字节码流中 new 指令后面是否跟随 invokespecial指令所决定,Java 编译器会在遇到 new 关键字的地方同时生成这两条字节码指令,但如果直接通过其他方式产生的则不一定如此),new 指令之后会接着执行 ()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。

2.3.2 对象的内存布局

在 HotSpot 虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:

  • 对象头(Header)
  • 实例数据(Instance Data)
  • 对齐填充(Padding)

HotSpot 虚拟机对象的对象头部分包括两类信息。

  • 第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32 个比特和 64 个比特,官方称它为“Mark Word”。对象需要存储的运行时数据很多,其实已经超出了 32、64 位 Bitmap 结构所能记录的最大限度,但对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。例如在 32 位的 HotSpot 虚拟机中,如对象未被同步锁锁定的状态下,Mark Word 的 32 个比特存储空间中的 25 个比特用于存储对象哈希码,4 个比特用于存储对象分代年龄,2 个比特用于存储锁标志位,1 个比特固定为0,在其他状态(轻量级锁定、重量级锁定、GC 标记、可偏向)下对象的存储内容如表 2-1 所示。
    《深入理解java虚拟机 第三版》学习笔记一_第3张图片

  • 对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针,Java 虚拟机通过这个指针来确定该对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身,这点我们会在下一节具体讨论。此外,如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通 Java 对象的元数据信息确定Java 对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。

接下来实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle 参数)和字段在 Java 源码中定义顺序的影响。HotSpot 虚拟机默认的分配顺序为 longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果 HotSpot 虚拟机的+XX:CompactFields 参数值为true(默认就为 true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。

对象的第三部分是对齐填充,这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于 HotSpot 虚拟机的自动内存管理系统要求对象起始地址必须是 8字节的整数倍,换句话说就是任何对象的大小都必须是 8 字节的整数倍。对象头部分已经被精心设计成正好是 8 字节的倍数(1 倍或者 2 倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

2.3.3 对象的访问定位

创建对象自然是为了后续使用该对象,我们的 Java 程序会通过栈上的 reference 数据来操作堆上的具体对象。由于 reference 类型在《Java 虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种:

  • 如果使用句柄访问的话,Java 堆中将可能会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息,其结构如图 2-2 所示。
    《深入理解java虚拟机 第三版》学习笔记一_第4张图片

  • 如果使用直接指针访问的话,Java 堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference 中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销,如图 2-3 所示。
    《深入理解java虚拟机 第三版》学习笔记一_第5张图片

这两种对象访问方式各有优势,使用句柄来访问的最大好处就是 reference 中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改。
使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在 Java 中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本,就本书讨论的主要虚拟机 HotSpot 而言,它主要使用第二种方式进行对象访问(有例外情况,如果使用了 Shenandoah 收集器的话也会有一次额外的转发,具体可参见第 3 章),但从整个软件开发的范围来看,在各种语言、框架中使用句柄来访问的情况也十分常见。

2.4 实战:OutOfMemoryError 异常

2.4.1 Java 堆溢出

Java 堆用于储存对象实例,我们只要不断地创建对象,并且保证 GC Roots 到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会产生内存溢出异常。代码清单 2-3 中限制 Java 堆的大小为 20MB,不可扩展(将堆的最小值-Xms 参数与最大值-Xmx 参数设置为一样即可避免堆自动扩展),通过参数-XX:+HeapDumpOnOutOf-MemoryError 可以让虚拟机在出现内存溢出异常的时候 Dump 出当前的内存堆转储快照以便进行事后分析。

  • ideal配置jvm运行参数
    《深入理解java虚拟机 第三版》学习笔记一_第6张图片
  • 代码清单 2-3 Java 堆内存溢出异常测试
/**
* VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
* @author zzm
*/
public class HeapOOM {
 static class OOMObject {
 }
 public static void main(String[] args) {
 List<OOMObject> list = new ArrayList<OOMObject>();
 while (true) {
 list.add(new OOMObject());
 }
 }
}
  • 运行结果
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid3404.hprof ...
Heap dump file created [22045981 bytes in 0.663 secs]

分析过程及结果:
https://blog.csdn.net/weixin_41827053/article/details/133809362

《深入理解java虚拟机 第三版》学习笔记一_第7张图片
Java 堆内存的 OutOfMemoryError 异常是实际应用中最常见的内存溢出异常情况。出现 Java 堆内存溢出时,异常堆栈信息“java.lang.OutOfMemoryError”会跟随进一步提示“Java heap space”。
要解决这个内存区域的异常,常规的处理方法是首先通过内存映像分析工具(如Eclipse Memory Analyzer)对 Dump 出来的堆转储快照进行分析。第一步首先应确认内存中导致 OOM 的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。
如果是内存泄漏,可进一步通过工具查看泄漏对象到 GC Roots 的引用链,找到泄漏对象是通过怎样的引用路径、与哪些 GC Roots 相关联,才导致垃圾收集器无法回收它们,根据泄漏对象的类型信息以及它到 GC Roots 引用链的信息,一般可以比较准确地定位到这些对象创建的位置,进而找出产生内存泄漏的代码的具体位置。
如果不是内存泄漏,换句话说就是内存中的对象确实都是必须存活的,那就应当检查 Java 虚拟机的堆参数(-Xmx 与-Xms)设置,与机器的内存对比,看看是否还有向上调整的空间。再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。

  • 内存泄漏(Memory Leak):
    定义:内存泄漏是指应用程序中的对象在不再需要时仍然存在于内存中,无法被垃圾回收机制回收。这些对象继续占用内存,导致应用程序的内存消耗逐渐增加。
    特征:内存泄漏通常表现为应用程序的内存占用逐渐增加,直到达到可用内存的极限,最终导致 OutOfMemoryError 异常。
    原因:内存泄漏通常是由于应用程序中的对象引用未被正确释放或管理,导致这些对象无法被垃圾回收。
    解决方法:解决内存泄漏通常需要仔细分析应用程序的代码,查找未释放引用的地方,并确保对象在不再需要时被适时释放。内存分析工具如Eclipse Memory Analyzer可以帮助您识别泄漏的对象。

  • 内存溢出(Memory Overflow):
    定义:内存溢出是指应用程序在需要更多内存时无法获得足够的内存,导致抛出 OutOfMemoryError 异常。这通常发生在应用程序尝试分配更多内存时,而可用内存已经耗尽。
    特征:内存溢出通常表现为突然的 OutOfMemoryError 异常,而不是逐渐增加的内存占用。
    原因:内存溢出通常是由于应用程序需要更多内存,但系统无法为其提供足够的内存。
    解决方法:解决内存溢出通常需要优化应用程序的内存使用,例如减少对象的创建、释放不必要的资源等。还可以增加Java虚拟机的堆内存大小,以提供更多的内存。

要区分内存泄漏和内存溢出,您可以观察应用程序的内存使用情况。如果内存逐渐增加,直到导致 OutOfMemoryError,那么很可能是内存泄漏。如果应用程序在某一时刻突然抛出 OutOfMemoryError,那么更可能是内存溢出。使用内存分析工具可以帮助您确定问题的根本原因并采取适当的措施来解决。

2.4.2 虚拟机栈和本地方法栈溢出

由于 HotSpot 虚拟机中并不区分虚拟机栈和本地方法栈,因此对于 HotSpot 来说,-Xoss 参数(设置本地方法栈大小)虽然存在,但实际上是没有任何效果的,栈容量只能由-Xss 参数来设定。关于虚拟机栈和本地方法栈,在《Java 虚拟机规范》中描述了两种异常:

  1. 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError 异常。
  2. 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出 OutOfMemoryError 异常。

《Java 虚拟机规范》明确允许 Java 虚拟机实现自行选择是否支持栈的动态扩展,而HotSpot 虚拟机的选择是不支持扩展,所以除非在创建线程申请内存时就因无法获得足够内存而出现 OutOfMemoryError 异常,否则在线程运行时是不会因为扩展而导致内存溢出的,只会因为栈容量无法容纳新的栈帧而导致 StackOverflowError 异常。
为了验证这点,我们可以做两个实验,先将实验范围限制在单线程中操作,尝试下面两种行为是否能让 HotSpot 虚拟机产生 OutOfMemoryError 异常: ·使用-Xss 参数减少栈内存容量。结果:抛出 StackOverflowError 异常,异常出现时输出的堆栈深度相应缩小。

  • 定义了大量的本地变量,增大此方法帧中本地变量表的长度。
    结果:抛出 StackOverflowError 异常,异常出现时输出的堆栈深度相应缩小。
    首先,对第一种情况进行测试,具体如代码清单 2-4 所示。代码清单 2-4 虚拟机栈和本地方法栈测试(作为第 1 点测试程序)
/**
* VM Args:-Xss128k
*
* @author zzm
*/
public class JavaVMStackSOF {
 private int stackLength = 1;
 public void stackLeak() {
 stackLength++;
 stackLeak();
 }
 public static void main(String[] args) throws Throwable {
 JavaVMStackSOF oom = new JavaVMStackSOF();
 try {
 oom.stackLeak();
 } catch (Throwable e) {
 System.out.println("stack length:" + oom.stackLength);
 throw e;
 }
 }
}
  • 运行结果:
stack length:2402
Exception in thread "main" java.lang.StackOverflowError
 at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:20) 
 at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:21) 
 at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:21)
 ……后续异常堆栈信息省略

对于不同版本的 Java 虚拟机和不同的操作系统,栈容量最小值可能会有所限制,这主要取决于操作系统内存分页大小。譬如上述方法中的参数-Xss128k 可以正常用于 32位 Windows 系统下的 JDK 6,但是如果用于 64 位 Windows 系统下的 JDK 11,则会提示栈容量最小不能低于 180K,而在 Linux 下这个值则可能是 228K,如果低于这个最小限制,HotSpot 虚拟器启动时会给出如下提示:

The Java thread stack size specified is too small. Specify at least 228k

实验结果表明:无论是由于栈帧太大还是虚拟机栈容量太小,当新的栈帧内存无法分配的时候, HotSpot 虚拟机抛出的都是 StackOverflowError 异常。

出现 StackOverflowError 异常时,会有明确错误堆栈可供分析,相对而言比较容易定位到问题所在。如果是建立过多线程导致的内存溢出,在不能减少线程数量或者更换 64 位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。这种通过“减少内存”的手段来解决内存溢出的方式,如果没有这方面处理经验,一般比较难以想到,这一点读者需要在开发 32 位系统的多线程应用时注意。也是由于这种问题较为隐蔽,从 JDK 7 起,以上提示信息中“unable to create native thread”后面,虚拟机会特别注明原因可能是“possibly out of memory or process/resource limits reached”。

2.4.3 方法区和运行时常量池溢出

由于运行时常量池是方法区的一部分,所以这两个区域的溢出测试可以放到一起进行。前面曾经提到 HotSpot 从 JDK 7 开始逐步“去永久代”的计划,并在 JDK 8 中完全使用元空间来代替永久代的背景故事,在此我们就以测试代码来观察一下,使用“永久代”还是“元空间”来实现方法区,对程序有什么实际的影响。
String::intern()是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此 String 对象的字符串,则返回代表池中这个字符串的 String 对象的引用;否则,会将此 String 对象包含的字符串添加到常量池中,并且返回此 String 对象的引用。在 JDK 6或更早之前的 HotSpot 虚拟机中,常量池都是分配在永久代中,我们可以通过-XX:PermSize 和-XX:MaxPermSize 限制永久代的大小,即可间接限制其中常量池的容量,具体实现如代码清单 2-7 所示,请读者测试时首先以 JDK 6 来运行代码。

  • 代码清单 2-7运行时常量池导致的内存溢出异常
import java.util.HashSet;
import java.util.Set;
/**
* VM Args:-XX:PermSize=6M -XX:MaxPermSize=6M
*
* @author zzm
*/
public class RuntimeConstantPoolOOM {
 public static void main(String[] args) {
 // 使用 Set 保持着常量池引用,避免 Full GC 回收常量池行为
 Set<String> set = new HashSet<String>(); // 在 short 范围内足以让 6MB 的
PermSize 产生 OOMshort i = 0;
 while (true) {
 set.add(String.valueOf(i++).intern());
 }
 }
}
  • 运行结果
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
 at java.lang.String.intern(Native Method)
 at org.fenixsoft.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java: 18)

从运行结果中可以看到,运行时常量池溢出时,在 OutOfMemoryError 异常后面跟随的提示信息是“PermGen space”,说明运行时常量池的确是属于方法区(即 JDK 6 的HotSpot 虚拟机中的永久代)的一部分。
而使用 JDK 7 或更高版本的 JDK 来运行这段程序并不会得到相同的结果,无论是在 JDK 7 中继续使用-XX:MaxPermSize 参数或者在 JDK 8 及以上版本使用-XX:MaxMeta-spaceSize 参数把方法区容量同样限制在 6MB,也都不会重现 JDK 6 中的溢出异常,循环将一直进行下去,永不停歇。出现这种变化,是因为自 JDK 7 起,原本存在永久代的字符串常量池被移至 Java 堆之中,所以在 JDK 7 及以上版本,限制方法区的容量对该测试用例来说是毫无意义的。这时候使用-Xmx 参数限制最大堆到 6MB 就能够看到以下两种运行结果之一,具体取决于哪里的对象分配时产生了溢出:

// OOM 异常一:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space 
at java.base/java.lang.Integer.toString(Integer.java:440) 
at java.base/java.lang.String.valueOf(String.java:3058) 
at RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:12)
// OOM 异常二:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space 
at java.base/java.util.HashMap.resize(HashMap.java:699) 
at java.base/java.util.HashMap.putVal(HashMap.java:658) 
at java.base/java.util.HashMap.put(HashMap.java:607) 
at java.base/java.util.HashSet.add(HashSet.java:220)
at RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java from InputFileObject:14)

方法区溢出也是一种常见的内存溢出异常,一个类如果要被垃圾收集器回收,要达成的条件是比较苛刻的。在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况。这类场景除了之前提到的程序使用了 CGLib 字节码增强和动态语言外,常见的还有:大量 JSP 或动态产生 JSP 文件的应用(JSP 第一次运行时需要编译为Java 类)、基于 OSGi 的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。
在 JDK 8 以后,永久代便完全退出了历史舞台,元空间作为其替代者登场。在默认设置下,前面列举的那些正常的动态创建新类型的测试用例已经很难再迫使虚拟机产生方法区的溢出异常了。不过为了让使用者有预防实际应用里出现类似于代码清单 2-9 那样的破坏性的操作,HotSpot 还是提供了一些参数作为元空间的防御措施,主要包括:

  • -XX:MaxMetaspaceSize:设置元空间最大值,默认是-1,即不限制,或者说只受
    限于本地内存大小。
  • -XX:MetaspaceSize:指定元空间的初始空间大小,以字节为单位,达到该值就会
    触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,
    就适当降低该值;如果释放了很少的空间,那么在不超过-XX:MaxMetaspaceSize(如
    果设置了的话)的情况下,适当提高该值。
  • -XX:MinMetaspaceFreeRatio:作用是在垃圾收集之后控制最小的元空间剩余容量
    的百分比,可减少因为元空间不足导致的垃圾收集的频率。类似的还有-XX:Max-MetaspaceFreeRatio,用于控制最大的元空间剩余容量的百分比。

2.4.4 本机直接内存溢出

直接内存(Direct Memory)的容量大小可通过-XX:MaxDirectMemorySize 参数来指定,如果不去指定,则默认与 Java 堆最大值(由-Xmx 指定)一致,代码清单 2-10 越过了 DirectByteBuffer 类直接通过反射获取 Unsafe 实例进行内存分配(Unsafe 类的getUnsafe()方法指定只有引导类加载器才会返回实例,体现了设计者希望只有虚拟机标准类库里面的类才能使用 Unsafe 的功能,在 JDK 10 时才将 Unsafe 的部分功能通过VarHandle 开放给外部使用),因为虽然使用 DirectByteBuffer 分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配就会在代码里手动抛出溢出异常,真正申请分配内存的方法是Unsafe::allocateMemory()。

  • 代码清单 2-10 使用 unsafe 分配本机内存
/**
* VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M
*
* @author zzm
*/
public class DirectMemoryOOM {
 private static final int _1MB = 1024 * 1024;
 public static void main(String[] args) throws Exception {
 Field unsafeField = Unsafe.class.getDeclaredFields()[0];
 unsafeField.setAccessible(true);
 Unsafe unsafe = (Unsafe) unsafeField.get(null);
 while (true) {
 unsafe.allocateMemory(_1MB);
 }
 }
}
  • 运行结果:
Exception in thread "main" java.lang.OutOfMemoryError 
at sun.misc.Unsafe.allocateMemory(Native Method) 
at org.fenixsoft.oom.DMOOM.main(DMOOM.java:20)

由直接内存导致的内存溢出,一个明显的特征是在 Heap Dump 文件中不会看见有什么明显的异常情况,如果读者发现内存溢出之后产生的 Dump 文件很小,而程序中又直接或间接使用了 DirectMemory(典型的间接使用就是 NIO),那就可以考虑重点检查一下直接内存方面的原因了。

第 3 章 垃圾收集器与内存分配策略

3.2 对象已死?

在堆里面存放着 Java 世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(“死去”即不可能再被任何途径使用的对象)了。

3.2.1 引用计数算法

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

Java 虚拟机并不是通过引用计数算法来判断对象是否存活的

3.2.2 可达性分析算法

这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。

如图 3-1 所示,对象 object 5、object 6、object 7 虽然互有关联,但是它们到 GC Roots 是不可达的,因此它们将会被判定为可回收的对象。
《深入理解java虚拟机 第三版》学习笔记一_第8张图片
在 Java 技术体系里面,固定可作为 GC Roots 的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如 Java 类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象。
  • Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized 关键字)持有的对象。
  • 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。

除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整 GC Roots 集合。

3.2.3 引用类型

在 JDK 1.2 版之后,Java 对引用的概念进行了扩充,将引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4 种,这 4 种引用强度依次逐渐减弱。

  • 强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
  • 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在 JDK 1.2 版之后提供了SoftReference 类来实现软引用。
  • 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在 JDK 1.2 版之后提供了WeakReference 类来实现弱引用。
  • 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在 JDK 1.2 版之后提供了 PhantomReference 类来实现虚引用。

3.2.3 判断对象是否死亡

1.使用可达性分析算法判断A对象不可达;
2.如果A对象覆盖finalize()方法或未者已经执行过finalize则进入3,否则进入5;
3.将A对象放到F-Queue队列中,有虚拟机创建的Finalizer线程去执行;
4.执行A对象的finalize对象,如果在执行过程中对象又建立了引用关联,则从F-Queue队列中去除,否则进入5;
5.A对象执行GC

判断是否有必要执行finalize()方法:

假如对象没有覆盖 finalize()方法,或者 finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。

即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize()方法。假如对象没有覆盖 finalize()方法,或者 finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为确有必要执行 finalize()方法,那么该对象将会被放置在一个名为 F-Queue 的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer 线程去执行它们的 finalize() 方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致 F-Queue 队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建
立关联即可,譬如把自己(this 关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。从代码清单 3-2 中我们可以看到一个对象的 finalize()被执行,但是
它仍然可以存活。代码清单 3-2 一次对象自我拯救的演示

/**
* 此代码演示了两点:
* 1.对象可以在被 GC 时自我拯救。
* 2.这种自救的机会只有一次,因为一个对象的 finalize()方法最多只会被系统自动调用一次
*
* @author zzm
*/
public class FinalizeEscapeGC {
 public static FinalizeEscapeGC SAVE_HOOK = null;
 public void isAlive() {
 System.out.println("yes, i am still alive :)");
 }
 @Override
 protected void finalize() throws Throwable {
 super.finalize();
 System.out.println("finalize method executed!");
 FinalizeEscapeGC.SAVE_HOOK = this;
 }
 public static void main(String[] args) throws Throwable {
 SAVE_HOOK = new FinalizeEscapeGC();
 //对象第一次成功拯救自己
 SAVE_HOOK = null;
 System.gc();
 // 因为 Finalizer 方法优先级很低,暂停 0.5 秒,以等待它
 Thread.sleep(500);
 if (SAVE_HOOK != null) {
 SAVE_HOOK.isAlive();
 } else {
 System.out.println("no, i am dead :(");
 }
 // 下面这段代码与上面的完全相同,但是这次自救却失败了
 SAVE_HOOK = null;
 System.gc();
 // 因为 Finalizer 方法优先级很低,暂停 0.5 秒,以等待它
 Thread.sleep(500);
 if (SAVE_HOOK != null) {
 SAVE_HOOK.isAlive();
 } else {
 System.out.println("no, i am dead :(");
 }
 }
}
  • 运行结果
finalize method executed!
yes, i am still alive :) no, i am dead :(

从代码清单 3-2 的运行结果可以看到,SAVE_HOOK 对象的 finalize()方法确实被垃圾收集器触发过,并且在被收集前成功逃脱了。另外一个值得注意的地方就是,代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败了。这是因为任何一个对象的 finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的 finalize()方法不会被再次执行,因此第二段代码的自救行动失败了。还有一点需要特别说明,上面关于对象死亡时 finalize()方法的描述可能带点悲情的艺术加工,笔者并不鼓励大家使用这个方法来拯救对象。

3.2.5 回收方法区

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。

  • 废弃常量

    当常量池中一个常量没有被任何地方引用,如果这时候发生内存回收,而且必要的话,这个常量就会被系统清理出常量池。

  • 无用的类

    该类的所有实例都已经被回收。
    加载该类的ClassLoader已经被回收。
    该类对应的java.lang.Class对象没有在任何地方被引用。

满足上面三个条件的无用类才可以进行回收。这里说的仅仅是"可以",而不是和对象一样,不使用了就必然会回收。

回收废弃常量与回收 Java 堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。
判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常是很难达成的。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java 虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot 虚拟机提供了-Xnoclassgc 参数进行控制,还可以使用-verbose:class 以及-XX:+TraceClass-Loading、-XX:+TraceClassUnLoading 查看类加载和卸载信息,其中-verbose:class 和-XX:+TraceClassLoading 可以在 Product 版的虚拟机中使用,-XX:+TraceClassUnLoading 参数需要 FastDebug 版[1]的虚拟机支持。
在大量使用反射、动态代理、CGLib 等字节码框架,动态生成 JSP 以及 OSGi 这类频繁自定义类加载器的场景中,通常都需要 Java 虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

3.3 垃圾收集算法

从如何判定对象消亡的角度出发,垃圾收集算法可以划分为“引用计数式垃圾收集”(Reference Counting GC)和“追踪式垃圾收集”(Tracing GC)两大类,这两类也常被称作“直接垃圾收集”和“间接垃圾收集”。由于引用计数式垃圾收集算法在本书讨论到的主流 Java 虚拟机中均未涉及,所以我们暂不把它作为正文主要内容来讲解,本节介绍的所有算法均属于追踪式垃圾收集的范畴。

3.3.1 分代收集理论

  • 分代假说
    • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
    • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
    • 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。

3.3.2 标记-清除算法

算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程。
它的主要缺点有两个:

  • 第一个是执行效率不稳定。如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
  • 第二个是内存空间的碎片化。 标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

《深入理解java虚拟机 第三版》学习笔记一_第9张图片

3.3.3 标记-复制算法(一般用于回收新生代)

为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,标记-复制算法 将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

  • 优点:实现简单,运行高效
  • 缺点:浪费空间

如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。
《深入理解java虚拟机 第三版》学习笔记一_第10张图片
现在的商用 Java 虚拟机大多都优先采用了这种收集算法去回收新生代,新生代中的对象有98%熬不过第一轮收集。因此并不需要按照 1∶1 的比例来划分新生代的内存空间。

Appel 式回收的具体做法是把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用Eden 和其中一块 Survivor。发生垃圾搜集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已用过的那块 Survivor空间。HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8∶1,也即每次新生代中可用内存空间为整个新生代容量的 90%(Eden 的 80%加上一个 Survivor 的 10%),只有一个 Survivor 空间,即 10%的新生代是会被“浪费”的。

当然,98%的对象可被回收仅仅是“普通场景”下测得的数据,任何人都没有办法百分百保证每次回收都只有不多于 10%的对象存活,因此 Appel 式回收还有一个充当罕见情况的“逃生门”的安全设计,当Survivor 空间不足以容纳一次 Minor GC 之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。

  • 内存的分配担保:
    • 如果另外一块 Survivor 空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。

3.3.4 标记-整理算法

针对老年代对象的存亡特征,提出了另外一种有针对性的“标记-整理”(Mark-Compact)算法,

其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

《深入理解java虚拟机 第三版》学习笔记一_第11张图片

标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:
如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,这就更加让使用者不得不小心翼翼地权衡其弊端了,像这样的停顿被最初的虚拟机设计者形象地描述为“Stop The World”[2]。
但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过“分区空闲分配链表”来解决内存分配问题(计算机硬盘存储大文件就不要求物理连续的磁盘空间,能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的)。内存的访问是用户程序最频繁的操作,甚至都没有之一,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。
基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。此语境中,吞吐量的实质是赋值器(Mutator,可以理解为使用垃圾收集的用户程序,本书为便于理解,多数地方用“用户程序”或“用户线程”代替)与收集器的效率总和。即使不移动对象会使得收集器的效率提升一些,但因内存分配和访问相比垃圾收集频率要高得多,这部分的耗时增加,总吞吐量仍然是下降的。HotSpot 虚拟机里面关注吞吐量的 Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的 CMS 收集器则是基于标记-清除算法的,这也从侧面印证这点。
另外,还有一种“和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标记-清除算法的 CMS 收集器面临空间碎片过多时采用的就是这种处理办法。

3.4 HotSpot 的算法细节实现

3.4.6 并发的可达性分析

当前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判定对象是否存活的,可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行。在根节点枚举这个步骤中,由于 GC Roots 相比起整个 Java 堆中全部的对象毕竟还算是极少数,且在各种优化技巧(如 OopMap)的加持下,它带来的停顿已经是非常短暂且相对固定(不随堆容量而增长)的了。可从 GC Roots 再继续往下遍历对象图,这一步骤的停顿时间就必定会与 Java 堆容量直接成正比例关系了:堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间自然就更长,这听起来是理所当然的事情。
要知道包含“标记”阶段是所有追踪式垃圾收集算法的共同特征,如果这个阶段会随着堆变大而等比例增加停顿时间,其影响就会波及几乎所有的垃圾收集器,同理可知,如果能够削减这部分停顿时间的话,那收益也将会是系统性的。
想解决或者降低用户线程的停顿,就要先搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?为了能解释清楚这个问题,我们引入三色标记(Tricolor Marking)作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:

  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
    关于可达性分析的扫描过程,读者不妨发挥一下想象力,把它看作对象图上一股以灰色为波峰的波纹从黑向白推进的过程,如果用户线程此时是冻结的,只有收集器线程在工作,那不会有任何问题。但如果用户线程与收集器是并发工作呢?收集器在对象图上标记颜色,同时用户线程在修改引用关系——即修改对象图的结构,这样可能出现两种后果。一种是把原本消亡的对象错误标记为存活,这不是好事,但其实是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好。另一种是把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误,下面表 3-1 演示了这样的致命错误具体是如何产生的。
    《深入理解java虚拟机 第三版》学习笔记一_第12张图片
    Wilson 于 1994 年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:
    ·赋值器插入了一条或多条从黑色对象到白色对象的新引用;
    ·赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning, SATB)。
    增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
    原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
    以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在 HotSpot 虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS 是基于增量更新来做并发标记的,G1、Shenandoah 则是用原始快照来实现。到这里,笔者简要介绍了 HotSpot 虚拟机如何发起内存回收、如何加速内存回收,以及如何保证回收正确性等问题,但是虚拟机如何具体地进行内存回收动作仍然未涉及。因为内存回收如何进行是由虚拟机所采用哪一款垃圾收集器所决定的,而通常虚拟机中往往有多种垃圾收集器,下面笔者将逐一介绍 HotSpot 虚拟机中出现过的垃圾收集器。

3.5 经典垃圾收集器

3.5.1 Serial 收集器 /ˈsɪəriəl/

Serial 收集器是最基础、历史最悠久的收集器。

这个收集器是一个单线程工作的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。

“Stop The World”这个词语也许听起来很酷,但这项工作是由虚拟机在后台自动发起和自动完成的,在用户不可知、不可控的情况下把用户的正常工作的线程全部停掉,这对很多应用来说都是不能接受的。读者不妨试想一下,要是你的电脑每运行一个小时就会暂停响应五分钟,你会有什么样的心情?
《深入理解java虚拟机 第三版》学习笔记一_第13张图片

3.5.2 ParNew 收集器

ParNew 收集器实质上是 Serial 收集器的多线程并行版本

除了同时使用多条线程进行垃圾收集之外,其余的行为包括 Serial 收集器可用的所有控制参数(例如:-XX:SurvivorRatio、-XX: PretenureSizeThreshold、-XX:HandlePromotionFailure 等)、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一致,在实现上这两种收集器也共用了相当多的代码。
《深入理解java虚拟机 第三版》学习笔记一_第14张图片
ParNew 收集器除了支持多线程并行收集之外,其他与 Serial 收集器相比并没有太多创新之处,但它却是不少运行在服务端模式下的 HotSpot 虚拟机,尤其是 JDK 7 之前的遗留系统中首选的新生代收集器,其中有一个与功能、性能无关但其实很重要的原因是:

除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作。

3.5.3 Parallel Scavenge 收集器 / ˈ P Å r ə Lel/ /ˈskévɪndʒ/

Parallel Scavenge 收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器……Parallel Scavenge 的诸多特性从表面上看和 ParNew 非常相似,那它有什么特别之处呢?
Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值,即:
《深入理解java虚拟机 第三版》学习笔记一_第15张图片
如果虚拟机完成某个任务,用户代码加上垃圾收集总共耗费了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;而高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。
Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小的-XX:GCTimeRatio 参数。
-XX:MaxGCPauseMillis 参数允许的值是一个大于 0 的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值。不过大家不要异想天开地认为如果把这个参数的值设置得更小一点就能使得系统的垃圾收集速度变得更快,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调得小一些,收集 300MB 新生代肯定比收集 500MB 快,但这也直接导致垃圾收集发生得更频繁,原来 10 秒收集一次、每次停顿 100 毫秒,现在变成 5 秒收集一次、每次停顿 70 毫秒。停顿时间的确在下降,但吞吐量也降下来了。
-XX:GCTimeRatio 参数的值则应当是一个大于 0 小于 100 的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。譬如把此参数设置为 19,那允许的最大垃圾收集时间就占总时间的 5%(即 1/(1+19)),默认值为 99,即允许最大 1%(即1/(1+99))的垃圾收集时间。
由于与吞吐量关系密切,Parallel Scavenge 收集器也经常被称作“吞吐量优先收集器”。除上述两个参数之外,Parallel Scavenge 收集器还有一个参数-XX:+UseAdaptiveSizePolicy 值得我们关注。这是一个开关参数,当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden 与 Survivor 区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略(GC Ergonomics)。如果读者对于收集器运作不太了解,手工优化存在困难的话,使用Parallel Scavenge 收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成也许是一个很不错的选择。只需要把基本的内存数据设置好(如-Xmx 设置最大堆),然后使用-XX:MaxGCPauseMillis 参数(更关注最大停顿时间)或-XX:GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。自适应调节策略也是 Parallel Scavenge 收集器区别于 ParNew 收集器的一个重要特性。

3.5.4 Serial Old 收集器

Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。

这个收集器的主要意义也是供客户端模式下的 HotSpot 虚拟机使用。如果在服务端模式下,它也可能有两种用途:一种是在 JDK 5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用[1],另外一种就是作为 CMS 收集器发生失败时的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。这两点都将在后面的内容中继续讲解。
《深入理解java虚拟机 第三版》学习笔记一_第16张图片

3.5.5 Parallel Old 收集器

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。

Parallel Old 收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑 Parallel cavenge 加 Parallel Old 收集器这个组合。
《深入理解java虚拟机 第三版》学习笔记一_第17张图片

3.5.6 CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。

目前很大一部分的 Java 应用集中在互联网网站或者基于浏览器的 B/S 系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS 收集器就非常符合这类应用的需求。
从名字(包含“Mark Sweep”)上就可以看出 CMS 收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快;并发标记阶段就是从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(详见 3.4.6 节中关于增量更新的讲解),这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。通过图 3-11 可以比较清楚地看到 CMS 收集器的运作步骤中并发和需要停顿的阶段。
《深入理解java虚拟机 第三版》学习笔记一_第18张图片
CMS 是一款优秀的收集器,它最主要的优点在名字上已经体现出来:并发收集、低停顿,一些官方公开文档里面也称之为“并发低停顿收集器”(Concurrent Low Pause Collector)。CMS 收集器是 HotSpot 虚拟机追求低停顿的第一次成功尝试,但是它还远达不到完美的程度,至少有以下三个明显的缺点:
首先,CMS 收集器对处理器资源非常敏感。事实上,面向并发设计的程序都对处理器资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。CMS 默认启动的回收线程数是(处理器核心数量+3)/4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过 25%的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时, CMS 对用户程序的影响就可能变得很大。如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。为了缓解这种情况,虚拟机提供了一种称为“增量式并发收集器”(Incremental Concurrent Mark Sweep/iCMS)的 CMS 收集器变种,所做的事情和以前单核处理器年代 PC 机操作系统靠抢占式多任务来模拟多核并行多任务的思想一样,是在并发标记、清理的时候让收集器线程、用户线程交替运行,尽量减少垃圾收集线程的独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得较少一些,直观感受是速度变慢的时间更多了,但速度下降幅度就没有那么明显。实践证明增量式的 CMS 收集器效果很一般,从 JDK 7 开始,i-CMS 模式已经被声明为“deprecated”,即已过时不再提倡用户使用,到JDK 9 发布后 i-CMS 模式被完全废弃。
然后,由于 CMS 收集器无法处理“浮动垃圾”(Floating Garbage),有可能出现“Con-current Mode Failure”失败进而导致另一次完全“Stop The World”的 Full GC 的产生。

  • 浮动垃圾 :
    在 CMS 的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS 无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。

同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此 CMS 收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。在 JDK5 的默认设置下,CMS 收集器当老年代使用了 68%的空间后就会被激活,这是一个偏保守的设置,如果在实际应用中老年代增长并不是太快,可以适当调高参数-XX:CMSInitiatingOccu-pancyFraction 的值来提高 CMS 的触发百分比,降低内存回收频率,获取更好的性能。到了 JDK 6 时,CMS 收集器的启动阈值就已经默认提升至 92%。但这又会更容易面临另一种风险:

  • 要是 CMS 运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了。所以参数-XX:CMSInitiatingOccupancyFraction 设置得太高将会很容易导致大量的并发失败产生,性能反而降低,用户应在生产环境中根据实际应用情况来权衡设置。

还有最后一个缺点,在本节的开头曾提到,CMS 是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次 Full GC 的情况。为了解决这个问题, CMS 收集器提供了一个-XX:+UseCMS-CompactAtFullCollection 开关参数(默认是开启的,此参数从 JDK 9 开始废弃),用于在 CMS 收集器不得不进行 Full GC 时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,(在 Shenandoah 和 ZGC 出现前)是无法并发的。这样空间碎片问题是解决了,但停顿时间又会变长,因此虚拟机设计者们还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction(此参数从 JDK 9 开始废弃),这个参数的作用是要求 CMS 收集器在执行过若干次(数量由参数值决定)不整理空间的 Full GC 之后,下一次进入 Full GC 前会先进行碎片整理(默认值为 0,表示每次进入 Full GC 时都进行碎片整理)。

3.5.7 Garbage First 收集器

Garbage First(简称 G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式。
G1 是一款主要面向服务端应用的垃圾收集器。
作为 CMS 收集器的替代者和继承人,设计者们希望做出一款能够建立起“停顿时间模型”(Pause Prediction Model)的收集器,停顿时间模型的意思是能够支持指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过 N 毫秒这样的目标,这几乎已经是实时 Java(RTSJ)的中软实时垃圾收集器特征了。
那具体要怎么做才能实现这个目标呢?首先要有一个思想上的改变,在 G1 收集器出现之前的所有其他收集器,包括 CMS 在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个 Java 堆(Full GC)。
而 G1 跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称 CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是 G1 收集器的 Mixed GC 模式。
G1 开创的基于 Region(区域) 的堆内存布局是它能够实现这个目标的关键。虽然 G1 也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:

G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region(区域) 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。收集器能够对扮演不同角色的 Region 采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

Region 中还有一类特殊的 Humongous([hjuːˈmʌŋɡəs]) 区域,专门用来存储大对象。G1 认为只要大小超过了一个 Region 容量一半的对象即可判定为大对象。每个 Region 的大小可以通过参数-XX:G1HeapRegionSize 设定,取值范围为 1MB~32MB,且应为 2 的 N 次幂。而对于那些超过了整个 Region 容量的超级大对象,将会被存放在 N 个连续的 Humongous Region 之中,G1 的大多数行为都把 Humongous Region 作为老年代的一部分来进行看待,如图 3-12 所示。
《深入理解java虚拟机 第三版》学习笔记一_第19张图片

虽然 G1 仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。

G1 收集器之所以能建立可预测的停顿时间模型,是因为它将 Region 作为单次回收的最小单元,即每次收集到的内存空间都是 Region 大小的整数倍,这样可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。更具体的处理思路是让 G1 收集器去跟踪各个 Region 里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是 200 毫秒),优先处理回收价值收益最大的那些 Region,这也就是“Garbage First”名字的由来。这种使用 Region 划分内存空间,以及具有优先级的区域回收方式,保证了 G1 收集器在有限的时间内获取尽可能高的收集效率。

G1 收集器至少有(不限于)以下这些关键的细节问题需要妥善解决:
·譬如,将 Java 堆分成多个独立 Region 后,Region 里面存在的跨 Region 引用对象如何解决?解决的思路我们已经知道(见 3.3.1 节和 3.4.4 节):使用记忆集避免全堆作为 GC Roots 扫描,但在 G1 收集器上记忆集的应用其实要复杂很多,它的每个 Region都维护有自己的记忆集,这些记忆集会记录下别的 Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。G1 的记忆集在存储结构的本质上是一种哈希表,Key 是别的 Region 的起始地址,Value 是一个集合,里面存储的元素是卡表的索引号。这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来更复杂,同时由于 Region 数量比传统收集器的分代数量明显要多得多,因此 G1 收集器要比其他的传统垃圾收集器有着更高的内存占用负担。根据经验,G1 至少要耗费大约相当于 Java 堆容量 10%至 20%的额外内存来维持收集器工作。
·譬如,在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?这里首先要解决的是用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误,该问题的解决办法笔者已经抽出独立小节来讲解过(见 3.4.6节):CMS 收集器采用增量更新算法实现,而 G1 收集器则是通过原始快照(SATB)算法来实现的。此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1 为每一个 Region 设计了两个名为 TAMS(Top at Mark Start)的指针,把 Region 中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1 收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。与 CMS 中的“Concurrent Mode Failure”失败会导致 Full GC 类似,如果内存回收的速度赶不上内存分配的速度, G1 收集器也要被迫冻结用户线程执行,导致 Full GC 而产生长时间“Stop The World”。
·譬如,怎样建立起可靠的停顿预测模型?用户通过-XX:MaxGCPauseMillis 参数指定的停顿时间只意味着垃圾收集发生之前的期望值,但 G1 收集器要怎么做才能满足用户的期望呢?G1 收集器的停顿预测模型是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中,G1 收集器会记录每个 Region 的回收耗时、每个Region 记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。这里强调的“衰减平均值”是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确地代表“最近的”平均状态。换句话说,Region 的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些 Region 组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。
如果我们不去计算用户线程运行过程中的动作(如使用写屏障维护记忆集的操作),G1 收集器的运作过程大致可划分为以下四个步骤:

  • 初始标记(Initial Marking):仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region 中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。
  • 并发标记(Concurrent Marking):从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理 SATB 记录下的在并发时有引用变动的对象。
  • 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。
  • 筛选回收(Live Data Counting and Evacuation):负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

从上述阶段的描述可以看出,G1 收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,换言之,它并非纯粹地追求低延迟,官方给它设定的标是在延迟可控的情况下获得尽可能高的吞吐量,所以才能担当起“全功能收集器”的重任与期望。从Oracle 官方透露出来的信息可获知,回收阶段(Evacuation)其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到 G1 只是回收一部分Region,停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了G1 之后出现的低延迟垃圾收集器(即 ZGC)中。另外,还考虑到 G1 不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。通过图 3-13 可以比较清楚地看到 G1 收集器的运作步骤中并发和需要停顿的阶段。
《深入理解java虚拟机 第三版》学习笔记一_第20张图片

G1 收集器常会被拿来与 CMS 收集器互相比较,毕竟它们都非常关注停顿时间的控制。
相比 CMS,G1 的优点有很多,暂且不论可以指定最大停顿时间、分 Region 的内存布局、按收益动态确定回收集这些创新性设计带来的红利,单从最传统的算法理论上看,G1 也更有发展潜力。与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个 Region 之间)上看又是基于“标记-复制”算法实现,无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次收集。
比起 CMS,G1 的弱项也可以列举出不少,如在用户程序运行过程中,G1 无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比 CMS 要高。
就内存占用来说,虽然 G1 和 CMS 都使用卡表来处理跨代指针,但 G1 的卡表实现更为复杂,而且堆中每个 Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致 G1 的记忆集(和其他内存消耗)可能会占整个堆容量的 20%乃至更多的内存空间;相比起来 CMS 的卡表就相当简单,只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要,由于新生代的对象具有朝生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的。
在执行负载的角度上,同样由于两个收集器各自的细节实现特点导致了用户程序运行时的负载会有不同,譬如它们都使用到写屏障,CMS 用写后屏障来更新维护卡表;而 G1 除了使用写后屏障来进行同样的(由于 G1 的卡表结构复杂,其实是更烦琐的)卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免 CMS 那样在最终标记阶段停顿时间过长的缺点,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。由于 G1 对写屏障的复杂操作要比 CMS 消耗更多的运算资源,所以 CMS 的写屏障实现是直接的同步操作,而 G1就不得不将其实现为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。
按照笔者的实践经验,目前在小内存应用上 CMS 的表现大概率仍然要会优于 G1,而在大内存应用上 G1 则大多能发挥其优势,这个优劣势的 Java 堆容量平衡点通常在 6GB 至 8GB 之间。

3.6 低延迟垃圾收集器

衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量(Throughput)和延迟(Latency),三者共同构成了一个“不可能三角”。

3.6.1 Shenandoah 收集器

Shenandoah 是一款只有 OpenJDK 才会包含,而 OracleJDK 里反而不存在的收集器,“免费开源版”比“收费商业版”功能更多,这是相对罕见的状况[2]。
Shenandoah 的目标是实现一种能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的垃圾收集器,该目标意味着相比 CMS 和 G1,Shenandoah 不仅要进行并发的垃圾标记,还要并发地进行对象清理后的整理动作。,
Shenandoah 更像是 G1 的下一代继承者,它们两者有着相似的堆内存布局,在初始标记、并发标记等许多阶段的处理思路上都高度一致,甚至还直接共享了一部分实现代码,这使得部分对 G1 的打磨改进和 Bug 修改会同时反映在 Shenandoah 之上,而由于Shenandoah 加入所带来的一些新特性,也有部分会出现在 G1 收集器中,譬如在并发失败后作为“逃生门”的 Full GC,G1 就是由于合并了 Shenandoah 的代码才获得多线程 Full GC 的支持。
那 Shenandoah 相比起 G1 又有什么改进呢?虽然 Shenandoah 也是使用基于 Region的堆内存布局,同样有着用于存放大对象的 Humongous Region,默认的回收策略也同样是优先处理回收价值最大的 Region……但在管理堆内存方面,它与 G1 至少有三个明显的不同之处,最重要的当然是支持并发的整理算法,G1 的回收阶段是可以多线程并行的,但却不能与用户线程并发,这点作为 Shenandoah 最核心的功能稍后笔者会着重讲解。其次,Shenandoah(目前)是默认不使用分代收集的,换言之,不会有专门的新生代 Region 或者老年代 Region 的存在,没有实现分代,并不是说分代对 Shenandoah 没有价值,这更多是出于性价比的权衡,基于工作量上的考虑而将其放到优先级较低的位置上。最后, Shenandoah 摒弃了在 G1 中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection Matrix)的全局数据结构来记录跨 Region 的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题(见 3.4.4 节)的发生概率。连接矩阵可以简单理解为一张二维表格,如果 Region N 有对象指向 Region M,就在表格的 N 行 M 列中打上一个标记,如图 3-15 所示,如果 Region 5 中的对象 Baz 引用了 Region 3 的 Foo,Foo 又引用了 Region 1 的 Bar,那连接矩阵中的 5 行 3 列、3 行 1列就应该被打上标记。在回收时通过这张表格就可以得出哪些 Region 之间产生了跨代引用。
Shenandoah 收集器的工作过程大致可以划分为以下九个阶段(此处以 Shenandoah在 2016 年发表的原始论文[4]进行介绍。在最新版本的 Shenandoah 2.0 中,进一步强化了“部分收集”的特性,初始标记之前还有 Initial Partial、Concurrent Partial 和 Final Partial阶段,它们可以不太严谨地理解为对应于以前分代收集中的 Minor GC 的工作):
《深入理解java虚拟机 第三版》学习笔记一_第21张图片

  • 初始标记(Initial Marking):与 G1 一样,首先标记与 GC Roots 直接关联的对象,这个阶段仍是“Stop The World”的,但停顿时间与堆大小无关,只与 GC Roots 的数量相关。
  • 并发标记(Concurrent Marking):与 G1 一样,遍历对象图,标记出全部可达的对象,这个阶段是与用户线程一起并发的,时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度。
  • 最终标记(Final Marking):与 G1 一样,处理剩余的 SATB 扫描,并在这个阶段统计出回收价值最高的 Region,将这些 Region 构成一组回收集(Collection Set)。最终标记阶段也会有一小段短暂的停顿。
  • 并发清理(Concurrent Cleanup):这个阶段用于清理那些整个区域内连一个存活对象都没有找到的 Region(这类 Region 被称为 Immediate Garbage Region)。
  • 并发回收(Concurrent Evacuation):并发回收阶段是 Shenandoah 与之前 HotSpot 中其他收集器的核心差异。在这个阶段,Shenandoah 要把回收集里面的存活对象先复制一份到其他未被使用的 Region 之中。复制对象这件事情如果将用户线程冻结起来再做那是相当简单的,但如果两者必须要同时并发进行的话,就变得复杂起来了。其困难点是在移动对象的同时,用户线程仍然可能不停对被移动的对象进行读写访问,移动对象是一次性的行为,但移动之后整个内存中所有指向该对象的引用都还是旧对象的地址,这是很难一瞬间全部改变过来的。对于并发回收阶段遇到的这些困难,Shenandoah 将会通过读屏障和被称为“Brooks Pointers”的转发指针来解决(讲解完 Shenandoah 整个工作过程之后笔者还要再回头介绍它)。并发回收阶段运行的时间长短取决于回收集的大小。
  • 初始引用更新(Initial Update Reference):并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。引用更新的初始化阶段实际上并未做什么具体的处理,设立这个阶段只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务而已。初始引用更新时间很短,会产生一个非常短暂的停顿。
  • 并发引用更新(Concurrent Update Reference):真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。并发引用更新与并发标记不同,它不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。
  • 最终引用更新(Final Update Reference):解决了堆中的引用更新后,还要修正存在于 GC Roots 中的引用。这个阶段是 Shenandoah 的最后一次停顿,停顿时间只与 GC Roots 的数量相关。
  • 并发清理(Concurrent Cleanup):经过并发回收和引用更新之后,整个回收集中所有的 Region 已再无存活对象,这些 Region 都变成 Immediate Garbage Regions 了,最后再调用一次并发清理过程来回收这些 Region 的内存空间,供以后新对象分配使用。

3.7 选择合适的垃圾收集器

3.7.2 收集器的权衡

如果你有充足的预算但没有太多调优经验,那么一套带商业技术支持的专有硬件或者软件解决方案是不错的选择,Azul 公司以前主推的 Vega 系统和现在主推的 Zing VM 是这方面的代表,这样你就可以使用传说中的 C4 收集器了。
如果你虽然没有足够预算去使用商业解决方案,但能够掌控软硬件型号,使用较新的版本,同时又特别注重延迟,那 ZGC 很值得尝试。
如果你对还处于实验状态的收集器的稳定性有所顾虑,或者应用必须运行在 Windows 操作系统下,那 ZGC 就无缘了,试试 Shenandoah 吧。
如果你接手的是遗留系统,软硬件基础设施和 JDK 版本都比较落后,那就根据内存规模衡量一下,对于大概 4GB 到 6GB 以下的堆内存,CMS 一般能处理得比较好,而对于更大的堆内存,可重点考察一下 G1。

3.7.3 虚拟机及垃圾收集器日志

《深入理解java虚拟机 第三版》学习笔记一_第22张图片
《深入理解java虚拟机 第三版》学习笔记一_第23张图片

3.7.4 垃圾收集器参数总结

《深入理解java虚拟机 第三版》学习笔记一_第24张图片
《深入理解java虚拟机 第三版》学习笔记一_第25张图片

3.8 实战:内存分配与回收策略

对象的内存分配,从概念上讲,应该都是在堆上分配(而实际上也有可能经过即时编译后被拆散为标量类型并间接地在栈上分配[1])。在经典分代的设计下,新生对象通常会分配在新生代中,少数情况下(例如对象大小超过一定阈值)也可能会直接分配在老年代。对象分配的规则并不是固定的,《Java 虚拟机规范》并未规定新对象的创建和存储细节,这取决于虚拟机当前使用的是哪一种垃圾收集器,以及虚拟机中与内存相关的参数的设定。

3.8.1 对象优先在 Eden 分配

大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。

Minor GC触发条件:
当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。

HotSpot 虚拟机提供了-XX:+PrintGCDetails 这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。在实际的问题排查中,收集器日志常会打印到文件后通过工具进行分析,不过本节实验的日志并不多,直接阅读就能看得很清楚。
在代码清单 3-7 的 testAllocation()方法中,尝试分配三个 2MB 大小和一个 4MB 大小的对象,在运行时通过-Xms20M、-Xmx20M、-Xmn10M这三个参数限制了 Java 堆大小为 20MB,不可扩展,其中 10MB 分配给新生代,剩下的 10MB 分配给老年代。-XX:Survivor-Ratio=8 决定了新生代中 Eden 区与一个 Survivor 区的空间比例是 8∶1,从输出的结果也清晰地看到“eden space 8192K、from space 1024K、to space 1024K”的信息,新生代总可用空间为 9216KB(Eden 区+1 个 Survivor 区的总容量)。
执行 testAllocation()中分配 allocation4 对象的语句时会发生一次 Minor GC,这次回收的结果是新生代 6651KB 变为 148KB,而总内存占用量则几乎没有减少(因为 allocation1、2、3 三个对象都是存活的,虚拟机几乎没有找到可回收的对象)。产生这次垃圾收集的原因是为 allocation4 分配内存时,发现 Eden 已经被占用了 6MB,剩余空间已不足以分配allocation4 所需的 4MB 内存,因此发生 Minor GC。垃圾收集期间虚拟机又发现已有的三个 2MB 大小的对象全部无法放入 Survivor 空间(Survivor空间只有 1MB 大小),所以只好通过分配担保机制提前转移到老年代去。
这次收集结束后,4MB 的 allocation4 对象顺利分配在 Eden 中。因此程序执行完的结果是 Eden 占用 4MB(被 allocation4 占用),Survivor 空闲,老年代被占用 6MB(被 allocation1、2、3 占用)。通过 GC 日志可以证实这一点。

private static final int _1MB = 1024 * 1024;
/**
* VM 参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -
XX:SurvivorRatio=8
*/
public static void testAllocation() {
 byte[] allocation1, allocation2, allocation3, allocation4;
 allocation1 = new byte[2 * _1MB];
 allocation2 = new byte[2 * _1MB];
 allocation3 = new byte[2 * _1MB];
 allocation4 = new byte[4 * _1MB]; // 出现一次 Minor GC
}

运行结果:

[GC [DefNew: 6651K->148K(9216K), 0.0070106 secs] 6651K->6292K(19456K), 0.0070426 
secs] [Times: user=0.00 Heap
 def new generation total 9216K, used 4326K [0x029d0000, 0x033d0000, 
0x033d0000) 
 eden space 8192K, 51% used [0x029d0000, 0x02de4828, 0x031d0000)
 from space 1024K, 14% used [0x032d0000, 0x032f5370, 0x033d0000) 
 to space 1024K, 0% used [0x031d0000, 0x031d0000, 0x032d0000) 
 tenured generation total 10240K, used 6144K [0x033d0000, 0x03dd0000, 
0x03dd0000) 
 the space 10240K, 60% used [0x033d0000, 0x039d0030, 0x039d0200, 
0x03dd0000) 
 compacting perm gen total 12288K, used 2114K [0x03dd0000, 0x049d0000, 
0x07dd0000) 
 the space 12288K, 17% used [0x03dd0000, 0x03fe0998, 0x03fe0a00, 
0x049d0000) 
 No shared spaces configured.

3.8.2 大对象直接进入老年代

大对象就是指需要大量连续内存空间的 Java 对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组,本节例子中的 byte[]数组就是典型的大对象。大对象对虚拟机的内存分配来说就是一个不折不扣的坏消息,比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,我们写程序的时候应注意避免。在 Java 虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复制对象时,大对象就意味着高额的内存复制开销。
HotSpot 虚拟机提供了-XX:PretenureSizeThreshold 参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在 Eden 区及两个 Survivor 区之间来回复制,产生大量的内存复制操作。
执行代码清单 3-8 中的 testPretenureSizeThreshold()方法后,我们看到Eden 空间几乎没有被使用,而老年代的 10MB 空间被使用了 40%,也就是 4MB 的 allocation 对象直接就分配在老年代中,这是因为-XX:PretenureSizeThreshold 被设置为 3MB(就是 3145728,这个参数不能与-Xmx 之类的参数一样直接写 3MB),因此超过 3MB 的对象都会直接在老年代进行分配。

  • 注意 -XX:PretenureSizeThreshold 参数只对 Serial 和 ParNew 两款新生代收集器有效,HotSpot 的其他新生代收集器,如 Parallel Scavenge 并不支持这个参数。如果必须使用此参数进行调优,可考虑 ParNew 加 CMS 的收集器组合。
private static final int _1MB = 1024 * 1024;
/**
* VM 参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -
XX:SurvivorRatio=8
* -XX:PretenureSizeThreshold=3145728
*/
public static void testPretenureSizeThreshold() {
 byte[] allocation;
 allocation = new byte[4 * _1MB]; //直接分配在老年代中
}

运行结果

Heap def new generation total 9216K, used 671K [0x029d0000, 0x033d0000, 
0x033d0000) 
 eden space 8192K, 8% used [0x029d0000, 0x02a77e98, 0x031d0000) 
 from space 1024K, 0% used [0x031d0000, 0x031d0000, 0x032d0000) 
 to space 1024K, 0% used [0x032d0000, 0x032d0000, 0x033d0000)
 tenured generation total 10240K, used 4096K [0x033d0000, 
0x03dd0000, 0x03dd0000) 
 the space 10240K, 40% used [0x033d0000, 0x037d0010, 0x037d0200, 
0x03dd0000) 
 compacting perm gen total 12288K, used 2107K [0x03dd0000, 
0x049d0000, 0x07dd0000) 
 the space 12288K, 17% used [0x03dd0000, 0x03fdefd0, 0x03fdf000, 
0x049d0000) 
 No shared spaces configured.

3.8.3 长期存活的对象将进入老年代

HotSpot 虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中(详见第 2 章)。对象通常在 Eden 区里诞生,如果经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,该对象会被移动到 Survivor 空间中,并且将其对象年龄设为 1 岁。对象在Survivor 区中每熬过一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX: MaxTenuringThreshold 设置。
读者可以试试分别以-XX:MaxTenuringThreshold=1 和-XX:MaxTenuringThreshold=15 两种设置来执行代码清单 3-9 中的testTenuringThreshold()方法,此方法中 allocation1 对象需要 256KB 内存,Survivor 空间可以容纳。当-XX:MaxTenuringThreshold=1 时,allocation1对象在第二次 GC 发生时进入老年代,新生代已使用的内存在垃圾收集以后非常干净地变成 0KB。而当-XX:MaxTenuringThreshold=15 时,第二次GC 发生后,allocation1 对象则还留在新生代 Survivor 空间,这时候新生代仍然有 404KB 被占用。

private static final int _1MB = 1024 * 1024;
/**
* VM 参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -
XX:Survivor-
* Ratio=8 -XX:MaxTenuringThreshold=1
* -XX:+PrintTenuringDistribution
*/
@SuppressWarnings("unused")
public static void testTenuringThreshold() {
 byte[] allocation1, allocation2, allocation3;
 allocation1 = new byte[_1MB / 4]; // 什么时候进入老年代决定于
XX:MaxTenuring-
 Threshold 设置 allocation2 = new byte[4 * _1MB];
 allocation3 = new byte[4 * _1MB];
 allocation3 = null;
 allocation3 = new byte[4 * _1MB];
}

以-XX:MaxTenuringThreshold=1 参数来运行的结果:

[GC [DefNew Desired Survivor size 524288 bytes, new threshold 1 (max 1)
- age 1: 414664 bytes, 414664 total
: 4859K->404K(9216K), 0.0065012 secs] 4859K->4500K(19456K), 0.0065283 secs] 
[Times: user=0.02 sys=0.00,
[GC [DefNew Desired Survivor size 524288 bytes, new threshold 1 (max 1)
: 4500K->0K(9216K), 0.0009253 secs] 8596K->4500K(19456K), 0.0009458 secs] 
[Times: user=0.00 sys=0.00, re Heap def new generation total 9216K, used 4178K 
[0x029d0000, 0x033d0000, 0x033d0000) 
 eden space 8192K, 51% used [0x029d0000, 0x02de4828, 0x031d0000) 
 from space 1024K, 0% used [0x031d0000, 0x031d0000, 0x032d0000) 
 to space 1024K, 0% used [0x032d0000, 0x032d0000, 0x033d0000)
 tenured generation total 10240K, used 4500K [0x033d0000, 0x03dd0000, 
0x03dd0000) 
 the space 10240K, 43% used [0x033d0000, 0x03835348, 0x03835400, 0x03dd0000) 
 com\pacting perm gen total 12288K, used 2114K [0x03dd0000, 0x049d0000, 
0x07dd0000) 
 the space 12288K, 17% used [0x03dd0000, 0x03fe0998, 0x03fe0a00, 0x049d0000) 
 No shared spaces configured.

以-XX:MaxTenuringThreshold=15 参数来运行的结果:

[GC [DefNew Desired Survivor size 524288 bytes, new threshold 15 (max 15)
- age 1: 414664 bytes, 414664 total
: 4859K->404K(9216K), 0.0049637 secs] 4859K->4500K(19456K), 0.0049932 secs] 
[Times: user=0.00 sys=0.00, [GC [DefNew Desired Survivor size 524288 bytes, new 
threshold 15 (max 15)
- age 2: 414520 bytes, 414520 total
: 4500K->404K(9216K), 0.0008091 secs] 8596K->4500K(19456K), 0.0008305 secs]
[Times: user=0.00 sys=0.00, Heap def new generation total 9216K, used 4582K 
[0x029d0000, 0x033d0000, 0x033d0000) 
 eden space 8192K, 51% used [0x029d0000, 0x02de4828, 0x031d0000) 
 from space 1024K, 39% used [0x031d0000, 0x03235338, 0x032d0000) 
 to space 1024K, 0% used [0x032d0000, 0x032d0000, 0x033d0000)
 tenured generation total 10240K, used 4096K [0x033d0000, 0x03dd0000, 
0x03dd0000) 
 the space 10240K, 40% used [0x033d0000, 0x037d0010, 0x037d0200, 0x03dd0000) 
 compacting perm gen total 12288K, used 2114K [0x03dd0000, 0x049d0000, 
0x07dd0000) 
 the space 12288K, 17% used [0x03dd0000, 0x03fe0998, 0x03fe0a00, 0x049d0000) 
 No shared spaces configured.

3.8.4 动态对象年龄判定

为了能更好地适应不同程序的内存状况,HotSpot 虚拟机并不是永远要求对象的年龄必须达到 XX:MaxTenuringThreshold 才能晋升老年代,

如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX: MaxTenuringThreshold 中要求的年龄。

执行代码清单 3-10 中的 testTenuringThreshold2()方法,并将设置-XX:MaxTenuring-Threshold=15,发现运行结果中 Survivor 占用仍然为0%,而老年代比预期增加了 6%,也就是说 allocation1、allocation2 对象都直接进入了老年代,并没有等到 15 岁的临界年龄。因为这两个对象加起来已经到达了 512KB,并且它们是同年龄的,满足同年对象达到Survivor 空间一半的规则。我们只要注释掉其中一个对象的 new 操作,就会发现另外一个就不会晋升到老年代了。

private static final int _1MB = 1024 * 1024;
/**
* VM 参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -
XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15
* -XX:+PrintTenuringDistribution
*/
@SuppressWarnings("unused")
public static void testTenuringThreshold2() {
 byte[] allocation1, allocation2, allocation3, allocation4;
 allocation1 = new byte[_1MB / 4]; // allocation1+allocation2 大于
survivo 空间一半
 allocation2 = new byte[_1MB / 4];
 allocation3 = new byte[4 * _1MB];
 allocation4 = new byte[4 * _1MB];
 allocation4 = null;
 allocation4 = new byte[4 * _1MB];
}

运行结果:

[GC [DefNew Desired Survivor size 524288 bytes, new threshold 1 (max 15)
- age 1: 676824 bytes, 676824 total
: 5115K->660K(9216K), 0.0050136 secs] 5115K->4756K(19456K), 0.0050443 
secs] 
[Times: user=0.00 sys=0.01, [GC [DefNew Desired Survivor size 524288 
bytes, new threshold 15 (max 15)
: 4756K->0K(9216K), 0.0010571 secs] 8852K->4756K(19456K), 0.0011009 secs] 
[Times: user=0.00 sys=0.00, re Heap def new generation total 9216K, 
used 4178K [0x029d0000, 0x033d0000, 0x033d0000) 
 eden space 8192K, 51% used [0x029d0000, 0x02de4828, 0x031d0000) 
 from space 1024K, 0% used [0x031d0000, 0x031d0000, 0x032d0000) 
 to space 1024K, 0% used [0x032d0000, 0x032d0000, 0x033d0000)
 tenured generation total 10240K, used 4756K [0x033d0000, 
0x03dd0000, 0x03dd0000) 
 the space 10240K, 46% used [0x033d0000, 0x038753e8, 0x03875400, 
0x03dd0000) 
 compacting perm gen total 12288K, used 2114K [0x03dd0000, 
0x049d0000, 0x07dd0000) 
 the space 12288K, 17% used [0x03dd0000, 0x03fe09a0, 0x03fe0a00, 
0x049d0000) 
 No shared spaces configured.

3.8.5 空间分配担保

在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Minor GC 可以确保是安全的。如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure 参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者-XX:HandlePromotionFailure 设置不允许冒险,那这时就要改为进行一次 Full GC。
解释一下“冒险”是冒了什么风险:前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个 Survivor 空间来作为轮换备份,因此当出现大量对象在 Minor GC 后仍然存活的情况 ——最极端的情况就是内存回收后新生代中所有对象都存活,需要老年代进行分配担保,把 Survivor 无法容纳的对象直接送入老年代,这与生活中贷款担保类似。

JDK 6 Update 24 之后 -XX:HandlePromotionFailure 不再被使用,规则变为

在发生 Minor GC 之前,只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行Full GC。

你可能感兴趣的:(JVM,java,jvm)