天天看点

JVM垃圾回收器详解:Shenandoah,垃圾回收实现

垃圾回收实现-垃圾回收策略

Shenandoah为了满足不同的使用场景,在垃圾回收时设计了4种不同的垃圾回收策略,分别是static、aggressive、adaptive和compact。每种策略触发垃圾回收的条件略有不同。

不同的回收策略除了控制如何启动垃圾回收之外,还会控制内存中的哪些内存可以被回收。这4种策略对应的回收触发条件和回收范围总结如表7-2所示。

JVM垃圾回收器详解:Shenandoah,垃圾回收实现

表7-2 Shenandoah垃圾回收策略

垃圾回收的策略可以通过参数控制,默认的策略是adaptive。另外,这4种模式回收的分区在不同的版本中可能略有区别,但总体来说差别不大。

垃圾回收模式

在JDK 12中只有一种回收模式,但是存在6种回收策略,其中traversal是一种比较特殊的策略。本质上traversal并不是一种回收策略,而是一种回收模式。回收策略定义在回收模式时垃圾回收的粒度,回收模式定义垃圾回收的整个流程。所以traversal策略实际上定义了一种回收模式。但是traversal相关代码复杂度太高,存在不少问题,所以JDK 15将该模式相关代码移除。但同时Shenandoah又引入了一种新的模式,称为增量更新。

在最新的JDK 17中Shenandoah支持3种回收模式:

1)SATB或者Normal模式,在JDK 16之前,名字使用Normal,在JDK 16中名字使用SATB。该模式表示在并发标记时使用SATB的标记算法,可以使用除了Passive策略以外的4种回收策略。

2)Incremental-Update(IU),该模式是在traversal移除后新增的回收模式。该模式指的是在并发标记时使用增量回收的标记算法,可以使用除了Passive策略以外的4种回收策略。

3)Passive模式,该模式仅仅使用Passive回收策略。由于Passive策略仅仅在执行OOM时才会触发垃圾回收,所以Passive模式在执行垃圾回收时是暂停执行的。

其中SATB模式是成熟的模式,IU模式是实验模式,Passive模式几乎不使用。

SATB模式和IU模式最大的区别是通过屏障技术解决并发标记正确性问题的方式不同,SATB模式通过屏障记录修改前的对象,而IU模式通过屏障记录引用者。

除了上述3种回收模式以外,本文也稍微提一下已经移除的traversal模式,该模式是一种非常激进的回收方式。

正常回收算法

在JDK 15之前,Shenandoah中有两种正常回收模式:一般模式和优化模式。

一般模式和优化模式的区别在于是否在标记的时候执行重定位,如果在标记的过程中执行重定位,则称为优化模式,否则称为一般模式。

这两种模式可以通过参数ShenandoahUpdateRefs-Early控制,取值为off/false表示垃圾回收执行优化模式,on/true/adaptive表示执行一般模式。

一般模式垃圾回收的步骤如下:

1)初始标记:从根集合出发,标记根集合所有引用的对象,这些对象作为下一步并发标记的出发点。这一步是在STW中进行的。

2)并发标记:以第一步标记的对象作为出发点,开始并发地标记对象。

3)预清理:在进入再标记阶段之前,先处理引用对象,把仍然活跃的引用对象重新激活,不进行真正的垃圾回收。该阶段支持并发执行,但是只有一个并发工作线程执行预清理。

4)再标记:该阶段要做3件事情,分别为终止标记、计算回收集、转移根集合直接的引用对象。这一步是在STW中进行的。

5)清理:再标记结束后,部分分区可能已经没有任何活跃对象,这些分区就可以被回收了。

6)并发转移:根据转移集,对所有在转移集中的活跃对象进行转移。

7)初始重定位:初始重定位将根据标记过程中识别的活跃对象更新分区中对象的内存地址。这一步是在STW中进行的。

8)并发重定位:遍历不属于回收集合中的分区的对象,根据BrookPointer更新对象的引用指针。

9)结束重定位:遍历根集合中所有引用的对象,更新对象的引用指针。

这一步是在STW中进行的。

10)再次清理:因为回收集合中对象全部转移完成,所以可以释放空间。

整个垃圾回收的活动如图7-14所示。

JVM垃圾回收器详解:Shenandoah,垃圾回收实现

图7-14 整个垃圾回收活动示意图

优化模式垃圾回收

优化模式与一般模式非常类似,唯一的区别在于是否合并标记和引用更新。如果合并这两个阶段,则称为优化模式。优化模式可以减少一次堆遍历,但是在Shenandoah中的优化模式把内存释放一直推迟到下一个垃圾回收周期,这将导致本应该快速释放的内存无法释放。在比较优化模式的成本与收益后,在JDK 15中正式将该模式移除。优化回收的步骤如下。

1)初始标记:和一般模式中的初始标记相同。

2)并发标记:以第一步标记的对象作为出发点,开始并发地标记对象。注意在这一步首先判断对象是否需要重定位,如果需要则进行重定位。

3)预清理:和一般模式中的预清理相同。

4)再标记:该阶段主要做4件事情,分别为更新根集合中所有对象的引用、终止标记、计算回收集、转移根集合直接的引用对象。这一步是在STW中进行的。

5)清理:和一般模式中的清理相同。

6)并发转移:和一般模式中的并发转移相同。

7)结束转移:设置转移结束标记,重置TLAB等信息。这一步是在STW中进行的。

垃圾回收的降级

降级回收算法(也称为Degenerated GC)指在垃圾回收过程中,如果遇到内存分配失败,就进入降级回收。降级回收实质上是在STW中并行执行的。

在正常回收运行的过程中,应用程序和垃圾回收线程都可能需要分配内存空间,也都有可能遇到内存不足导致分配失败的情况,此时正常回收将进入降级回收。如果在降级回收时再遇到内存不足的情况,将进入Full GC。这3种算法交互的流程如图7-15所示。

JVM垃圾回收器详解:Shenandoah,垃圾回收实现

图7-15 正常回收、降级回收和Full GC交互的流程

降级回收的步骤和正常回收基本一致,只不过降级回收是并行执行的。

降级回收中若再次遇到分配失败,将被进一步降级为Full GC(在并发回收中的分配失败通常是应用请求内存分配导致的,而降级回收中的分配失败是GC工作线程请求内存分配导致的)。Full GC采用典型的并行标记压缩回收,和G1的实现非常类似,这里不再赘述。

遍历回收算法

在介绍垃圾回收时,可以把重定位阶段和标记阶段进行合并,这个思路就是Shenandoah的优化回收。那么还能不能再进一步优化这个算法,把并发标记、并发转移和并发重定位合并放在一个并发步骤中?Shenandoah中的遍历回收实现就是把这3个并发阶段合并到一个阶段,如图7-16所示。

从图7-16中可以看出,在遍历回收中,第一次垃圾回收启动时进行并发标记,第二次垃圾回收启动时进行并发标记和并发转移,第三次垃圾回收和以后的垃圾回收启动时都可以执行并发标记、并发转移和并发重定位。

JVM垃圾回收器详解:Shenandoah,垃圾回收实现

图7-16 遍历回收示意图

由于代码的复杂性,遍历回收在JDK 15中被移除。

垃圾回收触发的时机

Shenandoah中垃圾回收触发的时机与垃圾回收的模式和策略密切相关,在后面介绍相关参数时会详细介绍。例如,Adaptive策略有6种触发垃圾回收的条件。

其他细节

Shenandoah的实现还有很多细节值得仔细推敲,限于篇幅,这里只是稍微介绍读者容易忽略的两个细节。

(1)并发转移是否可以利用SATB相关信息优化

在并发标记中使用了SATB引入的TAMS指针,分区中该指针以后的对象都是并发标记启动以后新分配的对象。

并发转移阶段中的分区分为两种:分区中的对象将要被转移,分区被回收,这些分区位于CSet中;分区不参与回收。CSet中的分区将不会再用于分配对象,非CSet中的分区可以继续用于分配对象。对于非CSet的分区可以利用TAMS指针,在并发转移启动以后,TAMS指针以后新分配的对象状态都是正确的,新分配的对象如果指向尚未完成转移的对象,就会通过读屏障将尚未转移的对象转移到新的位置,所以TAMS指针以后分配的对象都不需要再次更新对象的引用。所以在并发转移中也使用TAMS指针区分新分配对象和尚未完成更新的对象,这将提高并发更新引用的效率。TAMS指针在并发转移中的使用如图7-17所示。

JVM垃圾回收器详解:Shenandoah,垃圾回收实现

图7-17 TAMS指针在并发转移中的使用

(2)并发转移中出现转移失败该如何处理

在G1的垃圾回收过程中会申请内存用于转移对象,当无法申请到内存时就会导致对象无法转移,此时称为转移失败。当转移失败后,需要对转移失败的对象进行特殊处理,通常是将转移对象的转移指针指向自己,避免该对象再次被转移,同时并不中断垃圾回收的过程。在垃圾回收结束后,对转移失败的情况重新设置对象头,并更新引用集等信息。

G1的转移是并行处理,整个处理不会出现对象状态不一致的情况。而Shenandoah是并发转移,当出现转移失败时,需要额外处理,否则将出现对象不一致的情况。用一个简单的例子来演示Shenandoah并发转移可能存在的问题。假设垃圾回收处于并发转移阶段,有两个线程T1和T2可以访问同一个对象,运行时信息如图7-18所示。

JVM垃圾回收器详解:Shenandoah,垃圾回收实现

图7-18 并发转移阶段两个线程访问同一对象

当线程T1或者T2访问对象时,都会先转移对象到目标空间。假设T1在转移对象时遇到无法分配内存的情况,对T1来说就发生了转移失败,T1会尝试标记对象转移失败(假设也使用转移指针指向自己)。同时,线程T2访问对象时也会转移对象,假设T2有充足的内存(例如T2的TLAB中有空闲空间)可以成功转移内存。此时运行时信息如图7-19所示。

JVM垃圾回收器详解:Shenandoah,垃圾回收实现

图7-19 两个线程同时转移一个对象,一个成功,一个失败

线程T1先访问对象,转移失败,返回原始对象;线程T2后访问对象,转移成功,返回目标空间的对象。图7-19中为了说明这一结论直接使用指针指向了不同的对象,实际上是两个线程得到的返回对象地址不同。

在这种情况下就出现了问题,两个不同的线程指向了两个不同的对象,根据并发转移的要求,需要保证目标空间不变性。对于T1出现的转移失败情况需要特殊处理,理想的运行状态是T1也应该访问目标对象,同时在其他的线程转移完成后再进行转移失败处理,如图7-20所示。

JVM垃圾回收器详解:Shenandoah,垃圾回收实现

图7-20 理想的运行状态

那么该如何保证有这样的状态?目前,Shenandoah设计了一个特殊的机制,来处理转移中可能遇到的失败情况。具体步骤如下:

1)当线程进入对象转移时,增加计数器。

2)当线程成功转移对象后,减少计数器。

3)当某一个线程出现转移失败后,等待其他线程完成转移后才能继续执行;线程会重新确定对象是否转移,如果对象已经转移,则获取转移后的对象。

(3)Shenandoah对JNI的优化

当Java应用执行的本地代码中包含JNI Critical API时,因为本地代码会操作Java堆空间中的内存对象,而垃圾回收执行时会移动对象,这两个需求是矛盾的,所以在执行JNI Critical API时会设置一个GCLocker标志,告诉垃圾回收暂停执行,直到JNI Critical API执行完毕才会再执行垃圾回收。这样的设计的合理性是值得商榷的。

在Shenandoah中优化了这一设计,即在本地代码执行JNI Critical API时仍然可以执行垃圾回收。其方法是,仅仅将JNI Critical API访问对象所在的内存固定(称为Pinned),即垃圾回收可以继续执行,当遇到内存固定的区域时不进行回收。由于Shenandoah采用分区设计,因此垃圾回收也是基于分区进行的。固定JNI Critical API访问对象所在内存可以将整个分区固定,只要在垃圾回收时跳过这样的分区即可。该优化在有较多JNI CriticalAPI的应用中有较好的效果。

目前JVM中仅Shenandoah支持该优化,实际上G1 GC和ZGC也是基于分区设计的,要想实现类似的优化并不困难。

(4)为什么Shenandoah需要多种屏障

Shenandoah使用SATB屏障(本质是写屏障)保证并发标记的正确性。在JDK 13之前,并发转移阶段使用读屏障、写屏障和比较屏障;在JDK 13之后,并发转移阶段使用Load屏障(本质是读屏障)。在其他的垃圾回收器实现中,如JVM的ZGC、Android的Concurrent Copying都仅仅使用了Load屏障完成标记和转移。那为什么Shenandoah没有统一多种屏障为一种?原因主要是不同的屏障性能不同。Shenandoah的一个主要维护者Aleksey Shipilev在介绍Shenandoah时比较过使用不同屏障的成本,如表7-3所示。

JVM垃圾回收器详解:Shenandoah,垃圾回收实现

表7-3 SATB屏障和Load屏障在测试集上的成本比较

测试的基准是无屏障的情况。在表7-3中可以明显看出Load屏障的成本更高。这也可能是Shenandoah选择SATB算法进行并发标记的原因。

本文给大家讲解的内容是JVM垃圾回收器详解:Shenandoah,垃圾回收实现

  1. 下篇文章给大家讲解的内容是JVM垃圾回收器详解:Shenandoah,OpenJ9中的实时垃圾回收器Metronome介绍
  2. 感谢大家的支持!

继续阅读