ZGC 深入理解

概述

G1作为新一代成熟的垃圾回收器尚未得到广泛使用,新一代的垃圾回收器ZGC在JDK 11中引入,ZGC是2017年Oracle公司贡献给OpenJDK社区的,正式成为OpenJDK的开源项目,也就是JEP 333,目前它被明确地标记为实验性质(意味着还不成熟)。新一代的垃圾回收器一经发布,虽然尚不成熟,但是仍然阻挡不了众多Java程序员对它的追捧。ZGC是为了解决G1的不足,我们先看一下G1有哪些不足。

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

  1. 停顿时间过长,通常G1的停顿时间要达到几十到几百毫秒;这个数字其实已经非常小了,但是我们知道垃圾回收发生导致应用程序在这几十或者几百毫秒中不能提供服务,在某些场景中,特别是对用户体验有较高要求的情况下不能满足实际需求。
  2. 内存利用率不高,通常引用关系的处理需要额外消耗内存,一般占整个内存的1%~20%左右。
  3. 支持的内存空间有限,不适用于超大内存的系统,特别是在内存容量高于100GB的系统中,会因内存过大而导致停顿时间增长。

ZGC作为新一代的垃圾回收器,在设计之初就定义了三大目标:支持TB级内存,停顿时间控制在10ms之内,对程序吞吐量影响小于15%。实际上目前ZGC已经满足设计之初定义的目标,最大支持4TB堆空间,依据实际测试的情况来看,停顿时间通常都在10ms以下,并且垃圾回收所引起的暂停时间并不会随着内存的增大而延长。下面我们看一看ZGC是如何满足设计目标的。

简单地说,就是ZGC把一切能并发处理的工作都并发执行。在这里再强调一下JVM中“并行”和“并发”这两个词:并行指多个垃圾回收相关线程在操作系统之上并发地运行,强调的是只有垃圾回收线程工作,Java应用程序都暂停执行,因此并行线程执行的时候一定发生了STW;并发指如果启动了多个线程,那么与垃圾回收相关的线程并发地运行,同时这些线程会和Java应用程序并发地运行。所有线程都由操作系统调度,交替执行。

除了并发执行这个显著特点之外,ZGC还有以下特点:

  • 不分代的垃圾回收器,即垃圾回收时对全量内存进行标记,但是回收时仅针对部分内存回收,优先回收垃圾比较多的页面。
  • 仅支持Linux 64位系统,不支持32位平台。
  • 不支持使用压缩指针。
  • 内存分区管理,且支持不同的分区粒度,在ZGC中分区称为页面(page),有小页面、中页面、大页面3种。
  • 具有颜色指针(color pointer),通过设计不同的标记位区分不同的虚拟空间,而这些不同标记位指示的不同虚拟空间通过mmap映射在同一物理地址;颜色指针能够快速实现并发标记、转移和重定位。
  • 设计了读屏障,实现了并发标记和并发转移的处理。
  • 支持NUMA,尽量把对象分配在访问速度比较快的地方。

ZGC内存管理

对象的分配直接关系到内存的使用效率、垃圾回收的效率,不同的分配策略也会影响对象的分配速度,从而影响应用程序的运行。

ZGC为了支持太字节(TB)级内存,设计了基于页面(page)的分页管理(类似于G1的分区Region);为了能够快速地进行并发标记和并发移动,对内存空间重新进行了划分,这就是ZGC中新引入的Color Pointers;同时ZGC为了能更加高效地管理内存,设计了物理内存和虚拟内存两级内存管理。

为了能清晰地了解ZGC内存管理,在本章中,我们先介绍操作系统的虚拟内存和物理内存;随后介绍了ZGC的内存管理,主要包括多视图映射、NUMA支持和ZGC的两级内存管理;最后介绍了ZGC的对象分配,包括对象的快速分配和慢速分配、页面的分配。

  • 着色指针还不是很了解 本章略

垃圾回收触发的时机

本节主要介绍ZGC中的这些消息是何时触发的。
ZGC中,为了实现更高的性能,尽量避免进行同步垃圾回收,也就是说尽量避免触发同步垃圾回收的消息。ZGC中触发同步消息的场景也比较少,总体以触发异步消息为主。异步消息主要由ZDirector根据规则判断是否可以触发,在ZDirector流程图(见图3-3)中介绍了ZDirector有4种触发规则,本节主要介绍这4种规则是如何触发的,最后还会简要介绍其他的垃圾回收消息是如何触发的。

  • 基于固定时间间隔触发

ZDirector提供的第一个规则就是基于固定时间间隔触发垃圾回收。这个规则的目的非常简单,就是希望ZGC的垃圾回收器以固定的频率触发。在这一些场景中非常有用,例如我们的应用程序在晚上请求量比较低的情况下运行了很长时间,但是ZGC不满足其他垃圾回收器的触发条件,所以一直不会触发垃圾回收,这通常没什么问题,如果在早上某一个时间点开始请求暴增,这可能导致内存使用也暴增,而垃圾回收器来不及回收垃圾对象,将降低应用系统的吞吐量。所以ZGC提供了基于固定时间间隔触发垃圾回收的规则。
这个规则的实现也非常简单,就是判断前一次垃圾回收结束到当前时间是否超过时间间隔的阈值,如果超过,则触发垃圾回收,如果不满足,则直接返回。
需要说明的是,时间间隔由一个参数ZCollectionInterval来控制,这个参数的默认值为0,表示不需要触发垃圾回收。
实际工作中,可以根据场景设置该参数。

  • 预热规则触发

ZDirector提供的第二个规则是预热启动垃圾回收。为什么设计这一规则?设计这一规则的目的是当JVM刚启动时,还没有足够的数据来主动触发垃圾回收的启动,所以设置了预热规则。
预热规则指的是JVM启动后,当发现堆空间使用率达到10%、20%和30%时,会主动地触发垃圾回收。ZGC设计前3次垃圾回收可由预热规则触发,也就是说当垃圾回收触发(无论是由预热规则,还是主动触发垃圾回收)的次数超过3次时,预热规则将不再生效。

  • 根据分配速率

ZDirector提供的第三个规则是根据分配速率来预测是否能触发垃圾回收。这一规则设计的思路是:
1)收集数据:在程序运行时,收集过去一段时间内垃圾回收发生的次数和执行的时间、内存分配的速率memratio和当前空闲内存的大小memfree。
2)计算:根据过去垃圾回收发生的情况预测下一次垃圾回收发生的时间timegc,按照内存分配的速率预测空闲内存能支撑应用程序运行的实际timeoom,例如timeoom = memfree / memratio。
3)设计规则:如当timeoom小于timegc(垃圾回收的时间),则可以启动垃圾回收。这个规则的含义是如果从现在起到OOM发生前开始执行垃圾回收,刚好在OOM发生前完成垃圾回收的动作,从而避免OOM。在ZGC中ZDirector是周期运行的,所以在计算时还应该把OOM的时间减去采样周期的时间,采样周期记为timeinterval,则规则为timeoom < timegc + timeinterval时触发垃圾回收。
那么最主要的任务就变成了如何预测下一次垃圾回收时间timegc和内存分配的速率memratio(因为memfree是已知数据,无须额外处理)。
我们以预测垃圾回收时间timegc为例来看看如何预测。最简单也最直观的思路是,根据已经发生的垃圾回收所使用的时间来预测下一次垃圾回收可能花费的时间。这里提供几种思路:
1)收集过去一段时间内垃圾回收发生的次数和时间,取过去N次垃圾回收的平均时间作为下一次垃圾回收的预测时间;这一方法最为直观,但是准确度可能有待提高。
2)收集过去一段时间内垃圾回收发生的次数和时间,建立一个逻辑回归模型,从而预测下一次垃圾回收的预测时间;这一方法虽然比第一种方法有改进,根据垃圾回收的趋势来预测下一次垃圾回收的时间,但这一方法最大的问题是逻辑回归模型太简单,实际上如果我们能提供更多的输入,比如应用程序使用内存的情况、线程数等建立动态模型,这应该是一个非常好的方法。
3)使用衰减平均时间来预测下一次垃圾回收花费的时间。衰减平均方法实际上是第一种方法和第二种方法组合后的一种简化实现。它是一种简单的数学方法,用来计算一组数据的平均值,但是在计算平均值的时候最新的数据有更高的权重,即强调近期数据对结果的影响。
4)直接采用已经成熟的模型来预测下一次垃圾回收时间。ZGC中主要是基于正态分布来预测。

从统计角度来说,当数据样本足够大的时候(比如样本个数大于30个时),使用正态分布比较准确;当样本个数不多时,使用t分布效果比较好。在上述代码中实际上修正了真正的置信区间,使得置信度更高。如果读者有兴趣,可以实现t分布,并验证t分布和正态分布预测的准确度。

  • 主动触发

ZDirector提供的第四个规则是主动触发规则,该规则是为了应用程序在吞吐量下降的情况下,当满足一定条件时,还可以执行垃圾回收。这里满足一定条件指的是:
1)从上一次垃圾回收完成到当前时间,应用程序新增使用的内存达到堆空间的10%。
2)从上一次垃圾回收完成到当前时间已经过去了5min,记为timeelapsed。
如果这两个条件同时满足,预测垃圾回收时间为timegc,定义规则:如果numgc * timegc < timeelapsed,则触发垃圾回收。其中numgc是ZGC设计的常量,假设应用程序的吞吐率从50%下降到1%,需要触发一次垃圾回收。
这个规则实际上是为了弥补程序吞吐率骤降且长时间不执行垃圾回收而引入的。有一个诊断参数ZProactive来控制是否开启和关闭主动规则,默认值是true,即默认打开主动触发规则。
实际上这个规则和第一个规则(基于固定时间间隔规则)在某些场景中有一定的重复,第一个规则只强调时间间隔,本规则除了考虑时间之外还会考虑内存的增长和吞吐率下降的快慢程度。

  • 阻塞内存分配请求触发

阻塞内存分配由参数ZStallOnOutOfMemory控制,当参数ZStallOnOutOfMemory为true时进行阻塞分配,如果不能成功分配内存,则触发阻塞内存分配。
该触发请求是异步消息,并非同步消息。我们在2.3.2节中提到,页面阻塞分配会触发垃圾回收,直到垃圾回收完成并成功分配页面为止。因为是异步消息,所以页面阻塞分配请求需要额外的实现等待成功分配的功能,其实非常简单,可以通过一个循环来实现。
那为什么ZGC不把阻塞内存分配实现成同步消息,而是通过异步消息加上循环的方式?
原因在于同步消息请求的线程在发出同步消息后是通过通知等待机制完成的,通知等待机制通常会让出CPU,而页面阻塞分配采用异步消息加上循环的方式,不会让出CPU,在循环中判断垃圾回收是否完成,如果完成,则继续向下执行,这样的设计可以减少页面分配时因线程调度带来的额外开销。从这一点也可以看出,设计一款优秀的软件,需要从每一个细节出发,并仔细斟酌。

  • 外部触发

外部触发是指在Java代码中显式地调用System.gc()函数,在JVM执行该函数时,会触发垃圾回收。该触发请求是从用户代码主动触发的,从编程角度来看,说明程序员认为此时需要进行垃圾回收(当然首先是程序员正确使用System.gc()函数),所以ZGC把该触发规则设计为同步请求,只有在执行完垃圾回收后,才能进行后续代码的执行。

  • 元数据分配触发

元数据分配失败时,ZGC会尝试进行垃圾回收以确保元数据能正确分配。
异步垃圾回收后会尝试是否可以分配元数据对象空间,如果不能,将尝试进行同步垃圾回后可以分配元数据对象空间,如果还不成功,则尝试扩展元数据空间,再分配成功则返回内存空间,不成功则返回NULL。

回收过程

  • (STW)Pause Mark Start,开始标记,这个阶段只会标记(Mark0)由root引用的object,组成Root Set
  • Concurrent Mark,并发标记,从Root Set出发,并发遍历Root Set object的引用链并标记(Mark1)
  • (STW)Pause Mark End,检查是否已经并发标记完成,如果不是,需要进行多一次Concurrent Mark
  • Concurrent Process Non-Strong References,并发处理弱引用
  • Concurrent Reset Relocation Set
  • Concurrent Destroy Detached Pages
  • Concurrent Select Relocation Set,并发选择Relocation Set
  • Concurrent Prepare Relocation Set,并发预处理Relocation Set
  • (STW)Pause Relocate Start,开始转移对象,依然是遍历root引用
  • Concurrent Relocate,并发转移,将需要回收的Page里的对象转移到Relocation Set,然后回收Page给系统重新利用

你可能感兴趣的:(jvm)