Java程序员的荣光,听R大论JDK11的ZGC

阅读 5144
收藏 38
2018-08-30
原文链接:mp.weixin.qq.com

前言

ZGC来了 !!! Java程序员可以光荣的远离讨厌的GC停顿和调优了。ZGC的成绩是,无论你开了多大的堆内存(1288G? 2T?),硬是能保证低于10毫秒的JVM停顿。

SPECjbb 2015基准测试,在128G的大堆下,最大停顿时间才 1.68ms (不是平均,不是90%,99%,是Max ! ),远低于最初的目标-那保守的10ms,也远胜前代的G1。

大家的第一反应都是这么颠覆性的东西怎么来的,G1 通过每次只回收部分Region而不是全堆,改善了大堆下的停顿时间,但在普通大小的堆里表现并没惊喜,现在怎么突然就翻天了,一点心理准备都没有啊。

如果文章太长不想看下去,你只要记住R大下面这句话就够了:

与标记对象的传统算法相比,ZGC在指针上做标记,在访问指针时加入Load Barrier(读屏障),比如当对象正被GC移动,指针上的颜色就会不对,这个屏障就会先把指针更新为有效地址再返回,也就是,永远只有单个对象读取时有概率被减速,而不存在为了保持应用与GC一致而粗暴整体的Stop The World。

其实Azul JDK的皇牌 C4 垃圾收集 ,早就同样以最高十毫秒停顿成为江湖传说。 曾在Azul的R大, 看着JDK11 ZGC的算法和结果倍感熟悉,与ZGC的领队Per Liden大大聊完之后,确认了ZGC跟 Azul Pauseless GC,是,等,价,的。(R大御览本文时 -  其他同学是预览,R大是御览,想半天,选定了“等价”这个字眼)

(R大拍的Per大大在JVMLS)

嗯,如果你还有空,下面让我们来继续聊聊ZGC的八大特征。

一、所有阶段几乎都是并发执行的

    这里的并发(Concurrent),说的是应用线程与GC线程齐头并进,互不添堵。

    说几乎,就是还有三个非常短暂的STW的阶段,所以ZGC并不是Zero Pause GC啦。

    R大:“比如开始的Pause Mark Start阶段,要做根集合(root set)扫描,包括全局变量啊、线程栈啊啥的里面的对象指针,但不包括GC堆里的对象指针,所以这个暂停就不会随着GC堆的大小而变化(不过会根据线程的多少啊、线程栈的大小之类的而变化)”   -- 因此ZGC可以拍胸脯,无论堆多大停顿都小于10ms。

    二、并发执行的保证机制,就是Colored Pointer 和 Load Barrier

    原理前面R大一句话已经说完了。Colored Pointer 从64位的指针中,借了几位出来表示Finalizable、Remapped、Marked1、Marked0。 所以它不支持32位指针也不支持压缩指针, 且堆的上限是4TB。

    有Load barrier在,就会在不同阶段,根据指针颜色看看要不要做些特别的事情(Slow Path)。注意下图里只有第一种语句需要读屏障,后面三种都不需要,比如值是原始类型的时候。

    R大还提到了ZGC的Load Value Barrier,与Red Hat的Shenandoah收集器的不同,后者选择了70年代的比较基础的Brooks Pointer ,而前者在也是很老的Baker barrier上加入了self healing的特性,比如下面的代码:

    Object a = obj.x; 

    Object b = obj.x;

    两行代码都插入了读屏障,但ZGC在第一个读屏障之后,不但a的值是新的,self healing下obj.x的值自身也会修正,第二个读屏障时就直接进入FastPath,没有消耗了; 而Shenandoah 则不会修正obj.x的值,第二个读屏障又要SlowPath一次。

    三、像G1一样划分Region,但更加灵活

    ZGC将堆划分为Region作为清理,移动,以及并行GC线程工作分配的单位。

    不过G1一开始就把堆划分成固定大小的Region,而ZGC 可以有2MB,32MB,N× 2MB 三种Size Groups,动态地创建和销毁Region,动态地决定Region的大小。

    256k以下的对象分配在Small Page, 4M以下对象在Medium Page,以上在Large Page。

    所以ZGC能更好的处理大对象的分配。

     

    四、和G1一样会做Compacting-压缩

    CMS是Mark-Swap,标记过期对象后原地回收,这样就会造成内存碎片,越来越难以找到连续的空间,直到发生Full GC才进行压缩整理。

    ZGC是Mark-Compact ,会将活着的对象都移动到另一个Region,整个回收掉原来的Region。

    而G1 是 incremental copying collector,一样会做压缩。

    下面粗略了几十倍地过一波回收流程,小阶段都被略过了哈:

     

    1. Pause Mark Start -初始停顿标记

      停顿JVM地标记Root对象,1,2,4三个被标为live。

      2. Concurrent Mark -并发标记

      并发地递归标记其他对象,5和8也被标记为live。

      3. Relocate - 移动对象

      对比发现3、6、7是过期对象,也就是中间的两个灰色region需要被压缩清理,所以陆续将4、5、8  对象移动到最右边的新Region。移动过程中,有个forward table纪录这种转向。

      R大这里又赞扬了一下C4/ZGC的Quick Release特性:活的对象都移走之后,这个region可以立即释放掉,并且用来当作下一个要扫描的region的to region。所以理论上要收集整个堆,只需要有一个空region就OK了。

      而RedHat的Shenandoah 因为它的forward pointer的设计,则需要有1/2个Heap是空的。

      4. Remap - 修正指针

      最后将指针都妥帖地更新指向新地址。这里R大还提到一个亮点: “上一个阶段的Remap,和下一个阶段的Mark是混搭在一起完成的,这样非常高效,省却了重复遍历对象图的开销。”

      五、没有G1占内存的Remember Set,没有Write Barrier的开销

      G1 保证“每次GC停顿时间不会过长”的方式,是“每次只清理一部分而不是全部的Region”的增量式清理。

      那独立清理某个Region时 , 就需要有RememberSet来记录Region之间的对象引用关系, 这样就能依赖它来辅助计算对象的存活性而不用扫描全堆, RS通常占了整个Heap的20%或更高。

      这里还需要使用Write Barrier(写屏障)技术,G1在平时写引用时,GC移动对象时,都要同步去更新RememberSe,跟踪跨代跨Region间的引用,特别的重。而CMS里只有新老生代间的CardTable,要轻很多。

      ZGC几乎没有停顿,所以划分Region并不是为了增量回收,每次都会对所有Region进行回收,所以也就不需要这个占内存的RememberSet了,又因为它暂时连分代都还没实现,所以完全没有Write Barrier。

      六、支持Numa架构

      现在多CPU插槽的服务器都是Numa架构了,比如两颗CPU插槽(24核),64G内存的服务器,那其中一颗CPU上的12个核,访问从属于它的32G本地内存,要比访问另外32G远端内存要快得多。

      JDK的 Parallel Scavenger 算法支持Numa架构,在SPEC JBB 2005 基准测试里获得40%的提升。

      原理嘛,就是申请堆内存时,对每个Numa Node的内存都申请一些,当一条线程分配对象时,根据当前是哪个CPU在运行的,就在靠近这个CPU的内存中分配,这条线程继续往下走,通常会重新访问这个对象,而且如果线程还没被切换出去,就还是这位CPU同志在访问,所以就快了。

      但可惜CMS,G1不支持Numa,现在ZGC 又重新做了简单支持,哈哈哈。

      R大补充,G1也打算支持了Numa了: http://openjdk.java.net/jeps/157

      七、并行

      在ZGC 官网上有介绍,前面基准测试中的32核服务器,128G堆的场景下,它的配置是:

      20条ParallelGCThreads,在那三个极短的STW阶段并行的干活 -  mark roots, weak root processing(StringTable, JNI Weak Handles,etc)和 relocate roots ;

      4条ConcGCThreads,在其他阶段与应用并发地干活 - Mark,Process Reference,Relocate。 仅仅四条,高风亮节地尽量不与应用争抢CPU 。

      ConcCGCThreads开始时各自忙着自己平均分配下来的Region,如果有线程先忙完了,会尝试“偷”其他线程还没做的Region来干活,非常勤奋。

      八、单代

      没分代,应该是ZGC唯一的弱点了。所以R大说ZGC的水平,处于AZul早期的PauselessGC  与 分代的C4算法之间 - C4在代码里就叫GPGC,Generational Pauseless GC。

      分代原本是因为most object die young的假设,而让新生代和老生代使用不同的GC算法。但C4已经是全程并发算法了,为什么还要分代呢? 

      R大说:

      “因为分代的C4能承受的对象分配速度(Allocation Rate), 大概是原始PGC的10倍。

      如果对整个堆做一个完整并发收集周期,持续的时间可能很长比如几分钟,而此期间新创建的对象,大致上只能当作活对象来处理,即使它们在这周期里其实早就死掉可以被收集了。如果有分代算法,新生对象都在一个专门的区域创建,专门针对这个区域的收集能更频繁更快,意外留活的对象更也少。

      而Per大大因为分代实现起来麻烦,就先实现出比较简单可用的单代版本。所以ZGC如果遇上非常高的对象分配速率,目前唯一有效的“调优”方式就是增大整个GC堆的大小来让ZGC有更大的喘息空间。”

      小结

      ZGC这么让Java有面子有期待的事情,不转不是Java人 !!!

      全程各种R大聊天实录,不转不是R大粉!!!!

      小结2

      歇了一年多后的再次更新,因为错过了公众号最黄金的时代,麻烦大家重新关注下本号,给深夜码字的作者一点慰籍。

      各位老大写公众号推荐集合时,求顺带捎上小号。

      参考资料

      1. ZGC wiki:

      https://wiki.openjdk.java.net/display/zgc/Main

      2. R大的知乎回答:

      https://www.zhihu.com/question/287945354/answer/458761494

      3. ZGC回收器到底有多变态? by 贺卓凡   ImportSource  

      本文图片多有借用,感谢。链接太长不好贴,大家按标题搜索。

      4. A FIRST LOOK INTO ZGC: 

      http://dinfuehr.github.io/blog/a-first-look-into-zgc/

      5. AZul的《The Pauseless GC Algorithm》论文:

      https://www.usenix.org/legacy/events/vee05/full_papers/p46-click.pdf

      6. AZul开源的C4参考实现,原汁原味的论文实现

      https://github.com/GregBowyer/ManagedRuntimeInitiative/tree/master/MRI-J/hotspot/src/azshare/vm/

      评论