1.2.5 ZGC
G1作为新一代成熟的垃圾回收器尚未得到广泛使用,新一代的垃圾回收器ZGC在JDK 11中引入,ZGC是2017年Oracle公司贡献给OpenJDK社区的,正式成为OpenJDK的开源项目,也就是JEP 333,目前它被明确地标记为实验性质(意味着还不成熟)。新一代的垃圾回收器一经发布,虽然尚不成熟,但是仍然阻挡不了众多Java程序员对它的追捧。ZGC是为了解决G1的不足,我们先看一下G1有哪些不足。
G1的目标是在可控的停顿时间内完成垃圾回收,所以进行了分区设计,在回收时采用部分内存回收(在YGC时会回收所有新生代分区,在混合回收时会回收所有的新生代分区和部分老生代分区),支持的内存也可以达到几十个GB或者上百个GB。为了进行部分回收,G1实现了RSet管理对象的引用关系。基于G1设计上的特点,导致存在以下问题:
❑停顿时间过长,通常G1的停顿时间要达到几十到几百毫秒;这个数字其实已经非常小了,但是我们知道垃圾回收发生导致应用程序在这几十或者几百毫秒中不能提供服务,在某些场景中,特别是对用户体验有较高要求的情况下不能满足实际需求。
❑内存利用率不高,通常引用关系的处理需要额外消耗内存,一般占整个内存的1%~20%左右。
❑支持的内存空间有限,不适用于超大内存的系统,特别是在内存容量高于100GB的系统中,会因内存过大而导致停顿时间增长。
ZGC作为新一代的垃圾回收器,在设计之初就定义了三大目标:支持TB级内存,停顿时间控制在10ms之内,对程序吞吐量影响小于15%。实际上目前ZGC已经满足设计之初定义的目标,最大支持4TB堆空间,依据实际测试的情况来看,停顿时间通常都在10ms以下,并且垃圾回收所引起的暂停时间并不会随着内存的增大而延长。下面我们看一看ZGC是如何满足设计目标的。
看到这三大目标,是不是觉得不可思议,应该如何设计或者如何改进以前的垃圾回收器才能实现这些目标? ZGC的设计思路借鉴了一款商业垃圾回收器——Azul的C4(关于C4的介绍,可以参考相关论文)。C4这款垃圾回收器号称无停顿时间,所以很多人认为ZGC也是完全无停顿时间的,即ZGC在执行垃圾回收时完全不需要暂停应用程序,这实际上是对无停顿时间的误解。在这里对无停顿时间做个简要介绍,实际上从垃圾回收器的角度出发,没有任何一款垃圾回收器能做到完全无停顿时间,所谓的无停顿时间指的是停顿时间足够短,这个时间不影响应用程序的运行,比如ZGC的停顿时间小于10ms,从应用程序来看这个停顿时间就可以忽略,所以称为无停顿时间。大家在学习时应该知道这一概念指的是什么,另外,ZGC希望能将停顿时间控制在10ms以内,但不是所有垃圾回收活动都能在10ms内完成,注意,这里的10ms是一个目标值。那么哪些因素可能影响ZGC的目标停顿时间?除了ZGC中本身的STW活动,还有进入安全点(执行垃圾回收活动之前)的花费,比如我们知道在JVM中进入安全点时会进行字符串回收(这里字符串回收指的是回收因使用String类中的intern方法而产生的垃圾),所以ZGC为了保证进入安全点的时间足够短,会把这一部分工作优化成并发处理。
回到ZGC如何设计以达成目标这一问题。简单地说,就是ZGC把一切能并发处理的工作都并发执行。在这里再强调一下JVM中“并行”和“并发”这两个词:并行指多个垃圾回收相关线程在操作系统之上并发地运行,强调的是只有垃圾回收线程工作,Java应用程序都暂停执行,因此并行线程执行的时候一定发生了STW;并发指如果启动了多个线程,那么与垃圾回收相关的线程并发地运行,同时这些线程会和Java应用程序并发地运行。所有线程都由操作系统调度,交替执行。哪些工作是可以并发执行的?我们看一下ZGC的设计思路。
ZGC是在G1的基础上发展起来的,我们知道G1中实现了并发标记(参考图1-6),所以标记已经不会再影响停顿时间了。G1中的停顿时间主要来自垃圾回收(YGC和混合回收)阶段中的复制算法,在复制算法中,需要把对象转移到新的空间中,并且更新其他对象到这个对象的引用。实际中对象的转移涉及内存的分配和对象成员变量的复制,而对象成员变量的复制是非常耗时的。在G1中对象的转移都是在STW中并行执行的,而ZGC就是把对象的转移也并发执行,从而满足停顿时间在10ms以下。我们看一个实际的例子,这是使用G1作为垃圾回收器运行Cassandra的一个日志片段。在Cassandra的配置中,希望每次停顿时间为100ms,但是G1在这一次垃圾回收时花费了497.945ms,其中Evacuate Collection Set花费493ms。
GC(259) Pause Young (Normal) (G1 Evacuation Pause) GC(259) Using 8 workers of 8 for evacuation GC(259) MMU target violated: 101.0ms (100.0ms/101.0ms) GC(259) Pre Evacuate Collection Set: 0.1ms GC(259) EvacuateCollectionSet: 493.0ms GC(259) Post Evacuate Collection Set: 3.5ms GC(259) Other: 1.2ms GC(259) Eden regions:163->0(164) GC(259) Survivor regions:16->15(23) GC(259) Old regions:520->523 GC(259) Humongous regions:3->3 GC(259) Metaspace: 48742K->48742K(1093632K) GC(259) Pause Young (Normal)(G1 Evacuation Pause) 2804M->2162M(14336M) 497.945ms GC(259) User=2.18s Sys=0.01s Real=0.50s
Evacuate Collection Set就是对整个回收集合的分区进行标记和转移。493ms包含了标记和转移所用时间,如果使用一些诊断参数查看更细粒度的统计数据,通常转移时间占比在80%左右。转移时因为存在内存复制,所以极其耗时,从而导致停顿时间不可控,更多细节可以参考其他文档。ZGC中的改进就是把这步最耗时的动作变成了并发执行。
另外,在G1中可能存在FGC,如果发生了FGC,也可能导致停顿时间不可控。在目前的ZGC中,垃圾回收就是全量回收,也就是每发生一次垃圾回收就是一次FGC,而每次垃圾回收的停顿时间在10ms以下,所以FGC导致停顿时间不可控这一存在于G1中的问题也解决了。因为ZGC中每次垃圾回收都是全量回收(即每次都是FGC),那么大家可能会问,如果对象分配不成功,ZGC将如何处理这种情况呢?这里先留一个疑问,后文中将回答。
ZGC除了并发转移,还对整个垃圾回收进入STW的过程做了改进,把原来串行执行的动作也并发执行。在这里我们比较一下不同垃圾回收器在并发粒度上的区别,如表1-3所示。
表1-3 不同垃圾回收器的并发执行
这里的不支持并发执行,对于不同的垃圾回收器,概念还有区别。对于串行回收器来说,不支持并发执行意味着所有步骤是串行执行的。对于其他垃圾回收器,不支持并发执行又分成两种情况,一种是并行执行,例如转移、引用处理、弱引用处理;另一种是串行执行,如符号表、字符串表、类卸载。它们通常是在进入安全点的时候执行(通过VMThread串行执行,VMThread是整个JVM执行垃圾回收的核心,关于VMThread的详细内容将在3.2.3节介绍)。这样设计的目的是在实现复杂性和保证效率间寻找平衡,通常来说并发处理效率高,但是实现复杂;串行/并行效率略低,实现简单。
最后对ZGC做一个简单的总结。除了并发执行这个显著特点之外,ZGC还有以下特点:
❑不分代的垃圾回收器,即垃圾回收时对全量内存进行标记,但是回收时仅针对部分内存回收,优先回收垃圾比较多的页面。
❑仅支持Linux 64位系统,不支持32位平台。
❑不支持使用压缩指针。
❑内存分区管理,且支持不同的分区粒度,在ZGC中分区称为页面(page),有小页面、中页面、大页面3种。
❑具有颜色指针(color pointer),通过设计不同的标记位区分不同的虚拟空间,而这些不同标记位指示的不同虚拟空间通过mmap映射在同一物理地址;颜色指针能够快速实现并发标记、转移和重定位。
❑设计了读屏障,实现了并发标记和并发转移的处理。
〇二 从JDK 12开始支持并发类卸载。
❑支持NUMA,尽量把对象分配在访问速度比较快的地方。
关于这些特点,后文都会一一介绍。最后还要强调一下,目前ZGC仅仅是实验性质的垃圾回收器,在一些大内存的场景中表现了良好的性能,同时也说明ZGC还有一些不足,主要有:
❑仅实现了单代内存管理,也就是说没有考虑热点数据与冷数据,分代内存管理在C4中已经得到支持。据Azul官网文章介绍,所实现的分代的内存管理器比没有分代的内存管理器效率高10倍,也就是说ZGC还有巨大的进步空间。
❑C2的支持还不够完善。
❑不支持Graal、HDSB等功能。
❑一些功能尚待完善,比如尚不支持类回收。
❑稳定性尚需提高。
粗略估计ZGC可能要到下一个Long Term Support的版本才能得到完善。那么为什么还不完善的ZGC这么快就被纳入了OpenJDK的官方项目?据Per Lin、Eric等ZGC的几个主要维护者的观点,这样做是希望能尽快推出ZGC以得到广大开源爱好者的支持,并由社区推动,快速发展。关于ZGC更多的介绍可以参考官方网站。