JVM(2):垃圾收集器
相比于程序计数器、虚拟机栈和本地方法栈这种应分配的内存大小在类确定时就已固定下来的内存区域,Java
堆和方法区这两个区域有着不确定性:一个接口可能有多个实现类,一个方法可能有多个分支。垃圾收集器所关注的也就是这部分不确定的内存。
1. 对象状态
在进行垃圾收集之前,垃圾收集器需要对对象的状态进行判断,即判断其是否仍在被使用。
1.1 引用计数算法
引用计数 ( $Reference\ \ Counting$ ) 算法,可以简单的解释为在对象中添加一个引用计数器,当对象被引用时计数器便加一,反之减一。如此往复,当对象的引用计数器为零时,可以认为其不再被使用。虽然这种算法简单而且效率很高,但是因为存在着很多例外情况,因此不被主流的JVM
所采纳。比较简单的例子就比如存在对象 $A$ 和 $B$ ,它们都存在着一个字段互相引用对方,这样它们的引用计数器就都为 $1$ 。但实际上,除了互相引用之外,它们没有被其他对象引用,可以认为是无用的对象,应该被垃圾收集。另一方面,它们的引用计数又不为 $0$ ,无法触发垃圾收集。因此,引用计数算法需要配合大量额外处理才能保证正常工作。
1.2 可达性分析算法
可达性分析 ( $Reachability\ \ Analysis$ ) 算法,基本思路就是通过一系列的称为GC Roots
的根对象出发,不断搜索引用关系,搜索路径称为引用链 ( $Reference\ \ Chain$ )。当一个对象没有被搜索到,或者称不可达时,就认为这个对象不再被使用。在Java
技术体系里固定可作为GC Roots
的对象包括:
- 虚拟机栈中的引用对象
- 方法区中类静态属性的引用对象
- 方法区中常量引用对象
- 本地方法栈中
JNI
( 即 $Native$ 方法 ) 的引用对象 JVM
内部引用,如基本数据类型对应的 $Class$ 对象,一些常驻的异常对象等,还有系统类加载器- 同步锁 ( $synchronized$ 关键字 ) 持有的对象
- 反应
JVM
内部情况的JMXBean
、JVMTI
中注册的回调、本地代码缓存等
除此之外,根据用户选择的垃圾收集器以及当前收集的内存区域,还会有临时对象的加入。譬如要针对某个特定区域进行垃圾收集时,不仅要加入该区域的,还要加入关联区域的其他对象。虽然现在查找引用链的过程已经可以做到并发,但迄今为止,所有收集器在根节点枚举这一步骤都要暂停用户线程。
1.3 对象引用
在JDK 1.2
之前,Java
对应用的定义为:如果 $reference$ 类型的数据中存储的数值代表的是另一块内存的起始地址,就称其代表某块内存或某块对象的引用。而在JDK 1.2
之后,Java
扩充了引用的概念,分为 $4$ 种引用强度:
- 强引用 ( $Strong\ \ Reference$ ):强引用是最传统的引用方式,即指引用赋值。
- 软引用 ( $Soft\ \ Reference$ ):软引用指一些有用但非必须的对象。当系统将发生内存溢出时,软引用对象会被回收。
JDK 1.2
之后提供了 $SoftReference$ 类实现软引用。 - 弱引用 ( $Weak\ \ Reference$ ):弱引用指非必须对象,但强度比软引用更弱。无论内存是否足够,都会被回收。
JDK 1.2
之后提供了 $WeakReference$ 类实现弱引用。 - 虚引用 ( $Phantom\ \ Reference$ ):虚引用是最弱的引用关系,不会对对象生命周期造成影响,也不能通过虚引用取得对象实例。关联虚引用只是为了在对象被回收之前获得系统通知。
JDK 1.2
之后提供了 $PhantomReference$ 类实现虚引用。虚引用需要与 $ReferenceQueue$ 联合使用,当垃圾收集器发现对象存在虚引用时,就会在回收前将虚引用加到与之关联的引用队列中。
1.4 对象死亡
一个对象的死亡至少需要经历两次标记过程:第一次为可达性分析,第二次为筛选,筛选覆盖了 $finalize(\ )$ 方法或者执行过 $finalize(\ )$ 方法的对象。如果 $finalize(\ )$ 方法未被覆盖或者未被执行,那么对象会进入 $F-Queue$ 队列,并在之后由一条自动建立的低优先级的 $Finalizer$ 线程执行其 $finalize(\ )$ 方法。稍后收集器会检查队列中的对象,如果对象在 $finalize(\ )$ 方法中建立了与其他对象的关联,那么它就会被移出队列。需要注意的是,为了避免长时间等待或者进入死循环,收集器并不会等待对象的 $finalize(\ )$ 方法执行完毕,而且一个对象的 $finalize(\ )$ 方法只能调用一次,因此有可能在下次对象回收时该对象不能再次自救。
1.5 方法区回收
正如之前所说的,方法区可以不实现垃圾收集,而且方法区进行垃圾收集的性价比也不高。方法区的垃圾收集包括废弃常量和不再使用的类型。相比于前者,后者的判定难度就大得多了。判断一个类型不再使用需要满足以下条件:
- 该类所有实例均已回收。
- 该类的类加载器已回收。
- 该类的 $Class$ 对象没有被引用,无法通过反射方式访问该类方法。
是否对类型进行回收,HotSpot
虚拟机中可以通过 $+Xnoclassgc$ 参数控制,还可以使用 $-verbose:class$、$-XX:+TraceClassLoading$、$-XX:+TraceClassUnLoading$ 查看类加载和卸载信息,前两者可以在Product
版中使用,后者需要在FastDebug
版中使用。
2. 垃圾收集算法
垃圾收集算法可以分为引用计数式垃圾收集和追踪式垃圾收集两大类,它们也被称作直接垃圾收集和间接垃圾收集。
2.1 分代收集理论
分代收集理论建立在两个假说上:
- 弱分代假说 ( $Weak\ \ Generational\ \ Hypothesis$ ):绝大多数对象都是朝生夕灭的。
- 强分代假说 ( $Strong\ \ Generational\ \ Hypothesis$ ):经过越多次垃圾收集的对象越难以消亡。
根据这两个假说,多款垃圾收集器中都给Java
堆中划分出不同的区域,每次回收根据对象的年龄,将对象分配到不同区域中。在划分出不同的区域后,垃圾收集器可以每次只回收某个或某些特定区域。在Hotspot
虚拟机中,Java
堆被分为新生代 ( $Young\ \ Generation$ ) 和老年代 ( $Old\ \ Generation$ ) 两个区域。但是分代收集并没有想象的那么简单,因为对象不是孤立的,跨代引用是存在的。在此基础上有了第三条经验法则:
- 跨代引用假说 ( $Intergenerational\ \ Reference\ \ Hypothesis$ ):跨代引用相对于同代引用来说仅占少数。
隐含推论是:互相引用的两个对象,倾向于同时生存或者同时消亡。因此没必要再为跨代引用去扫描老年代,只需要在新生代上建立一个全局数据结构,称为记忆集 ( $Remembered\ \ Set$ ),将老年代划分为若干小块,标识其中存在跨代引用的小块。在之后进行局限于新生代的垃圾收集 ( $Minor\ \ GC$ ) 时,只需要将这些小块中的对象加入GC Roots
即可。
2.2 标记-清除算法
标记-清除 ( $Mark-Sweep$ ) 算法,首先标记处需要回收的对象,在标记完成后统一收集标记的对象,也可以反过来标记不应清除的对象。标记-清除算法的缺点主要有两个:效率不稳定以及会导致内存碎片化。
2.3 标记-复制算法
标记-复制算法是为了解决标记-清除算法在面对大量可回收对象时的效率低下问题而提出的。首先是半区复制 ( $Semispace\ \ Copying$ ) 算法,它将可用内存分为大小相等的两块,每次只使用一块。当其中一块使用完之后,将其中存活的对象移到另一块,再将这块内存清理掉。虽然实现简单,运行高效,但是可用内存缩小为一半,浪费了大量空间。之后又提出了 $Appel$ 式回收,它将新生代分为一块较大的Eden
空间和两块较小的Survivor
空间,每次分配内存只使用Eden
和一块Survivor
。在垃圾收集时将它们中的存活对象复制到另一块Survivor
中,然后清理剩下的Eden
和Survivor
。HotSpot
虚拟机中Eden
和Survivor
的比例为 $8:1$ 。如果Survivor
空间无法容纳存活对象,那么就需要其他内存区域进行分配担保 ( $Handle\ \ Promotion$ ),也就是把新生代的对象转移到老年代。
2.4 标记-整理算法
标记-复制算法在面对大量存活对象时效率较低,所以不适用于老年代。标记-整理 ( $Mark-Compact$ ) 算法,其标记过程和标记-复制算法一样,但不进行复制,而是将存活对象向内存一端移动,再清理掉边界外内存。但是对于老年代这种存在大量存活对象的内存区,移动需要耗费大量时间,而且必须暂停用户程序。
3. HotSpot
虚拟机算法
在可达性分析算法的根节点收集这一步中,用户线程必须暂停。为了减小这段暂停的时间,HotSpot
虚拟机中有一组称为 $OopMap$ 的数据结构。当类加载完成时,HotSpot
会把对象内偏移量对应的数据类型计算出来,并在即时编译过程中记录栈和寄存器中的引用位置。这样扫描器在扫描时就不需要慢慢查找了,可以直接获取这些信息。但是如果给每一条指令都建立一个 $OopMap$ ,那么会占用大量存储空间。因此引入了安全点 ( $Safepoint$ ) 的概念。只有程序执行到安全点后,虚拟机才会生成 $OopMap$ 。同样,也只有执行到安全点后,程序才能开始垃圾收集。安全点的选取要以能否让程序长时间执行为标准,譬如方法调用、循环跳转、异常跳转这些需要指令序列的复用的地方就可以作为安全点。HotSpot
虚拟机为了避免安全点过多带来的负担,会对循环次数较少的循环不设安全点,而判断一个循环的循环次数的依据是其使用的数据类型:对于 $int$ 及更小的数据类型作为索引值的循环默认不会放置安全点,这种循环称为可数循环 ( $Counted\ \ Loop$ );对应的,使用 $long$ 或更大的数据类型作为索引值的循环就称为不可数循环 ( $Uncounted\ \ loop$ ) 。如果需要在可数循环中也放置安全点,那么需要使用 $-XX:+UseCountedLoopSafepoints$ 参数,但是在JDK 8
下这个参数可能会导致虚拟机崩溃,因此最好将原来的数据类型替换为更大的数据类型如 $long$ 等。
在多线程环境下,每个线程的执行速度不同,可能会出现当一个线程到达安全点之后其他线程还未到达的情况,因此需要采取不同策略。这里有两种方案可以选择:抢占式中断 ( $Preemptive\ \ Suspension$ ) 和主动式中断 ( $Voluntary\ \ Suspension$ ),几乎所有虚拟机都采用了主动式中断的方案。抢占式中断会在垃圾收集发生时直接中断所有线程,如果这时某条线程不在安全点上,就恢复其执行,直到其在安全点上。主动式中断设置了一个标志位,线程们会不断轮询这个标志位,当其为真时,线程会执行到最近的一个安全点上然后中断。轮询点包括安全点、创建对象的地方和其他需要在Java
堆上分配内存的地方,以避免因为内存不够而导致的无法分配问题。在HotSpot
虚拟机中采用了内存保护陷阱的方式,使得轮询操作只需要一条汇编指令即可执行,从而保证了高效性。
虽然安全点的引入解决了停顿线程的问题,但是无法在程序不执行的状态下运行。这时候就引入了安全区域 ( $Safe\ \ Region$ ) 的概念。安全区域指代一块代码段,在这段代码中,引用关系不会发生改变,因此可以在这段代码的任意地点开始垃圾收集。当线程进入安全区域,会标识其已进入安全区域,这样在垃圾收集时虚拟机就不会管这些线程。当线程离开时,如果仍在进行根节点枚举这种需要暂停用户线程的步骤,那么就要等待直到完成。
在进行部分区域收集 ( $Partial\ \ GC$ ) 时,可能会存在跨代引用问题,因此引入了记忆集的概念。记忆集专门用于记录非收集区域指向收集区域的指针集合。设计者在设计记忆集时,可以选择不同的记忆精度,比较常见的有:
- 字长精度:每个记录精确到一个机器字长,即处理器的寻址长度。
- 对象精度:每个记录精确到一个对象。
- 卡精度:每个记录精确到一块内存区域。
卡精度通过一种称为卡表 ( $Card\ \ Table$ ) 的方式实现,也是目前最常用的实现方式。最简单的卡表形式可以是一个字节数组,这也是HotSpot
虚拟机中的实现方式。字节数组中的每一个元素都标识一块内存区域,称为卡页 ( $Card\ \ Page$ )。当卡页中存在跨代指针时,就要将卡页标识为 $1$ ,称为元素变脏 ( $Dirty$ )。在垃圾收集时,只需要筛选变脏的卡页,然后加入GC Roots
即可。卡表变脏显然是需要虚拟机负责维护的,但是经过即时编译后的代码已经是一条条机器指令了,这时就需要一种手段可以将维护卡表的操作放到引用赋值之中。HotSpot
虚拟机通过写屏障 ( $Write\ \ Barrier$ ) 维护卡表状态。写屏障可以看作在虚拟机层面堆“引用类型字段赋值"这个动作的AOP
切面,在引用赋值时产生一个环形 ( $Around$ ) 通知,供程序执行额外动作。赋值前的部分叫写前屏障 ( $Pre-Write\ \ Barrier$ ),赋值后的部分叫做写后屏障 ( $Post-Write\ \ Barrier$ )。写前屏障可以应用于SATB
算法中,而写后屏障可以用于更新卡表中。
在对象标记的过程中,我们引入三色标记 ( $Tri-color\ \ Marking$ ) 来辅导。白色指未被访问的对象,黑色指所有引用都被扫描过的对象,灰色指部分引用还未被扫描的对象。而在对象标记的过程中,如果用户线程仍在执行,那么可能会触发对象消失问题,即原本存活的对象被标记为死亡。当且仅当以下条件同时满足时,就会产生对象消失问题:
- 赋值器插入了一条或多条从黑色到白色的新引用:即已扫描的对象与未扫描的对象之间建立联系;
- 赋值器删除了全部从灰色到白色的直接或间接引用:即正在扫描的对象与未扫描的对象之间的联系消失。
而要避免这种情况,实现并发标记,那么就要破坏这两个条件的任意一个。对此有两种方案:增量更新 ( $Increment\ \ Update$ ) 和原始快照 ( $Snapshot\ \ At\ \ The\ \ Beginning$, $SATB$ )。增量更新破坏第一个条件,即记录黑色到白色的新引用,在扫描结束后再以它们为根重新进行一次扫描。原始快照破坏第二个条件,即记录灰色对象删除指向白色对象的引用,在扫描结束后以其中灰色对象为根再进行一次扫描。在HotSpot
虚拟机中,这两种方案都有应用。
4. 经典垃圾收集器
4.1 Serial
收集器
Serial
是最基础、历史最悠久的收集器。它是一个单线程收集器,也就是说在其进行垃圾收集时,其他所有工作都要暂停。对新生代的收集会采用复制算法,对老年代的收集会采用标记-整理算法。Serial
收集器虽然是单线程,但是它相比于其他收集器有着简单高效的特点。对于内存资源受限的环境,它的内存消耗是最小的,而且内存资源小往往也意味着需要收集的对象少,因此停顿时间也不会很多。
4.2 ParNew
收集器
ParNew
收集器本质上是Serial
收集器的多线程版本,也就是除了使用多条线程并行扫描新生代外,其余的包括算法、控制参数和回收策略等都和Serial
收集器一致。ParNew
收集器和Serial
收集器是仅有的能够与CMS
收集器一起配合工作的收集器,其中ParNew
是激活CMS
后的默认收集器。
4.3 Parallel Scavenge
收集器
Parallel Scavenge
收集器是一款新生代收集器,同样基于标记-复制算法实现,支持并行收集。它的目标是使处理器达到一个可控的吞吐量 ( $Throughput$ ),即用户代码运行时间与处理器运行时间的比值。由于与吞吐量密切相关,因此也被称作吞吐量优先收集器。Parallel Scavenge
收集器提供了一种自适应的调节策略,允许虚拟机根据当前系统的运行情况实现动态调整Java
堆中各个区域的大小。
4.4 Serial Old
收集器
Serial Old
是Serial
收集器的老年代版本,同样也是一个单线程收集器,使用标记-整理算法。在服务器模式下可能有两种用途:在JDK 5
之前版本中与Parallel Scavenge
收集器搭配使用,或者作为CMS
收集器发生失败时后备预案。
4.5 Parallel Old
收集器
Parallel Old
是Parallel Scavenge
的老年代版本,同样支持多线程,基于标记-整理算法。在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge
和Parallel Old
这个组合。
4.6 CMS
收集器
CMS
( $Concurrent\ \ Mark\ \ Sweep$ ) 收集器是一种以获取最短回收停顿时间为目标的收集器,基于标记-清除算法。在JDK 8
之后,CMS
收集器逐渐被G1
收集器所取代。它的运行过程分为四步:
- 初始标记 ( $initial\ \ mark$ ):记录
GC Roots
的直接关联对象,需要停顿用户线程 - 并发标记 ( $concurrent\ \ mark$ ):从
GC Roots
的直接关联对象开始遍历对象图 - 重新标记 ( $remark$ ):修正并发标记期间因用户线程持续运作导致的标记变动,需要停顿用户线程
- 并发清除 ( $concurrent\ \ sweep$ ):删除已死亡的对象
CMS
具有并发收集、低停顿的优点。但是具有三个明显的缺点:
- 占用处理器资源。并发阶段虽然不会使用户线程停顿,但会因为占用线程而导致程序变慢,降低吞吐量。
- 无法处理浮动垃圾 ( $Floating\ \ Garbage$ ),可能出现并发失败 ( $Concurrent\ \ Mode\ \ Failure$ )。并发阶段,用户可能还在产生垃圾对象。但这部分对象出现在标记结束后,
CMS
无法收集,只能等待下次垃圾收集。同样也是由于并发执行,CMS
不能像其他收集器那样直到老年代快被填满时才开始垃圾收集,必须预留一部分空间供并发收集时的程序运作使用,所以需要为老年代空间设置一个阈值。而如果这部分预留的空间无法满足,就会出现并发失败。这时虚拟机只能冻结用户线程,临时启用Serial Old
收集器。阈值可以通过 $-XX:CMSInitiatingOccupancyFraction$ 来设置。 - 产生空间碎片。基于标记-清除算法,因此收集结束会产生碎片,不利于之后大对象的分配。为此,
CMS
默认开启了碎片整理功能,会在进入Full GC
时开始碎片整理。这个设置可以通过参数 $-XX:CMSFullGCsBeforeCompaction$ 改变,要求CMS
在经过若干次不整理空间的Full GC
后,在下一次进入前先进行碎片整理。
4.7 Garbage First
收集器
Garbage First
,简称G1
收集器开创了收集器面向局部收集的设计思路和基于Region
的内存布局形式,是一款主要面向服务端应用的垃圾收集器。在G1
之前,收集器范围要么是新生代 ( Minor GC
),要么是老年代 ( Major GC
),要么是整个Java
堆 ( Full GC
)。而G1
可以面向堆内存的任何部分组成回收集 ( $Collection\ \ Set$, $CSet$ ) 进行回收,称为Mixed GC
。实现这个的关键是基于Region
的堆内存布局。G1
不是按照固定大小和固定数量的分代区域划分,而是将Java
堆分为多个等大连续的区域,可以通过参数 $-XX:G1HeapRegionSize$ 设置,大小应为 $2$ 的 $N$ 次幂,每一个区域都可以作为Eden
、Survivor
或者老年代。Region
中有一特殊的Humongous
区域,专门用于存储大对象,只要对象大小超过了Region
的一半就被认为是大对象。而对于超过了Region
的大对象,会用几个连续的Humongous
存放。G1
的大多数行为都会把Humongous
作为老年代看待。Region
是每次收集的最小单元,这样可以避免Full GC
。收集器会跟踪每个Region
里的垃圾价值,价值即回收所获得的空间大小以及回收所需时间的经验值,并在后台维护一个优先级列表,每次根据用户设定收集允许停顿时间,通过 $-XX:MaxGCPauseMillis$ 设置,优先处理价值大的Region
。
G1
收集器的每个Region
都会维护一个记忆集,以哈希表的形式,$Key$ 为其他指向自己的Region
的起始地址,$Value$ 为一个存储着卡表索引号的集合,代表着 $Key$ 指向Region
的范围。也因为这种复杂的卡表,G1
比其他垃圾收集器有着更高的内存占用。
为了保证标记的正确进行,CMS
收集器使用了增量更新算法,而G1
使用了原始快照 ( SATB
) 算法。此外,G1
收集器为每个Region
设计了两个TAMS
( $Top\ \ at\ \ Mark\ \ Start$ ) 指针,并发回收时的新分配对象必须要在这两个指针的位置上,G1
会默认它们是存活的。G1
收集器仍然存在与CMS
相同的并发失败问题,一旦发生并发失败,那么同样也需要停顿用户线程。
G1
提供的 $-XX:MaxGCPauseMillis$ 参数可以设定允许停顿的时间,为此需要可靠的停顿预测模型,即预测停顿时间。G1
通过衰减均值 ( $Decaying\ \ Average$ ) 理论,记录每个Region
的回收耗时、脏卡数量等,由此得出统计信息,从而得到平均状态。
可以将G1
收集器的运作过程分为四步:
- 初始标记 ( $Initial\ \ Marking$ ):标记
GC Roots
能直接关联到的对象,并修改TAMS
指针的值,需要停顿用户线程,但耗时很短,而且是借用进行Minor GC
的时候同步完成的,所以这个阶段实际并没有额外的停顿; - 并发标记 ( $Concurrent\ \ Marking$ ):进行可达性分析,扫描完成后再处理
SATB
记录下的变动对象; - 最终标记 ( $Final\ \ Marking$ ):处理并发阶段后仍然遗留的小部分
SATB
记录,需要短暂停顿用户线程; - 筛选回收 ( $Live\ \ Data\ \ Counting\ \ and\ \ Evacuation$ ):更新
Region
的统计数据,对Region
的回收价值和成本进行排序。根据用户的期望停顿时间,制定回收计划,会将决定回收部分的Region
的存活对象复制到空Region
中,清空原Region
。存活对象移动的过程需要暂停用户线程。
从整体上看,G1
基于标记-整理算法,但从局部上看,又是基于标记-复制算法,但无论如何,都意味着不会产生内存碎片。再者,由于G1
的卡表复杂,因此不能像CMS
那样通过写后屏障更新卡表,而是使用类似于消息队列的方式,再在之后异步处理。自G1
开始,垃圾收集器的设计导向开始变为追求内存分配速率 ( $Allocation\ \ Rate$ ),而非清理干净,所以可以说G1
是收集器技术发展的一个里程碑。
5. 低延迟垃圾收集器
衡量垃圾收集器最重要的三个指标:内存占用 ( $Footprint$ )、吞吐量 ( $Throughput$ ) 和延迟 ( $Latency$ )。随着硬件的发展,延迟越来越重要。对于一些只进行短暂停顿的垃圾收集器,我们称为低延迟垃圾收集器 ( $Low-Latency\ \ Garbage\ \ Collector$ / $Low-Pause-Time\ \ Garbage\ \ Collector$ )。
5.1 Shenandoah
收集器
Shenandoah
收集器是第一款不由Oracle
公司的虚拟机团队领导开发的HotSpot
垃圾收集器,被OracleJDK
所排斥在外,只有OpenJDK
才会包含,成为OpenJDK 12
的正式特性之一。Shenandoah
与G1
有着很多相似性,包括堆内存布局,初始标记、并发标记等阶段的处理思路等。但是它支持并发整理算法,即可以多线程回收;没有分代,即不存在新生代和老年代;摒弃了记忆集,改为连接矩阵 ( $Connection\ \ Matrix$ )。Shenandoah
收集器工作过程可以划分为九个阶段:
- 初始标记 ( $Initial\ \ Marking$ ):标记与
GC Roots
直接关联的对象,需要停顿用户线程。 - 并发标记 ( $Concurrent\ \ Marking$ ):遍历对象图,标记可达对象。
- 最终标记 ( $Final\ \ Marking$ ):处理剩余的
SATB
扫描,统计Region
价值,需要停顿用户线程。 - 并发清理 ( $Concurrent\ \ Cleanup$ ):清理没有存活对象的
Region
。 - 并发回收 ( $Concurrent\ \ Evacuation$ ):将回收集的存活对象复制到未使用的
Region
中,但是要并发执行。 - 初始引用更新 ( $Initial\ \ Update\ \ Reference$ ):创建一个线程集合点,确保回收阶段中的线程都已完成任务,产生一个短暂的停顿。
- 并发引用更新 ( $Concurrent\ \ Update\ \ Reference$ ):将旧对象引用修正为新地址。
- 最终引用更新 ( $Final\ \ Update\ \ Reference$ ):修正
GC Roots
中的引用,需要停顿用户线程。 - 并发清理 ( $Concurrent\ \ Cleanup$ ):回收集中的
Region
已无存活对象,称为 $Immediate\ \ Garbage\ \ Regions$ ,清理这些区域。
Shenandoah
收集器做到短停顿的关键在于实现并发对象移动。在之前,要实现并发移动,通常是在被移动对象内存上设置保护陷阱 ( $Memory\ \ Protection\ \ Trap$ ),用户访问旧对象时会产生自陷中段,进入异常处理器中,再由异常处理器转发到新对象上。但是这种方案需要操作系统层面的支持,用户态要频繁地切换到核心态。Shenandoah
使用了Brooks Pointer
的概念,在对象结构前添加一个引用字段,在没有进行并发移动时指向自己,在并发移动时将其指向新对象。缺点也很明显,访问对象需要进行两次转发。此外,还要对转发指针进行同步,因为对旧对象进行写操作是无意义的,写操作只能对新对象进行,Shenandoah
使用比较并交换 ( $Compare\ \ And\ \ Swap$, $CAS$ ) 操作来保证并发时对象的访问正确性。
5.2 ZGC
收集器
ZGC
( $Z\ \ Garbage\ \ Collector$ ) 是JDK 11
中加入的低延迟垃圾收集器,但是与Shenandoah
的实现思路具有显著差异。它基于Region
内存布局,(暂时)不设分代,使用读屏障、染色指针 ( $Colored\ \ Pointer$ ) 和内存多重映射等技术实现并发的标记-整理算法。ZGC
中的Region
/Page
可以动态创建和摧毁,具有动态大小,其中小型和中型Region
容量固定,用于存放小对象,而大型Region
容量不定,存放大对象。染色指针是一种直接将少量信息存放在指针的技术。$64$ 位Linux
系统中高 $18$ 位不能寻址,染色指针技术从剩余的 $46$ 位中提取 $4$ 位作为标志位,也因此该技术限制了内存大小,也不支持 $32$ 位平台。四个标志从左到右分别是:是否只能通过 $finalize(\ )$ 方法访问、是否进入重分配集 ( 即被移动过 ) 以及引用对象的三色标记。染色指针很好的解决了写屏障的问题,因为不需要再去专门记录对象指针的变动。但是也带来了新问题:操作系统无法识别标志位。Linux/x86-64
平台上的ZGC
使用了多重映射 ( $Multi-Mapping$ ),将多个不同的虚拟内存地址映射到同一物理内存地址上,即多对一映射。以此,染色指针中的标志位可以视为分段符,只需要将这些地址段都映射到同一物理内存空间,就可以正常寻址了。
ZGC
的工作流程可以分为四个阶段,全部都可以并发执行:
- 并发标记 ( $Concurrent\ \ Mark$ ):遍历对象图做可达性分析,前后也需要进行初始标记和最终标记,并有短暂停顿。不同之处在于,标记阶段更新的是指针中的标志位。
- 并发预备重分配 ( $Concurrent\ \ Prepare\ \ for\ \ Relocate$ ):根据统计得出要清理的
Region
,组成重分配集 ( $Relocation\ \ Set$ )。不同于记忆集,ZGC
每次都会扫描全堆。 - 并发重分配 ( $Concurrent\ \ Relocate$ ):将重分配集中的存活对象复制到其他
Region
,并为集合中的每个Region
维护一个转发表 ( $Forward\ \ List$ ),用于记录旧对象到新对象的转向关系。当对象被访问时,如果其指针标志位标识其处于重分配集中,那么就会通过这个转发表转发到新对象地址,同时修正引用值,这种行为称为自愈 ( $Self-Healing$ )。因为要保证自愈能力,所以重分配集中的Region
在被清理时会保留转发表,直到其相关的指针引用修正完毕。 - 并发重映射 ( $Concurrent\ \ Remap$ ):修正堆中指向重分配集中旧对象的所有引用。由于指针具有自愈能力,所以这个阶段并不是非常必要的。因此
ZGC
中这一阶段并到了下一次垃圾收集的并发标记阶段,在每次遍历对象时修正对象引用。在所有指针修正完成后,释放原来的转发表。
虽然ZGC
给用户线程带来的负担很小,但它不能承受太高的对象分配速率,因为它会将垃圾收集期间的新对象都视为存活对象,从而在过高的分配速率下产生大量的浮动垃圾。
6. 垃圾收集器的选择
衡量垃圾收集器的三个最重要的指标:内存占用、吞吐量和延迟。不同的垃圾收集器对这三个方面有不同的侧重点,同样的,不同的程序也对这三方面有着不同的要求。因此,可以根据这三方面进行选择。除此之外,收集器的选择还受到操作系统以及JDK
发行商和版本的限制。
6.1 Epsilon
收集器
Epsilon
是一款无操作的垃圾收集器 ( $No-Op\ \ Garbage\ \ Collector$ ),因为它并不能进行垃圾收集,更准确的工作应该是负责内存管理,即堆内存管理布局、对象分配、与解释器和编译器的协作等。Epsilon
的主要应用在一些小型Java
程序中,即只需要运行几分钟甚至数秒的程序,而Epsilon
得工作就是保证虚拟机正确分配内存,使程序能在内存耗尽前退出。
6.2 虚拟机以及垃圾收集器日志
每个收集器的日志格式可能都不一样,而且在JDK 9
之前,HotSpot
并没有提供统一日志框架,因此在其之前的日志系统很混乱。JDK 9
之后,HotSpot
将所有日志功能收归到了 $-Xlog$ 参数下。
-Xlog[:[selector][:[output][:[decorator][:output-options]]]]
$Selector$ 用于选取日志输出,由标签和日志级别组成,标签可以视为虚拟机中某个功能模块的名字,垃圾收集器的标签名为 $gc$。日志级别分为六级:$Trace$, $Debug$, $Info$, $Warning$, $Error$, $Off$ 。$Decorator$ 用于附加额外内容,如时间、进程ID
等。
7. 内存分配与回收策略
- 大多数情况下,对象在新生代
Eden
区中分配,当Eden
中没有足够空间时,就会发起一次Minor GC
。 - 大对象容易导致垃圾收集提前触发,同时具有高昂的复制开销,通过 $-XX:PretenureSizeThreshold$ 参数 ( 只对
Serial
和ParNew
有效 ),所有大于指定值的对象都会直接分配在老年代,从而减少复制操作。 - 虚拟机给每个对象定义了一个年龄计数器,存储在对象头中。通常对象在
Eden
区中诞生,每经历一次Minor GC
并且进入Survivor
区,年龄便加 $1$ 。到一定程度 ( 默认为 $15$ ),就会晋升至老年代,可以通过 $-XX:MaxTenuringThreshold$ 设置。不过为了更好的适应不同程序的内存状况,当Survivor
中某个年龄的对象内存总和达到一半以上时,所有大于等于该年龄的对象都可以进入老年代。 - 在
Minor GC
之前,虚拟机要检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。如果为真,那么可以认为这次Minor GC
是安全的;否则,查看 $-XX:HandlePromotionFailure$ 参数是否允许担保失败。如果允许,那么检查之前的最大可用连续空间是否大于历次晋升到老年对象的平均大小,如果小于将会尝试一次Minor GC
。如果不允许,改为Full GC
。但是在JDK 6
之后,这个参数不再被使用,规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC
,否则进行Full GC
。