了不起的ZGC

        ZGC是随着Java11发布的新一代垃圾收集器,它基于Page(和G1的Region等价,官方资料称为Page)内存布局,以低延迟为设计目标,我们先看一下官方的测试成绩:

了不起的ZGC_第1张图片

        ZGC的设计思路很接近Azul的C4,目前还不支持分代。ZGC还使用了染色指针、读屏障、内存多重映射的技术实现了并发的标记-整理算法。ZGC能做到极短的STW的时间,关键是做到了暂停时间只和GC Roots相关,而与堆大小无关,几乎整个回收过程都是并发的,为此ZGC引入的另一项标志性的技术是染色指针,通过使用指针的高位作为读屏障(Load Barrier)来标记GC过程中的状态,在读取对象时,如果指针的颜色不对,这个屏障就会先把指针更新为有效地址再返回,这样就只有单个对象读取时有概率被减速,而不存在为了保持应用与GC一致而粗暴整体的STW。

一、内存布局

        ZGC采用的是具有动态创建和销毁特性的Page,不同于G1大小相等的Page,ZGC具有大、中、小三种不同容量的Page分别放置不同大小的对象:

  • 容量为2的整数倍的Large Page放置4MB或以上的大对象,每个Page只放一个对象;
  • 容量为32MB的Medium Page放置大于等于256KB小于4MB的对象;
  • 容量固定为2MB的Small Page放置小于256KB的小对象;

        Large Page的实际容量可能小于Medium Page,是的,这里的Small、Medium、Large说的是里面的对象大小,而不是Page本身。需要注意的是,Large Page分配之后,只能回收,不会再移动了。

二、染色指针

        染色指针指的是GC过程中,将对象可达性标记信息记在引用对象的指针上的技术。64位Linux系统支持46位内存物理地址空间寻址,ZGC的染色指针将46位指针宽度的高4位存储标志信息(Finalizable、Remapped、Marked1、Marked0),这样的话ZGC能够管理的内存是4TB(42位)。染色指针对改善GC过程的STW有很大帮助,主要体现在以下几个方面:

  • Page内的存活对象被移走之后马上就能被释放和重用,不必等到整个堆上对该Page的引用都被修正之后才能释放和清理;
  • 大幅度减少了GC过程中内存屏障的使用(目前只使用了读屏障,没有使用写屏障),相比之下G1为了处理GC过程中Region之间的相互引用,消耗了相当大比例的堆容量。

了不起的ZGC_第2张图片

三、内存多重映射

        内存多重映射是使用染色指针的前置条件,由于染色指针的标记位在GC过程中会发生变化,从上面的染色指针图可以看出,当标记位变化时,指针的虚拟内存地址会发生变化,此时对象的物理内存地址没有变化。ZGC采用的内存多重映射技术就是将不同的虚拟内存地址映射到同一个物理内存地址上的技术。

 

了不起的ZGC_第3张图片

四、ZGC工作流程

        ZGC的工作流程从逻辑上可以分为三个非常短暂的STW阶段和四个大的并发处理阶段,如下图:

 

了不起的ZGC_第4张图片

  • Pause Mark Start阶段:是STW阶段,用来标记GC Roots直接关联到的对象;
  • Concurrent Mark阶段:并发过程,从GC Roots出发,对堆上的对象做遍历进行可达性分析,ZGC的特点是这个阶段的可达性标记是在指针上,而不是在对象上进行的;
  • Pause Mark End阶段:是STW阶段,用来处理上个并发阶段期间的应用变化;
  • Concurrent Prepare for Relocate阶段:并发阶段,这个阶段主要用来得出本次GC过程需要清理哪些Page,将这些Page组成重分配集合。ZGC在这个阶段会扫描全堆所有的Page,用更大的扫描范围的代价换取不需要维护类似G1中Remembered Set;
  • Pause Relocate Start阶段:是STW阶段(这个阶段干了啥不清楚,有些文章说处理边缘情况,也有的说是转移跟对象引用的对象);
  • Concurrent Relocate阶段:并发阶段,此阶段是ZGC执行过程中的核心阶段,这个过程的主要目标是把重分配集中的存活对象复制到新的Page上,并为每个Page维护一个转发表(Forward Table)记录旧对象到新对象的转向关系。得益于染色指针的使用,Page的对象被移走之后,这个Page马上就可以被释放和重用。根据染色指针的标记位得知对象所处的状态,如果此时应用访问了旧对象就可以被读内存屏障截获,然后根据转发表,将访问转发到复制的新对象上,同时更新引用值,ZGC把这个特性称为自愈(Self-Healing);
  • Concurrent Remap阶段:此阶段是并发的,此阶段主要目标是更新整个堆中指向重分配集中旧对象的所有引用。由于ZGC拥有自愈特性,所以这个阶段是个不紧急的任务,所以实现时,把这个阶段合并到下次gc的Concurrent Mark阶段了,反正都要遍历对象图,索性合并到一次遍历中。

五、与G1比较

        相比上一代的G1垃圾收集器,ZGC的设计思想有了巨大的改变。G1为了控制暂停时间,JVM会根据预测的停顿时间模型,选择优先收集的Region列表,但是待收集的Region极可能是新生代,也可能是旧生代,所以需要Remembered Set记录Region对象之间的双向引用关系,这对内存造成了很大的负担,经验数据是花费大约10%~20%的堆容量的内存来维持收集器工作。G1在平时写引用时,GC移动对象时,都要同步去更新Remembered Set,跟踪跨代跨Region间的引用,特别的重。

        相比之下,ZGC的做法是控制停顿阶段做的事情不和堆里面的对象产生直接关联,所有对堆上进行扫描、分配、复制的操作都是并发的。ZGC通过扫描整个堆和染色指针技术,换取了不需要Remembered Set。

六、ZGC的缺点

        ZGC看上去很完美,支持大内存的前提下还能保持低停顿,似乎可以适应任何场景。不过,从ZGC没有分代来看,ZGC很难适应高并发这种对象分配速率很高的场景。在JVM的回收算法不断更新的过程中,更加强调低停顿,结果往往是整个回收过程变得越来越复杂,执行时间越来越长(注:这里说的是总得GC过程时间长,而不是STW时间长)。分代代对提高吞吐量意义重大,分代假设绝大多数对象是朝生夕灭。我们可以设想一个高并发场景,假设GC全过程持续5分钟,这段时间内,新分配的对象不能进入本次回收的范围,这就产生了大量的浮动垃圾,如果产生浮动垃圾的速度超过回收的速度,那么可用堆空间越来越小,直到耗尽。这似乎会进入这样的死循环:增大堆容量->GC持续过程增加->产生更多的浮动垃圾->需要更大的堆容量。解决思路使用更大规模的集群数,使得落到每个应用的请求数减少,使得产生浮动垃圾的速度和回收内存的速度平衡。

        ZGC从Java11开始提供,想要享受ZGC的强大必须先升级JDK到新版本,目前线上的服务大多数仍然停留在了Java8,主要原因是后续Java版本更新非常快、特性丰富,但官方维护时间短,此时IDE、构建工具、框架、中间件对高版本Java支持是个未知数,升级新版Java要冒很大的风险。

七、总结

        ZGC很美,想享受ZGC的强大,需要冒很大的风险。

参考资料:

1、https://www.jianshu.com/p/2a765fada19d

2、深入理解Java虚拟机-JVM高级特性与最佳实践(第3版)

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

 

个人公众号:

        自己在软件这个行业10来年的工作和学习历程中犯了不少错误,也走了不少弯路,在此公众号分享自己的成长心路和工作心得,希望给后来的从业者一个参照,不要再犯我犯过的错误,不要再走我走过的弯路。在此我也会分享工作中遇到的技术问题和自己研究技术的记录与心得;在项目过程中遇到的风险和暴露于化解风险的过程和方法;在团队管理过程中的心得和体会。后续会发布一系列专题技术文章,程序员成长系列文章,项目的系列文章,行业发展分析和展望的文章,甚至会包含婚恋和育儿的心得文章,我是个不专注却热爱生活的工程师!

了不起的ZGC_第5张图片

 

你可能感兴趣的:(JVM)