Shenandoah垃圾回收器
虽然目前大部分系统使用的是JDK8,使用的垃圾回收器也大概率为G1或者更古老的垃圾回收器,但是截止到目前为止,JDK已经更新到JDK16了,垃圾回收器也几乎在每一次迭代中被更新,目前最前沿的垃圾回收器为Shenandoah和ZGC,这两款垃圾回收器都是以低延时为主要目的。
一、概述
Shenandoah垃圾回收器是RedHat公司发明的,非Oracle公司官方实现,不是Oracle的亲儿子,因此在一定程度上遭到了“排挤”,只在开源的OpenJDK12中开始出现,而在商业版的Oracle JDK12中则没有。
Shenandoah的目标是将垃圾回收的停顿时间控制在10ms以内,这意味着Shenandoah不仅需要在并发标记阶段实现并发,还需要在标记清除阶段实现并发。
与G1的异同点
Shenandoah与G1有很多相同点,都采用了基于Region的内存布局,在标记阶段均采用了并发标记。事实上,Shenandoah在代码实现上使用了很多G1的代码,因此Shenandoah有很多特点和G1是一样的。另外,Shenandoah在G1的基础上做了很多改变,至少存在下面3处改进。
- 在最终的回收阶段,采用的是并发整理,由于和用户线程并发执行,因此这一过程不会造成
STW,这大大缩短了整个垃圾回收过程中系统暂停的时间。 - 默认情况下不使用分代收集,也就是
Shenandoah不会专门设计新生代和老年代,因为Shenandoah认为对对象分代的优先级并不高,不是非常有必要实现。(至于不实现分代,对Shenandoah性能能带来什么好处,笔者也不是很清楚,猜测原因可能是,不进行分代可能在设计上更简单吧。毕竟Shenandoah是RedHat公司设计实现的,不是Oracle的官方团队,他们从零开始设计,工作量巨大) - 采用“连接矩阵”代替记忆集。在
G1以及其他经典垃圾回收器中均采用了记忆集来实现跨分区或者跨代引用的问题,每个Region中都维护了一个记忆集,浪费了很多内存,且导致系统负载也更重,「因此在Shenandoah中摒弃了这种实现方式,而是采用连接矩阵来解决跨分区引用的问题」。
连接矩阵可以理解为一个二维数组,当
Region N的对象引用了Region M中的对象,那么就将二维数组array[N][m]设置一个标志位。
二、工作流程
Shenandoah的工作流程大致可以分为初始标记、并发标记、最终标记、并发清理、并发疏散、引用更新、并发清理这几个步骤,其中引用更新还可以细分为初始引用更新、并发引用更新、最终引用更新三个小步骤。
初始标记、并发标记、最终标记这三个步骤和G1一样。并发清理这一步和G1就不同了,G1中是多个GC线程并行清理,而Shenandoah中是并发清理。

GC(3) Pause Init Mark 0.771ms
GC(3) Concurrent marking 76480M->77212M(102400M) 633.213ms
GC(3) Pause Final Mark 1.821ms
GC(3) Concurrent cleanup 77224M->66592M(102400M) 3.112ms
GC(3) Concurrent evacuation 66592M->75640M(102400M) 405.312ms
GC(3) Pause Init Update Refs 0.084ms
GC(3) Concurrent update references 75700M->76424M(102400M) 354.341ms
GC(3) Pause Final Update Refs 0.409ms
GC(3) Concurrent cleanup 76244M->56620M(102400M) 12.242ms
1. 初始标记(Init Mark)
并发标记的初始化阶段,它为并发标记准备堆和应用线程,然后扫描root集合。这是整个GC生命周期第一次停顿,这个阶段主要工作是root集合扫描,所以停顿时间主要取决于root集合大小。
初始标记标记的是和GC Roots直接相关联的对象,会造成STW,停顿的时间长短与GC Roots的数量成正比。
2. 并发标记(Concurrent Marking)
贯穿整个堆,以root集合为起点,跟踪可达的所有对象。这个阶段和应用程序一起运行,即并发(concurrent)。这个阶段的持续时间主要取决于存活对象的数量,以及堆中对象图的结构。由于这个阶段,应用依然可以分配新的数据,所以在并发标记阶段,堆占用率会上升。
并发标记阶段是垃圾回收线程和用户线程并发执行,不会造成STW,这一步需要遍历整个对象图,耗时较长。
3. 最终标记(Final Mark)
清空所有待处理的标记/更新队列,重新扫描root集合,结束并发标记。这个阶段还会搞明白需要被清理(evacuated)的region(即垃圾收集集合),并且通常为下一阶段做准备。最终标记是整个GC周期的第二个停顿阶段,这个阶段的部分工作能在并发预清理阶段完成,这个阶段最耗时的还是清空队列和扫描root集合。
最终标记阶段是对并发标记阶段进行修正,处理那些因为用户线程同时运行导致引用关系改变的对象,这一步需要暂停用户线程,会造成STW,但暂停时间不会太长。
4. 并发清理(Concurrent Cleanup)
回收即时垃圾区域 -- 这些区域是指并发标记后,探测不到任何存活的对象。
GC线程和用户线程并发执行,不会造成STW。而且这一步清理仅仅只是清理一个存活对象都没有的Region(也就是说Region中的对象都是垃圾)
5. 并发疏散(Concurrent Evacuation)
从垃圾收集集合中拷贝存活的对到其他的Region中,这是有别于OpenJDK其他GC主要的不同点。这个阶段能再次和应用一起运行,所以应用依然可以继续分配内存,这个阶段持续时间主要取决于选中的垃圾收集集合大小(比如整个堆划分128个region,如果有16个region被选中,其耗时肯定超过8个region被选中)。
将回收集中,所有存活的对象复制到空闲的Region中,这一步也是并发执行,不会造成STW,执行时间的长短与回收集的大小以及存活对象的数量相关。并发回收是Shenandoah与其他垃圾回收器相比,最大的不同之处了。Shenandoah在回收时使用的是复制算法,而复制算法的特点是:在移动完存活对象后,还需要修改所有指向这些存活对象的引用指向,而这个过程很难一瞬间就改变过来。由于是并发执行,用户线程也在运行,当我们将存活对象移动到新的Region中时,如果引用指向还没有修改为最新的对象地址,那就可能导致程序出错。Shenandoah为了实现并发回收,采用了「Brooks Pointers」转发指针来解决该问题。
6. 初始引用更新(Init Update Refs)
初始化更新引用阶段,它除了确保所有GC线程和应用线程已经完成并发Evacuation阶段,以及为下一阶段GC做准备以外,其他什么都没有做。这是整个GC周期中,第三次停顿,也是时间最短的一次。
为了提供一个线程的集合点,确保所有的垃圾回收线程都完成了复制对象到新Region的任务。
7. 并发引用更新(Concurrent Update References)
再次遍历整个堆,更新那些在并发evacuation阶段被移动的对象的引用。这也是有别于OpenJDK其他GC主要的不同,这个阶段持续时间主要取决于堆中对象的数量,和对象图结构无关,因为这个过程是线性扫描堆。这个阶段是和应用一起并发运行的。
就是在「并发引用更新」阶段是真正的更新引用,该过程不需要遍历整个对象图,只需要按照内存的物理地址顺序,线性地搜索出引用类型,然后更新为新地址,这一步是和用户线程一起并发执行。
8. 最终引用更新(Final Update Refs)
通过再次更新现有的root集合完成更新引用阶段,它也会回收收集集合中的region,因为现在的堆已经没有对这些region中的对象的引用。这是整个GC周期最后一个阶段,它的持续时间主要取决于root集合的大小。
更新GC Roots中的指向旧地址的对象到新地址。
9. 并发清理(Concurrent Cleanup)
回收现在没有任何引用的Region集合。
将回收集中所有的Region清除,该过程和用户线程并发执行,不会产生STW。
从Shenandoah的工作流程来看,大部分阶段都是并发执行,仅有初始标记和最终标记会造成STW,并且这两个阶段停顿的时间都十分短暂,因此Shenandoah在进行垃圾回收时造成的系统延时非常低,确实是一款以低延时为目标的垃圾回收器。

浙公网安备 33010602011771号