JDK12中新版垃圾回收器-Shenandoah GC

Shenandoah是一款concurrent及parallel的垃圾收集器;跟ZGC一样也是面向low-pause-time的垃圾收集器,不过ZGC是基于colored pointers来实现,而Shenandoah GC是基于brooks pointers来实现。

其实低停顿的GC,业界早就出现,只不过Java比较晚

Azul的Zing中C4 GC   土豪选择

oracle中的HotSpot ZGC    JDK11的选择

R大说ZGC说抄袭Azul的,两者是等价的。

不过今天主要是探究一下另一款追求低停顿时间的GC回收器Shenandoah GC
Shenandoah 主要目标是99.9%停顿小于10ms,暂停与堆大小无关

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Init Mark 启动并发标记。它为堆和应用程序线程准备并发标记,然后扫描根集。这是流程中的第一个暂停,最主要的消耗是根集扫描。因此,其持续时间取决于根集大小。
 
Concurrent Marking 遍历堆,并跟踪可访问的对象(三色标记对象)。此阶段与应用程序一起运行,其持续时间取决于活动对象的数量和堆中对象关系的结构。由于应用程序可以在此阶段自由分配新数据,因此在并发标记期间堆占用率会上升。这里主要运用的是SATB(snapshot-at-the-beginning)
 
Final Mark 通过清理所有等待的标记,更新队列,重新扫描根设置三个并发的来完成标记。(这里主要是处理一些SATB没有处理完的引用)它还通过确定要撤离的区域(收集集合),预先疏散一些根来初始化疏散,并且通常为下一阶段准备运行时间。这项工作的一部分可以在Concurrent Cleanup阶段同时完成。这是周期中的第二个暂停,这里消耗最主要的时间是清理队列并扫描根集。 
 
Concurrent Cleanup 回收立即垃圾区域。 即在并发标记之后检测到的没有活动对象的区域。
 
Concurrent Evacuation 将对象集合从集合集复制到其他区域。这是与其他OpenJDK GC的主要区别。此阶段再次与应用程序一起运行,因此应用程序可以自由分配。其持续时间取决于为流程选择的集合集的大小。
 
Init Update Refs 初始化更新引用阶段。除了确保所有GC和应用程序线程都已完成疏散,然后为下一阶段准备GC之外,它几乎没有任何作用。这是周期中的第三次暂停,最短暂停。
 
Concurrent Update References 遍历堆,并更新对并发撤离期间移动的对象的引用。 这是与其他OpenJDK GC的主要区别。 它的持续时间取决于堆中的对象数,但不取决于对象图结构,因为它会线性扫描堆。此阶段与应用程序同时运行。
 
Final Update Refs 通过重新更新现有根集来完成更新引用阶段。它还从集合集中回收区域,因为现在堆没有对它们的(陈旧)对象的引用。这是循环中的最后一次暂停,其持续时间取决于根集的大小。
 
Concurrent Cleanup  回收集合集区域,现在没有引用

 

通常,JDK对象头有2个字分配给它们(类名和用于锁定的标记字,前向指针等)。Shenendoah增加了第三个词叫做间接指针。对象的所有引用都必须通过此指针。这允许移动对象而不更新对它的所有引用,这意味着可以在Java线程同时运行时更新活动对象。只有在使用Shenendoah GC时才会添加这个额外的指针。

在64位系统上,指针占8字节大小(后面会有汇编例子)真实在代码中使用的无符号整型替代大小
JDK12中新版垃圾回收器-Shenandoah GC_第1张图片

屏障barrier保证 读写一定是to区域的新对象

使用Shenandoah GC,可能会导致吞吐量降低,为啥会降低,GC回收线程与业务线程的切换。Brooks indirection pointer算法带来的问题(读写可能被插入了barriers,ZGC的LVB据说比这个优秀)但是不会显著干扰。

Shenandoah GC是并发GC,逻辑上是不存在分代的。不存在所谓的young/old区

如果遇见非常高的对象分配速率的话会跟不上,目前唯一有效的“调优”方式就是增大整个GC堆的大小来让GC有更大的喘息空间,这也就是为什么适用于超大内存的使用场景了。

所以Shenandoah GC在allocation failure的情况下有一些优雅的方式解决

JDK12中新版垃圾回收器-Shenandoah GC_第2张图片

gc速度慢的时候,stall 就是减缓正在分配对象的线程,gc速度跟上来后,就去掉减缓。

JDK12中新版垃圾回收器-Shenandoah GC_第3张图片

可以看出如果最后实在没办法,发生FGC,停顿时间将大大的增大。

除了上述那个“调优”方法-增大内存来解决这个问题,其实还存在一种,这个不是开发者能控制的,那就是分代。Shenandoah GC如果项目不死的话,最后发展成分代的可能性极大。

分代收集,也就是是划分young/old区,其目的是为了提高GC能够应付的应用内存分配速率

对于一个并发GC来说,能够尽快回收出越多空间,就能够应付越高的应用内存分配速率!

针对上述的Shenandoah GC流程,分析一下如果,Shenandoah GC进化成分代收集之后,会有哪些优化?

在之前GC算法种,回收young区的算法都复制算法,它的开销只跟活对象的多少(live data set)有关系,而跟它所管理的堆空间的大小没关系。根据大多数对象生命周期很短的规则区看,在young区基本上99%的对象都是一次就死,存活只有1%,并行处理的话大概100-200ms就能解决

但是如果是存活对象占这个区域比例很高的话,那么可达性分析所消耗的时间将会特别差!

现在Shenandoah GC是不分代,那么扫描GC根的时间是一样的,但是后面可达性分析是不一样的。虽然这时候是并发执行也就是上述的Concurrent Marking阶段。这个阶段消耗时间会比分代收集young区时间长(因为存在长时间存活对象)那么导致回收的时间变长!

这也一来,根据能够尽快回收出越多空间,就能够应付越高的应用内存分配速率这个条件反推,原先的应付内存分配速率是小于分代收集的。

那么有人会问,那么进入old区的对象怎么回收呢?

目前来说只收集old的只有CMS的concurrent collection是这个模式,其他回收old区域,都会触发收集整个堆GC,也就是FGC
 

JDK12中新版垃圾回收器-Shenandoah GC_第4张图片

如上图所示,Shenandoah GC每个GC周期由2个STW(Stop The World)阶段和2个并发阶段组成。在初始化标记阶段,扫描root集合的时候会STW。然后并发标记阶段,Shenandoah GC和Java工作线程一起运行,最后,在最终标记阶段,又会STW,然后执行一个并发evacuation阶段。 

JDK12中新版垃圾回收器-Shenandoah GC_第5张图片

 

JDK12中新版垃圾回收器-Shenandoah GC_第6张图片

每个阶段要做的事情如下:

Init Mark 并发标记的初始化阶段,它为并发标记准备堆和应用线程,然后扫描root集合。这是整个GC生命周期第一次停顿,这个阶段主要工作是root集合扫描,所以停顿时间主要取决于root集合大小。

Concurrent Marking 贯穿整个堆,以root集合为起点,跟踪可达的所有对象。 这个阶段和应用程序一起运行,即并发(concurrent)。这个阶段的持续时间主要取决于存活对象的数量,以及堆中对象图的结构。由于这个阶段,应用依然可以分配新的数据,所以在并发标记阶段,堆占用率会上升。

Final Mark 清空所有待处理的标记/更新队列,重新扫描root集合,结束并发标记。. 这个阶段还会搞明白需要被清理(evacuated)的region(即垃圾收集集合),并且通常为下一阶段做准备。最终标记是整个GC周期的第二个停顿阶段,这个阶段的部分工作能在并发预清理阶段完成,这个阶段最耗时的还是清空队列和扫描root集合。

Concurrent Cleanup 回收即时垃圾区域 -- 这些区域是指并发标记后,探测不到任何存活的对象。

Concurrent Evacuation 从垃圾收集集合中拷贝存活的对象到其他的region中,这是有别于OpenJDK其他GC主要的不同点。这个阶段能再次和应用一起运行,所以应用依然可以继续分配内存,这个阶段持续时间主要取决于选中的垃圾收集集合大小(比如整个堆划分128个region,如果有16个region被选中,其耗时肯定超过8个region被选中)。

Init Update Refs 初始化更新引用阶段,它除了确保所有GC线程和应用线程已经完成并发Evacuation阶段,以及为下一阶段GC做准备以外,其他什么都没有做。这是整个GC周期中,第三次停顿,也是时间最短的一次。

Concurrent Update References 再次遍历整个堆,更新那些在并发evacuation阶段被移动的对象的引用。这也是有别于OpenJDK其他GC主要的不同,这个阶段持续时间主要取决于堆中对象的数量,和对象图结构无关,因为这个过程是线性扫描堆。这个阶段是和应用一起并发运行的。

Final Update Refs 通过再次更新现有的root集合完成更新引用阶段,它也会回收收集集合中的region,因为现在的堆已经没有对这些region中的对象的引用。

这是整个GC周期最后一个阶段,它的持续时间主要取决于root集合的大小。

Concurrent Cleanup 回收那些现在没有任何引用的Region集合。

目标

Shenandoah不是一个要一统天下的GC,有一些其他的吞吐量优先,或者内存占用优先的GC算法,它们并不是把响应性放在第一位(即不是主要考虑缩短停顿时间)。

Shenandoah是一个对那些更看重响应性和可预测短暂停顿的应用来说,更合适的GC算法。它的目标不是要解决所有JVM的停顿问题,由于GC之外的其他原因(例如到达安全点时间(TTSP--Time To Safe Point)问题)而暂停时间超出了此JEP的范围。

现代服务器比以前拥有更多的内存和处理器,SLA应用需要保证RP在10~500ms。为了达到
最苛刻的目标(保证RP在10ms以内),我们需要GC的算法足够高效,允许程序在可用内存中运行,并且经过优化后,永远不会让正在运行的程序的停顿时间超过5毫秒(a handful of milliseconds,一只手就5根手指头,所以是5ms)。

Shenandoah就是这样一个OpenJDK为更近这个目标而设计的开源、低停顿时间的垃圾回收器。

替代方案

1. Zing/Azul是一个没有停顿的垃圾收集器,但是不会贡献给OpenJDK。

2. 基于colored pointers设计的ZGC也是一个拥有很低停顿时间的垃圾收集器,Shenandoah期望能与之一战。

3. G1很多工作都是并行或者并发的,但是evacuation阶段不能并发执行。

4. CMS能并发标记,但是它执行年轻代拷贝时,需要STW,并且不会压缩老年代,这就会导致花费更多时间来管理老年代中的可用空间以及碎片问题。

使用

这还是一个体验功能,需要增加-XX:+UnlockExperimentalVMOptions参数才能开启Shenandoah GC:

-XX:+UnlockExperimentalVMOptions -XX:+UseShenandoahGC

常规测试

RedHat已经做了大量的测试,OpenJDK也为Shenandoah开发了很多测试用例。而且从Fedora 24开始Shenandoah在Fedora中随着JDK一起发布,并在Rhel7.4中作为技术预览. 通过-XX:+UseShenandoahGC运行标准的OpenJDK完全足够。

JDK12中新版垃圾回收器-Shenandoah GC_第7张图片

 

Shenandoah垃圾收集器可以说是G1垃圾收集器的一个修改版了(不能说是改进,只能说是修改,因为Shenandoah更激进,一味追求低延迟),所以,它与G1有着高度相似,当然为了追求低延迟,它也有一些修改的地方。

Shenandoah与G1的区别与联系
Shenandoah是在G1的基础上进行了修改,从而向低延迟的目标进发,回忆之前我们博客中讲G1的时候,在其Mixed GC阶段,主要分为初始标记、并发标记、重新标记、清除垃圾和最终回收阶段(evacuation),其中5个有3个都是STW的,除了并发标记其他都是STW,那么为了追求低延时,此刻Shenandoah选择将其中停顿时间较大的最终回收阶段给变成非STW的,也就是并发的,用户线程与垃圾回收线程同时运行。
 

最终回收阶段,Shenandoah改成了 非STW的,也就是用户线程和GC线程同时运行

Shenandoah继承了G1的堆内存划分,也就是将堆内存划分成了一个个大小相等的Region,也有着存放大对象的Humongous区域,但是呢,Shenandoah不遵循分代理论,也就是说在Shenandoah立即收集器的规则里,没有老年代新生代一说了。不过在进行垃圾回收时,依然是选取回收效率高的Region回收。(没了分代理论了,自然也就没有G1中的young GC了,只剩下Mixed GC)。

 

JDK12中新版垃圾回收器-Shenandoah GC_第8张图片

显然,通过这个连接矩阵,我们可以很方便的获得跨Region的引用情况,比起每个Region都维护一个卡表可以说方便很多,而且也节省了资源。

JDK12中新版垃圾回收器-Shenandoah GC_第9张图片

初始标记阶段:该阶段是标记GC ROOTS直接可达的对象。因为和G1不同,没有了young GC,没法借道,所以这里是需要STW的,不过时间非常短暂。

并发标记阶段:和用户线程一起并发工作,在可达性数上进行扫描,确认对象们的存活状态。该阶段是不需要STW的。

重新标记阶段:与G1一样,将在并发标记中被用户修改引用关系的对象重新扫描,避免出现并发可达性分析的安全问题。这里采用的是原始快照。同时,统计出回收价值最高的Region,将这些Region加入回收集。这个阶段当然是会STW的。

并发清理阶段:这个阶段和G1有点不同,因为在G1中,该阶段STW,而在Shenandoah中,却没有,该阶段作用一样,也是来清理回收集中那些无存活对象的Region。该阶段不需要STW。

并发回收阶段:该阶段是Shenandoah与G1的核心差异所在。将回收集里的存活对象复制到其他未使用的Region中,然后将原Region回收。看到这,你可能会说,这和G1有什么区别呢?G1也是做这些呀。
不一样,G1是STW之后,来复制对象,当然,这个阶段时间不短,这样的操作会十分的简单。
而在Shenandoah中,它不需要STW,也就是该阶段在Shenandoah中是并发的,哦哟,这可了不得,因为要知道,这会出并发安全问题的。所以针对此,Shenandoah进行了专门的安排
JDK12中新版垃圾回收器-Shenandoah GC_第10张图片

JDK12中新版垃圾回收器-Shenandoah GC_第11张图片

JDK12中新版垃圾回收器-Shenandoah GC_第12张图片

如此,便解决了访问新对象的问题。
不过,这种方法也是有弊端的,很显然的就是将原本简单的对象访问流程变的更加繁琐,本来一步就能访问到对象,现在得两步,你说麻烦不麻烦。不过由于复制存活对象这事干的挺多,所以其实也还好,总体还是挺好的。

下面来解决第二个问题。
并发安全问题。在上面的问题的基础上,我们来设想这样一种情况:

垃圾收集线程复制了新的对象
用户线程更新了对象
垃圾收集线程将旧对象的Brooks Pointer指向新对象
如此一来,用户的更新操作落在了旧对象上,而新对象并未被操作,从而出现了安全问题。所以这个问题必须得解决。这个问题也可以说是一个同步问题,也是比较简单的,Shenandoah同时设置了读、写屏障来解决该问题。保证1,3是必须相继完成,不能被分割。
 

说完了上面两个问题,其实并发回收阶段的核心也讲的差不多了,接下来就简单的把并发回收阶段的具体流程简单的过一遍。
并发回收阶段流程:

并发复制:利用读写屏障和Brooks Pointer,将存活对象复制别的Region中去。
初始引用更新:设定一个线程集合点,确保并发回收阶段所有的收集线程都已经完成它们的对象移动任务。会STW很短一段时间,该阶段为下一阶段做准备。
并发引用更新:开始进行引用更新,将变量中的旧对象内存地址改成新对象的内存地址。沿着内存物理地址顺序进行。
最终引用更新:修正GC Roots中的引用,该阶段短暂的STW。
并发清理:将回收集中的Region回收。
 

 

  • 支持并发的整理算法,Shenandoah的回收阶段可以和用户线程并发执行;
  • Shenandoah 目前不使用分代收集,也就是没有年轻代老年代的概念在里面了;
  • Shenandoah 摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection Matrix)的全局数据结构来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题的发生概率。

并发回收(Concurrent Evacuation) :首先把回收集里面的存活对象先复制一份到其他未被使用的Region之中,然后通过读屏障Brooks Pointers转发指针技术来解决在垃圾回收期间用户线程继续读写被移动对象的问题,并发回收阶段运行的时间长短取决于回收集的大小。

连接矩阵

连接矩阵可以简单理解为一张二维表格,如果Region N有对象指向RegionM,就在表格的N行M列中打上一个标记,如图所示,如果Region 5中的对象Baz引用了Region 3的Foo,Foo又引用了Region 1的Bar,那连接矩阵中的5行3列、3行1列就应该被打上标记。在回收时通过这张表格就可以得出哪些Region之间产生了跨代引用


JDK12中新版垃圾回收器-Shenandoah GC_第13张图片

当写操作发生时,Shenandoah收集器是通过CAS(Compare And Swap)操作,来保证收集器线程或者用户线程只有其中之一可以进行修改操作,以此来保证并发时对象访问的正确性。

优缺点

  • 优点:延迟低
  • 缺点:高运行负担使得吞吐量下降;使用大量的读写屏障,尤其是读屏障,增大了系统的性能开销;



 

你可能感兴趣的:(JVM,垃圾回收)