【JVM篇】Java开疆拓土的垃圾收集器ZGC

1.序言

1.1 ZGC 诞生的背景

为了满足不同的业务需求,Java 的 GC 算法也在不停迭代,对于特定的应用,选择其最适合的 GC 算法,才能更高效的帮助业务实现其业务目标。对于这些延迟敏感的应用来说,GC 停顿已经成为阻碍 Java 广泛应用的一大顽疾,需要更适合的 GC 算法以满足这些业务的需求。

近些年来,服务器的性能越来越强劲,各种应用可使用的堆内存也越来越大,常见的堆大小从 10G 到百 G 级别,部分机型甚至可以到达 TB 级别,在这类大堆应用上,传统的 GC,如 CMS、G1 的停顿时间也跟随着堆大小的增长而同步增加,即堆大小指数级增长时,停顿时间也会指数级增长。特别是当触发 Full GC 时,停顿可达分钟级别(百GB级别的堆)。

当业务应用需要提供高服务级别协议(Service Level Agreement,SLA),例如 99.99% 的响应时间不能超过 100ms,此时 CMS、G1 等就无法满足业务的需求。
为满足当前应用对于超低停顿、并应对大堆和超大堆带来的挑战,伴随着 2018 年发布的 JDK 11,A Scalable Low-Latency Garbage Collector - ZGC 应运而生。

1.2 ZGC 的目标

ZGC和Shenandoah的目标是高度相似的,都希望在尽可能对吞吐量影响不太大的前提下实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟 。
其目标可大致分为一下几个点:

  1. 支持TB量级的堆。我们生产环境的硬盘还没有上TB呢,这应该可以满足未来十年内,所有JAVA应用的需求了吧。
  2. 最大GC停顿时间不超10ms。目前一般线上环境运行良好的JAVA应用Minor GC停顿时间在10ms左右,Major GC一般都需要100ms以上(G1可以调节停顿时间,但是如果调的过低的话,反而会适得其反),之所以能做到这一点是因为它的停顿时间主要跟Root扫描有关,而Root数量和堆大小是没有任何关系的。
  3. 最糟糕的情况下吞吐量会降低15%。这都不是事,停顿时间足够优秀。至于吞吐量,通过扩容分分钟解决。另外,Oracle官方提到了它最大的优点是:它的停顿时间不会随着堆的增大而增长!也就是说,几十G堆的停顿时间是10ms以下,几百G甚至上T堆的停顿时间也是10ms以下。
  4. 奠定未来GC特性的基础。
1.3 ZGC的特点

不分代:单代,即ZGC没有分代。我们知道以前的垃圾回收器之所以分代,是因为源于“「大部分对象朝生夕死」”的假设,事实上大部分系统的对象分配行为也确实符合这个假设。那么为什么ZGC就不分代呢?因为分代实现起来麻烦,作者就先实现出一个比较简单可用的单代版本,后续会优化。

1.4 ZGC 的优点

ZGC的优点主要分为一下几个方面:

  1. 停顿时间不超过10ms(JDK16已经达到不超过1ms)
  2. 停顿时间不会随着堆的大小,或者活跃对象的大小而增加
  3. 支持8MB~4TB级别的堆,JDK15后已经可以支持16TB

2 ZGC 的内存布局

2.1 ZGC内存布局

首先从Z G C的内存布局说起。与Shenandoah和G 1一样,Z G C也采用基于Region的堆内存布局,但 与 它 们 不 同 的 是 , Z G C 的 R e gi o n ( 在 一 些 官 方 资 料 中 将 它 称 为 P a ge 或 者 Z P a ge , 本 章 为 行 文 一 致 继 续 称 为 R e gi o n ) 具 有 动 态 性 — — 动 态 创 建 和 销 毁 , 以 及 动 态 的 区 域 容 量 大 小 。 在 x6 4 硬 件 平 台 下 , Z G C 的 Region可以具有如图3-19所示的大、中、小三类容量:

  • 小型Region(Small Region):容量固定为2M B,用于放置小于256KB的小对象。
  • 中型Region(M edium Region):容量固定为32M B,用于放置大于等于256KB但小于4M B的对象。
  • 大型Region(Large Region):容量不固定,可以动态变化,但必须为2M B的整数倍,用于放置 4M B或以上的大对象。每个大型Region中只会存放一个大对象,这也预示着虽然名字叫作“大型 Region”,但它的实际容量完全有可能小于中型Region,最小容量可低至4M B。大型Region在ZGC的实 现中是不会被重分配(重分配是ZGC的一种处理动作,用于复制对象的收集器阶段,稍后会介绍到) 的,因为复制一个大对象的代价非常高昂。
2.2 ZGC内存布局结构

ZGC内存布局结构如图:

ZGC中的内存分配示意图解:
【JVM篇】Java开疆拓土的垃圾收集器ZGC_第1张图片

  1. 当对象大小小于等于256KB时,对象分配在小页面。
  2. 当对象大小在256KB和4M之间,对象分配在中页面。
  3. 当对象大于4M,对象分配在大页面。

ZGC对于不同页面回收的策略也不同。简单地说,小页面优先回收;中页面和大页面则尽量不回收。

2.3 内存布局设计原因

标准大页(huge page)是Linux Kernel 2.6引入的,目的是通过使用大页内存来取代传统的4KB内存页面,以适应越来越大的系统内存,让操作系统可以支持现代硬件架构的大页面容量功能。Huge pages 有两种格式大小: 2MB 和 1GB , 2MB 页块大小适合用于 GB 大小的内存, 1GB 页块大小适合用于 TB 级别的内存; 2MB 是默认的页大小。所以ZGC这么设置也是为了适应现代硬件架构的发展,提升性能。

3 ZGC核心技术与概念

3.1 支持NUMA
3.1.1 了解UMA技术

对于X86架构的计算机,内存控制器还没有整合进CPU,所有对内存的访问都需要通过北桥芯片来完成。X86系统中的所有内存都可以通过CPU进行同等访问。任何CPU访问任何内存的速度是一致的,不必考虑不同内存地址之间的差异,这称为“统一内存访问”(Uniform Memory Access,UMA)如下图所示:
【JVM篇】Java开疆拓土的垃圾收集器ZGC_第2张图片

3.1.2 NUMA技术的出现

在UMA中,各处理器与内存单元通过互联总线进行连接,各个CPU之间没有主从关系。之后的X86平台经历了一场从“拼频率”到“拼核心数”的转变,越来越多的核心被尽可能地塞进了同一块芯片上,各个核心对于内存带宽的争抢访问成为瓶颈,所以人们希望能够把CPU和内存集成在一个单元上(称Socket),这就是非统一内存访问(Non-Uniform Memory Access,NUMA)。很明显,在NUMA下,CPU访问本地存储器的速度比访问非本地存储器快一些。
【JVM篇】Java开疆拓土的垃圾收集器ZGC_第3张图片

3.2 根可达算法
3.2.1 算法定义

来判定对象是否存活的。这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

3.2.2 GC Roots

作为GC Roots的对象主要包括下面4种:

  1. 虚拟机栈(栈帧中的本地变量表):各个线程调用方法堆栈中使用到的参数、局部变量、临时变量等。
  2. 方法区中类静态变量:java类的引用类型静态变量。
  3. 方法区中常量:比如:字符串常量池里的引用。
  4. 本地方法栈中JNI指针:(即一般说的Native方法)。
  5. ZGC中初始标记和并发标记

图解:
【JVM篇】Java开疆拓土的垃圾收集器ZGC_第4张图片

3.3 染色指针

染色指针是一种直接将少量额外的信息存储在指针上的技术,可是为什么指针本身也可以存储额外信息呢?在64位系统中,理论可以访问的内存高达16EB(2的64次幂)字节。实际上,基于需求(用不到那么多内存)、性能(地址越宽在做地址转换时需要的页表级数越多)和成本(消耗更多晶体管)的考虑,在AMD64架构中只支持到52位(4PB)的地址总线和48位(256TB)的虚拟地址空间,所以目前64位的硬件实际能够支持的最大内存只有256TB。此外,操作系统一侧也还会施加自己的约束,64位的Linux则分别支持47位(128TB)的进程虚拟地址空间和46位(64TB)的物理地址空间,64位的Windows系统甚至只支持44位(16TB)的物理地址空间。

如下图所示:
【JVM篇】Java开疆拓土的垃圾收集器ZGC_第5张图片

每个对象有一个64位指针,这64位被分为:
18位:预留给以后使用;
1位:Finalizable标识,此位与并发引用处理有关,它表示这个对象只能通过finalizer才能访问;
1位:Remapped标识,设置此位的值后,对象未指向relocation set中(relocation set表示需要GC的Region集合);
1位:Marked1标识;
1位:Marked0标识,和上面的Marked1都是标记对象用于辅助GC;
42位:对象的地址(所以它可以支持2^42=4T内存):

ZGC只支持64位系统(使用64位指针)
ZGC中低42位表示使用中的堆空间
ZGC借几位高位来做GC相关的事情(快速实现垃圾回收中的并发标记、转移和重定位等)

【JVM篇】Java开疆拓土的垃圾收集器ZGC_第6张图片

为什么有2个mark标记?

每一个GC周期开始时,会交换使用的标记位,使上次GC周期中修正的已标记状态失效,所有引用都变成未标记。
GC周期1:使用mark0, 则周期结束所有引用mark标记都会成为01。
GC周期2:使用mark1, 则期待的mark标记10,所有引用都能被重新标记。
通过对配置ZGC后对象指针分析我们可知,对象指针必须是64位,那么ZGC就无法支持32位操作系统,同样的也就无法支持压缩指针了(CompressedOops,压缩指针也是32位)。

染色指针的三大优势:

  1. 一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理,这使得理论上只要还有一个空闲Region,ZGC就能完成收集。
  2. 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些专门的记录操作。
  3. 颜色指针具备强大的扩展性,它可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。
3.4 ZGC中读屏障

之前的GC都是采用Write Barrier,这次ZGC采用了完全不同的方案读屏障,这个是ZGC一个非常重要的特性。在标记和移动对象的阶段,每次「从堆里对象的引用类型中读取一个指针」的时候,都需要加上一个Load Barriers。
那么我们该如何理解它呢?看下面的代码,第一行代码我们尝试读取堆中的一个对象引用obj.fieldA并赋给引用o(fieldA也是一个对象时才会加上读屏障)。如果这时候对象在GC时被移动了,接下来JVM就会加上一个读屏障,这个屏障会把读出的指针更新到对象的新地址上,并且把堆里的这个指针“修正”到原本的字段里。这样就算GC把对象移动了,读屏障也会发现并修正指针,于是应用代码就永远都会持有更新后的有效指针,而且不需要STW。
【JVM篇】Java开疆拓土的垃圾收集器ZGC_第7张图片

那么,JVM是如何判断对象被移动过呢?就是利用上面提到的颜色指针,如果指针是Bad Color,那么程序还不能往下执行,需要「slow path」,修正指针;如果指针是Good Color,那么正常往下执行即可;

这个动作是不是非常像JDK并发中用到的CAS自旋?读取的值发现已经失效了,需要重新读取。而ZGC这里是之前持有的指针由于GC后失效了,需要通过读屏障修正指针。后面3行代码都不需要加读屏障:Object p = o这行代码并没有从堆中读取数据;o.doSomething()也没有从堆中读取数据;obj.fieldB不是对象引用,而是原子类型。正是因为Load Barriers的存在,所以会导致配置ZGC的应用的吞吐量会变低。官方的测试数据是需要多出额外4%的开销:
【JVM篇】Java开疆拓土的垃圾收集器ZGC_第8张图片

4 ZGC 垃圾收集过程

ZGC的运作过程大致可划分为以下四个大的阶段。全部四个阶段都是可以并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段。这四个阶段分别为:并发标记(Concurrent Mark)、并发预备重分配( Concurrent Prepare for Relocate)、并发重分配(Concurrent Relocate)、并发重映射(Concurrent Remap)。
其过程如下图所示:

【JVM篇】Java开疆拓土的垃圾收集器ZGC_第9张图片

一次ZGC处理流程:
【JVM篇】Java开疆拓土的垃圾收集器ZGC_第10张图片

第二次ZGC 流程:
【JVM篇】Java开疆拓土的垃圾收集器ZGC_第11张图片

4.1 标记阶段

与G1、Shenandoah一样,并发标记是遍历对象图做可达性分析的阶段,前后也要经过类似于G1、Shenandoah的初始标记、最终标记(尽管ZGC中的名字不叫这些)的短暂停顿,而且这些停顿阶段所做的事情在目标上也是相类似的。与G1、Shenandoah不同的是,ZGC的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked 0、Marked 1标志位。

4.1.1 初始阶段

在ZGC初始化之后,此时地址视图为Remapped,程序正常运行,在内存中分配对象,满足一定条件后垃圾回收启动。
【JVM篇】Java开疆拓土的垃圾收集器ZGC_第12张图片

4.1.2 初始标记

这个阶段需要暂停(STW),初始标记只需要扫描所有GC Roots,其处理时间和GC Roots的数量成正比,停顿时间不会随着堆的大小或者活跃对象的大小而增加。
第一次GC:初始标记只需要扫描所有GC Roots 将其标为绿色
【JVM篇】Java开疆拓土的垃圾收集器ZGC_第13张图片

第二次GC 将GC Roots 标记为红色
【JVM篇】Java开疆拓土的垃圾收集器ZGC_第14张图片

4.1.3 并发标记

这个阶段不需要暂停(没有STW),扫描剩余的所有对象,这个处理时间比较长,所以走并发,业务线程与GC线程同时运行。但是这个阶段会产生漏标问题。
第一次GC: 将将GC Roots 连接的对象染色指针置为绿色
【JVM篇】Java开疆拓土的垃圾收集器ZGC_第15张图片

第二次GC: 修正指针地址并且删除转发表 修改染色指针为红色
【JVM篇】Java开疆拓土的垃圾收集器ZGC_第16张图片

4.1.4 再标记

这个阶段需要暂停(没有STW),主要处理漏标对象,通过SATB算法解决(G1中的解决漏标的方案)。

4.2 转移阶段 (并发预备重分配&重分配 )

将活跃的对象转移到新的空白空间上,原有的内存空间进行回收了。

4.2.1 并发转移准备 (并发预备重分配)

这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。重分配集与G1收集器的回收集(Collection Set)还是有区别的,ZGC划分Region的目的并非为了像G1那样做收益优先的增量回收。相反,ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。因此,ZGC的重分配集只是决定了里面的存活对象会被重新复制到其他的Region中,里面的Region会被释放,而并不能说回收行为就只是针对这个集合里面的Region进行,因为标记过程是针对全堆的。此外,在JDK 12的ZGC中开始支持的类卸载以及弱引用的处理,也是在这个阶段中完成的。
总结起来就是算出可以优先回收的区域。

4.2.2 初始转移 (重分配)

转移初始标记的存活对象同时做对象重定位 (此过程有STW)
第一次GC:
【JVM篇】Java开疆拓土的垃圾收集器ZGC_第17张图片

4.2.3 并发转移 (重分配)

并发转移是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障(读屏障(见下面详解))所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力。ZGC的颜色指针因为“自愈”(Self-Healing)能力,所以只有第一次访问旧对象会变慢, 一旦重分配集中某个Region的存活对象都复制完毕后, (此过程无STW)
如何进行转移?
需要转发表 类似于hashmap , 在转移对象时同时插入转发表数据 该操作为原子操作转发表(类似于HashMa
第一次GC: 转移对象并且插入转发表数据
【JVM篇】Java开疆拓土的垃圾收集器ZGC_第18张图片

4.3 重映射阶段
4.3.1 并发重映射

重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,但是ZGC中对象引用存在“自愈”功能,所以这个重映射操作并不是很迫切。ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后, 原来记录新旧对象关系的转发表就可以释放掉了。(第二次GC)

5 ZGC中GC触发机制(JAVA16)

  • 预热规则:服务刚启动时出现,一般不需要关注。日志中关键字是“Warmup”。
  • JVM启动预热,如果从来没有发生过GC,则在堆内存使用超过10%、20%、30%时,分别触发一次GC,以收集GC数据.
  • 基于分配速率的自适应算法:最主要的GC触发方式(默认方式),其算法原理可简单描述为”ZGC根据近期的对象分配速率以及GC时间,计算出当内存占用达到什么阈值时触发下一次GC”。通过ZAllocationSpikeTolerance参数控制阈值大小,该参数默认2,数值越大,越早的触发GC。日志中关键字是“Allocation Rate”。
  • 基于固定时间间隔:通过ZCollectionInterval控制,适合应对突增流量场景。流量平稳变化时,自适应算法可能在堆使用率达到95%以上才触发GC。流量突增时,自适应算法触发的时机可能会过晚,导致部分线程阻塞。我们通过调整此参数解决流量突增场景的问题,比如定时活动、秒杀等场景。
  • 主动触发规则:类似于固定间隔规则,但时间间隔不固定,是ZGC自行算出来的时机,我们的服务因为已经加了基于固定时间间隔的触发机制,所以通过-ZProactive参数将该功能关闭,以免GC频繁,影响服务可用性。
  • 阻塞内存分配请求触发:当垃圾来不及回收,垃圾将堆占满时,会导致部分线程阻塞。我们应当避免出现这种触发方式。日志中关键字是“Allocation Stall”。
    外部触发:代码中显式调用System.gc()触发。 日志中关键字是“System.gc()”。
  • 元数据分配触发:元数据区不足时导致,一般不需要关注。 日志中关键字是“Metadata GC Threshold”。

6 ZGC参数设置

ZGC 优势不仅在于其超低的 STW 停顿,也在于其参数的简单,绝大部分生产场景都可以自适应。当然,极端情况下,还是有可能需要对 ZGC 个别参数做个调整,大致可以分为三类:

  1. 堆大小:Xmx。当分配速率过高,超过回收速率,造成堆内存不够时,会触发 Allocation Stall,这类 Stall 会减缓当前的用户线程。因此,当我们在 GC 日志中看到 Allocation Stall,通常可以认为堆空间偏小或者 concurrent gc threads 数偏小。
  2. GC 触发时机:ZAllocationSpikeTolerance, ZCollectionInterval。ZAllocationSpikeTolerance 用来估算当前的堆内存分配速率,在当前剩余的堆内存下,ZAllocationSpikeTolerance 越大,估算的达到 OOM 的时间越快,ZGC 就会更早地进行触发 GC。ZCollectionInterval 用来指定 GC 发生的间隔,以秒为单位触发 GC。
  3. GC 线程:ParallelGCThreads, ConcGCThreads。ParallelGCThreads 是设置 STW 任务的 GC 线程数目,默认为 CPU 个数的 60%;ConcGCThreads 是并发阶段 GC 线程的数目,默认为 CPU 个数的 12.5%。增加 GC 线程数目,可以加快 GC 完成任务,减少各个阶段的时间,但也会增加 CPU 的抢占开销,可根据生产情况调整。

可调节参数如下:
【JVM篇】Java开疆拓土的垃圾收集器ZGC_第19张图片
把活跃对象转移(复制)到新的内存上,原来的内存空间可以回收
把活跃对象转移(复制)到新的内存上,原来的内存空间可以回把活跃对象转移(复制)到新的内存上,原来的内存空间可以

7 参考文献

  • openjdk zgc
  • ZGC-Jfokus-2018.pdf
  • 垃圾收集器ZGC模块
  • 深入理解Java虚拟机读书笔记

你可能感兴趣的:(java,jvm,开发语言)