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
在进行垃圾回收时造成的系统延时非常低,确实是一款以低延时为目标的垃圾回收器。