深入JVM垃圾回收器详解:ZGC,内存管理模型与高速分配设计

ZGC

2017年,Oracle公司宣布实现一款新的垃圾回收ZGC,并将贡献给OpenJDK社区。

在2018年9月发布的JDK 11中,ZGC作为实验性质的特性被正式引入JVM中。

自首次发布到目前为止,它是社区最活跃的项目。在随后的JDK12、JDK 13、JDK 14中都有重要的特性发布;在JDK 15中,ZGC正式升级为产品特性;ZGC的发展并没有停止,在JDK 16中引入了并发栈扫描,进一步减少停顿时间。

在ZGC发布以前,JVM的所有垃圾回收器中对停顿时间管理得最好的是G1。

G1的设计思路是以停顿时间为目标,并在吞吐量和停顿时间之间寻找平衡,但是在一些要求极致停顿时间的场景中的表现差强人意。G1是一款并发的垃圾回收器,但是G1仅仅在标记时采用并发操作,在执行Minor GC时仍然采用并行操作。ZGC吸收了G1的优点,并着力解决G1的一些不足。下面先看看G1的不足。

G1的目标是在可控的停顿时间内完成垃圾回收,所以对内存进行了分区设计。在回收时采用部分内存回收(在执行Minor GC时会回收所有新生代分区,在混合回收时会回收所有新生代分区和部分老生代分区),支持的内存也可以达到几十GB甚至上百GB。为了进行部分回收,G1实现了RSet管理对象的引用关系。但由于设计上的特点,导致G1存在下面的问题:

1)停顿时间过长,通常G1的停顿时间要达到几十到几百毫秒。这个数字其实已经非常小了,但是因为垃圾回收要求应用暂停,导致应用程序在这几十或者几百毫秒中不能提供服务。在某些场景中,特别是对用户体验有较高要求的情况下,它不能满足实际的需求。

2)内存利用率不高,通常引用关系的处理需要额外消耗内存,一般占整个内存的1%~20%(在JDK 17中对这一部分做了较大的优化,但额外存储成本仍然较高)。

3)支持的内存空间有限,不适用于超大内存的系统,特别是在100GB内存以上的系统中,内存过大会导致停顿时间增加。

ZGC作为新一代的垃圾回收器,在设计之初就定下了三大目标:支持TB级内存,停顿时间控制在10毫秒之内,对程序吞吐量影响小于15%。实际上,目前ZGC已经基本满足设计之初时定下的目标,支持4TB~16TB[1]堆空间,从实际测试的情况来看,停顿时间通常都在10毫秒以下[2],并且垃圾回收所引起的暂停时间并不会随着内存的增大而延长。它应该怎么设计或者怎么改进以前的垃圾回收器才能实现这些目标呢?

ZGC的设计思路借鉴了一款商业垃圾回收器Azul的C4,关于C4的介绍,可以参考Tene G等人的论文C4: The Continuously Concurrent CompactingCollector。ZGC希望能达到10毫秒以内的停顿时间,但不是所有的垃圾回收活动都能在10毫秒内完成,停顿时间指的是需要STW的初始标记、重新标记和重定位阶段花费的时间。另外要提一下,这里的10毫秒是一个目标值。有哪些因素可能影响ZGC的目标停顿时间?除了ZGC中本身的STW活动以外,还有进入安全点(执行垃圾回收活动之前)的花费,比如我们知道,在JVM中进入安全点时会进行字符串回收(这里的字符串回收针对的是使用String类中intern方法导致的垃圾),所以ZGC为了保证进入安全点的时间足够短,会把这一部分工作优化成并发处理。

回到ZGC如何设计达成目标这一问题。简单的回答是ZGC把一切能并发处理的工作都并发执行。从技术发展路径上看,可以认为ZGC是G1在并发执行方面的进一步发展。

我们知道,G1中实现了并发标记,标记已经不会再影响停顿时间了,所以G1中的停顿时间主要是在垃圾回收阶段(Minor GC和Mixed GC)复制对象造成的。在复制算法中,需要把对象转移到新的空间中,并且更新其他对象到这个对象的引用。实际中对象的转移涉及内存的分配和对象成员变量的复制,而对象成员变量的复制是非常耗时的。在G1中对象的转移都是在STW中并行执行的[3],而ZGC把对象的转移也并发执行,从而满足停顿时间在10毫秒以下。下面看一个实际的例子,这是使用G1作为垃圾回收器运行Cassandra的一个日志片段。在Cassandra的配置中,希望每次停顿时间为100毫秒。但是G1在这一次垃圾回收中花费了497.945毫秒,其中仅Evacuate Collection Set就花费了493毫秒。日志片段如下:

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) Evacuate Collection Set: 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就是对整个回收集合的分区进行标记和转移。

493毫秒包含了标记和转移,如果使用一些诊断参数查看更细粒度的统计数据,通常转移时间占比在80%左右,转移时因为包括内存复制,所以极其耗时,从而导致停顿时间不可控。ZGC的改进就是把这步最耗时的动作变成并发执行。

另外,在G1中可能会存在Full GC,如果发生了Full GC,也可能导致停顿时间不可控。在目前的ZGC中,垃圾回收就是Full GC,也就是每发生一次垃圾回收就是一次Full GC,而每次垃圾回收的停顿时间在10毫秒以下,所以困扰G1的Full GC导致停顿时间不可控的问题也解决了。因为ZGC每次垃圾回收都是Full GC(即每次都是全量回收),那么大家可能会问,如果对象分配不成功,ZGC是怎么处理这种情况的呢?这里先留一个疑问,后文回答。

ZGC除了并发转移以外,还对整个垃圾回收在进入STW的过程做了改进,把原来串行/并行执行的动作也优化为并发执行。在这里比较一下不同垃圾回收器在并发粒度上的区别,如表8-1所示。

表8-1 不同垃圾回收器的并发执行

这里的不支持并发执行不同的GC实现隐含的意义稍有不同,对于串行回收器,所有步骤是串行执行的。其他垃圾回收器不支持还分成两种,一种是并行执行,例如转移、引用处理、弱引用处理;另一种就是串行执行,如符号表、字符串表、类卸载,它们通常是在进入安全点的时候执行。这样的设计是在实现的复杂性和效率之间寻找平衡,通常来说并发处理效率高,但是实现复杂;串行/并行效率略低,但实现简单。

最后对ZGC的特性做一个简单的总结。除了并发执行这个显著特点之外,ZGC还有以下特性:

1)不分代的垃圾回收器,即垃圾回收时对全量内存进行标记,但是回收的时候仅针对部分内存回收,优先回收垃圾比较多的页面(分代已经在实现中)。

2)最初仅支持Linux 64/X86位系统,后又扩展至Mac、Windows系统和AArch64平台。

3)内存分区管理,且支持不同的分区粒度。在ZGC中分区称为页面(Page),有小页面、中页面、大页面3种。

4)颜色指针,通过设计不同的标记位区分不同的虚拟空间,而这些不同标记位指示不同虚拟空间通过mmap被映射在同一物理地址。颜色指针用于快332速实现并发标记、转移和重定位。

5)设计了读屏障,实现了并发标记和并发转移的处理。

6)支持NUMA,尽量把对象分配在应用访问速度比较快的地方。

关于这些特点后文会一一介绍。截至目前,ZGC表现优异,而且越来越多的人参与开发并使用它。

[1] 在ZGC最初发布时仅支持最大4TB的堆空间,在JDK 13中堆空间最大支持16TB。

[2] 在ZGC最初发布时,对大多数应用可以满足停顿时间在10毫秒以下,但存在一些应用停顿时间还是会超过10毫秒,其主要原因是最初的版本不支持类卸载或者超大的根集合。在最新的JDK 16发布后,测试表明大多数应用的停顿时间在1毫秒以下。

[3] 串行回收器、并行回收器和CMS中的复制也是在STW中执行的。

内存管理

在内存管理上,ZGC吸收了以前垃圾回收器的经验,并对其不足做了增强。具体来说可以总结为两点:

1)在JVM内部对内存进行了抽象,设计了虚拟内存管理和物理内存管理。其中虚拟内存的管理是面向应用的,为应用提供以分页为粒度的管理方式;而物理内存的管理是面向OS的,当向OS请求内存时不再需要连续的内存空间。

2)提供高速的分配机制,设计了不同层次的缓存,包括:应用线程级缓存、CPU级缓存和节点级缓存。

下面对这两个特点进一步展开介绍。

内存管理模型

在JVM早期的垃圾回收器中,应用看到的堆空间为新生代和老生代。在JVM内部表现为一整块内存,并且这块内存是垃圾回收器在启动时向OS请求,要求OS提供一个可以连续使用的虚拟内存空间。从应用、JVM和OS角度来看,堆空间的布局如图8-1所示。

图8-1 GC堆内存布局一般模型

该内存模型非常简单,从应用到OS看到的内存是一致的,JVM管理非常简单。但是这样的内存模型有一个比较大的缺点:应用启动时JVM必须划分好新生代和老生代的比例,同时需要向OS申请虚拟空间。这样的设计要求使用者非常了解应用,才能保证获得较高的效率。

另外,当内存比较大时,这样的设计在串行或者并行回收中很难保证停顿时间。所以一种自然而然的做法是将应用使用的内存划分成较小的块(G1中称为分区,ZGC称为页面),如图8-2所示。

图8-2 G1堆内存管理模式

这是G1采用的内存管理模型。在该内存模型中,应用仍然可以感知到新生代和老生代,但是新生代和老生代的划分由停顿预测模型来预测。

该内存模型可以有效解决停顿时间过长的问题。但是还有一个问题该内存模型并没解决:需要向OS申请一块大的连续虚拟内存。这通常不是一个问题,因为OS有非常大的虚拟内存供应用使用。但是该设计隐含的问题是:JVM在向OS归还内存时稍微有些麻烦。在G1中内存按照分区设计,当内存不再使用后,JVM可以将内存归还给OS,而归还的方式也是按照分区来执行的。因为归还的分区是应用不再使用的分区,所以分区在应用中一般都是不连续的,归还给OS后,可能会导致OS的虚拟内存不连续。可能存在一种情况,虚拟内存有较大的可用空间,但是因为地址不连续,无法响应应用的大对象分配请求,从而导致垃圾回收。

所以ZGC继续在G1的内存模型上优化,引入虚拟地址管理和物理地址管理。虚拟内存提供分页管理机制,用于满足应用的分区请求;而物理内存管理则负责向OS请求和归还内存。内存模型如图8-3所示。

图8-3 ZGC堆内存布局示意图

因为现代操作系统都采用页面式管理方式,所以ZGC中也是以页面的形式对OS的虚拟内存进行管理。图8-3中为了便于演示,将ZGC物理内存管理的页面和OS虚拟内存的页面一一对应,实际上ZGC的页面一般比OS的页面大,所以通常来说ZGC的一个页面会包含多个OS的页面。

高速分配设计

ZGC另外一个值得学习的地方是对分配进行细致的优化,更加适用于现代计算机体系结构的发展趋势,特别是多核、多节点的计算机体系结构。

在JVM中有一个概念称为TLAB(
Thread-Local-Allocation-Buffer),用于提高应用多线程之间的分配效率(JVM等也有类似的实现)。TLAB的思想是为每一个Mutator申请一块缓存,这个缓存被称为TLAB,当Mutator分配内存时只从自己的TLAB中进行分配,多个Mutator分配内存互不干涉,这样多线程本文给大家讲解的内容是JVM垃圾回收器详解:ZGC,内存管理访问同一内存的锁竞争就被消除了。而ZGC在此基础上又对内存分配做了进一步的优化,主要表现如下:

1)引入了CPU的缓存。TLAB虽然能解决多个Mutator之间内存分配的问题,但是在使用TLAB时,需要为每一个Mutator分配一个TLAB,而这样的TLAB缓存需要从全局堆空间中获取,所以在申请一个新的TLAB时仍然需要锁。而ZGC引入CPU缓存,每个CPU包含一个分页(Page),该CPU上所有的Mutator在申请TLAB时都从CPU的缓存中获得。因为同一CPU一个时刻只有一个Mutator获得运行,所以该CPU上的多个Mutator并不需要锁,进一步优化了锁竞争,从而将Mutator的TLAB之间的锁竞争转化为多个CPU之间的缓存分配竞争。

2)引入NUMA缓存。由于多个CPU位于同一个NUMA节点,在分配时CPU使用的缓存尽量从本NUMA节点的缓存获取,减少跨NUMA节点的分配,进一步优化了Mutator分配的效率。

ZGC设计的三级分配缓存如图8-4所示。

图8-4 ZGC设计的三级分配缓存

当然,现代计算机体系结构设计得非常复杂,在NUMA架构中从CPU Core到Socket可能还有进一步的划分,例如将在第15章介绍的鲲鹏920,多个CPUCore首先组成CPU Cluster,多个CPU Cluster组成一个CPU Die,两个CPUDie组成一个Socket,两个Socket组成一个处理器,而这样复杂的设计必然对内存的访问速度有影响。但是目前NUMA相关API并不能反映这些细节,所以无法在JVM的实现中体现。未来,随着NUMA相关API的完善,在内存管理上还可以进一步优化。

ZGC中CPU缓存和NUMA缓存是以页面为基础的,其中页面类似于G1 GC中的Region。但是ZGC对页面进行进一步的细化,设计了大、中、小3种类型的页面。3种不同的页面管理的对象大小也不同。3种页面的具体信息如表8-2所示。

表8-2 ZGC页面分类总结

表8-2中MinObjectAlignmentInBytes的默认值是8,它由参数ObjectAlignmentInBytes控制,大小为8~256,且为2的幂次。对象对齐的粒度影响对象的分配、访问速度及内存空间的浪费,通常来说,粒度越大,处理器访问内存的速度越快,但可能导致过多的浪费。在实际中,可以根据应用系统对象的平均大小来合理地设置该值。

为什么设计3种类型的页面?其主要原因在于减少垃圾回收对大对象的转移。一般来说,对象的平均大小并不大(有研究表明,对象的平均大小为47字节,当然这与应用相关),按照不同大小来组织内存管理,可以有效地减少因对象对齐造成的浪费。在ZGC的实现中,大页面不参与垃圾回收,除非整个页面中的对象都已经死亡。

ZGC中的小页面和中页面都基于OS的页面,它们唯一的区别就是需要连续的虚拟页面的大小并不相同。

ZGC的页面最小为2MB,这个特性非常适合Linux的大页管理方式。在Linux中启动大页管理时,页面的大小为2MB。所以在使用ZGC的时候,使用OS的大页管理通常可以获得更好的性能。

  • 9
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值