新一代垃圾回收器ZGC
原文出处:新一代垃圾回收器ZGC的探索与实践
ZGC(The Z Garbage Collector)是JDK 11中推出的一款低延迟垃圾回收器,它的设计目标包括:
- 停顿时间不超过10ms;
- 停顿时间不会随着堆的大小,或者活跃对象的大小而增加;
- 支持8MB~4TB级别的堆(未来支持16TB)。
从设计目标来看,我们知道ZGC适用于大内存低延迟服务的内存管理和回收。本文主要介绍ZGC在低延时场景中的应用和卓越表现,文章内容主要分为四部分:
- GC之痛:介绍实际业务中遇到的GC痛点,并分析CMS收集器和G1收集器停顿时间瓶颈;
- ZGC原理:分析ZGC停顿时间比G1或CMS更短的本质原因,以及背后的技术原理;
- ZGC调优实践:重点分享对ZGC调优的理解,并分析若干个实际调优案例;
- 升级ZGC效果:展示在生产环境应用ZGC取得的效果。
GC之痛
很多低延迟高可用Java服务的系统可用性经常受GC停顿的困扰。GC停顿指垃圾回收期间STW(Stop The World),当STW时,所有应用线程停止活动,等待GC停顿结束。
以美团风控服务为例,部分上游业务要求风控服务65ms内返回结果,并且可用性要达到99.99%。但因为GC停顿,我们未能达到上述可用性目标。当时使用的是CMS垃圾回收器,单次Young GC 40ms,一分钟10次,接口平均响应时间30ms。通过计算可知,有( 40ms + 30ms ) 10次 /60000ms = 1.12%的请求的响应时间会增加0 ~ 40ms不等,其中30ms 10次 / 60000ms = 0.5%的请求响应时间会增加40ms。
可见,GC停顿对响应时间的影响较大。为了降低GC停顿对系统可用性的影响,我们从降低单次GC时间和降低GC频率两个角度出发进行了调优,还测试过G1垃圾回收器,但这三项措施均未能降低GC对服务可用性的影响。
CMS与G1停顿时间瓶颈
在介绍ZGC之前,首先回顾一下CMS和G1的GC过程以及停顿时间的瓶颈。CMS新生代的Young GC、G1和ZGC都基于标记- 复制算法,但算法具体实现的不同就导致了巨大的性能差异。
标记-复制算法应用在CMS新生代(ParNew是CMS默认的新生代垃圾回收器)和G1垃圾回收器中。标记-复制算法可以分为三个阶段:
- 标记阶段,即从GC Roots集合开始,标记活跃对象;
- 转移阶段,即把活跃对象复制到新的内存地址上;
- 重定位阶段,因为转移导致对象的地址发生了变化,在重定位阶段,所有指向对象旧地址的指针都要调整到对象新的地址上。
下面以G1为例,通过G1中标记-复制算法过程(G1的Young GC和Mixed GC均采用该算法),分析G1停顿耗时的主要瓶颈。G1垃圾回收周期如下图所示:

G1的混合回收过程可以分为标记阶段、清理阶段和复制阶段。
标记阶段停顿分析
- 初始标记阶段:初始标记阶段是指从GC Roots出发标记全部直接子节点的过程,该阶段是STW的。由于GC Roots数量不多,通常该阶段耗时非常短。
- 并发标记阶段:并发标记阶段是指从GC Roots开始对堆中对象进行可达性分析,找出存活对象。该阶段是并发的,即应用线程和GC线程可以同时活动。并发标记耗时相对长很多,但因为不是STW,所以我们不太关心该阶段耗时的长短。
- 再标记阶段:重新标记那些在并发标记阶段发生变化的对象。该阶段是STW的。
清理阶段停顿分析
- 清理阶段清点出有存活对象的分区和没有存活对象的分区,该阶段不会清理垃圾对象,也不会执行存活对象的复制。该阶段是STW的。
复制阶段停顿分析
- 复制算法中的转移阶段需要分配新内存和复制对象的成员变量。转移阶段是STW的,其中内存分配通常耗时非常短,但对象成员变量的复制耗时有可能较长,这是因为复制耗时与存活对象数量与对象复杂度成正比。对象越复杂,复制耗时越长。
四个STW过程中,初始标记因为只标记GC Roots,耗时较短。再标记因为对象数少,耗时也较短。清理阶段因为内存分区数量少,耗时也较短。转移阶段要处理所有存活的对象,耗时会较长。因此,G1停顿时间的瓶颈主要是标记-复制中的转移阶段STW。为什么转移阶段不能和标记阶段一样并发执行呢?主要是G1未能解决转移过程中准确定位对象地址的问题。
G1的Young GC和CMS的Young GC,其标记-复制全过程STW,这里不再详细阐述。
ZGC原理
全并发的ZGC
与CMS中的ParNew和G1类似,ZGC也采用标记-复制算法,不过ZGC对该算法做了重大改进:ZGC在标记、转移和重定位阶段几乎都是并发的,这是ZGC实现停顿时间小于10ms目标的最关键原因。
ZGC垃圾回收周期如下图所示:

ZGC只有三个STW阶段:初始标记,再标记,初始转移。其中,初始标记和初始转移分别都只需要扫描所有GC Roots,其处理时间和GC Roots的数量成正比,一般情况耗时非常短;再标记阶段STW时间很短,最多1ms,超过1ms则再次进入并发标记阶段。即,ZGC几乎所有暂停都只依赖于GC Roots集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与ZGC对比,G1的转移阶段完全STW的,且停顿时间随存活对象的大小增加而增加。
ZGC关键技术
ZGC通过着色指针和读屏障技术,解决了转移过程中准确访问对象的问题,实现了并发转移。大致原理描述如下:并发转移中“并发”意味着GC线程在转移对象的过程中,应 用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。而在ZGC中,应用线程访问对象将触发“读屏障” ,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。那么,JVM是如何判断对象被移动过呢?就 是利用对象引用的地址,即着色指针。下面介绍着色指针和读屏障技术细节。
着色指针
着色指针是一种将信息存储在指针中的技术。
ZGC仅支持64位系统,它把64位虚拟地址空间划分为多个子空间,如下图所示:

其中,[0~4TB) 对应Java堆,[4TB ~ 8TB) 称为M0地址空间,[8TB ~ 12TB) 称为M1地址空间,[12TB ~ 16TB)预留未使用,[16TB ~ 20TB) 称为Remapped空间。
当应用程序创建对象时,首先在堆空间申请一个虚拟地址,但该虚拟地址并不会映射到真正的物理地址。ZGC同时会为该对象在M0、M1和Remapped地址空间分别申请一个虚拟地址,且这三个虚拟地址对应同一个物理地址,但这三个空间在同一时间有且只有一个空间有效。ZGC之所以设置三个虚拟地址空间,是因为它使用“空间换时间”思想,去降低GC停顿时间。“空间换时间”中的空间是虚拟空间,而不是真正的物理空间。后续章节将详细介绍这三个空间的切换过程。
与上述地址空间划分相对应,ZGC实际仅使用64位地址空间的第0~41位,而第42~45位存储元数据,第47~63位固定为0。

ZGC将对象存活信息存储在42~45位中,这与传统的垃圾回收并将对象存活信息放在对象头中完全不同。
屏障
读屏障是JVM向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。需要注意的是,仅“从堆中读取对象引用”才会触发这段代码。
读屏障示例:
Object o = obj.FieldA // 从堆中读取引用,需要加入屏障
<Load barrier>
Object p = o // 无需加入屏障,因为不是从堆中读取引用
o.dosomething() // 无需加入屏障,因为不是从堆中读取引用
int i = obj.FieldB //无需加入屏障,因为不是对象引用
ZGC中读屏障的代码作用:在对象标记和转移过程中,用于确定对象的引用地址是否满足条件,并作出相应动作。
ZGC并发处理演示
接下来详细介绍ZGC一次垃圾回收周期中地址视图的切换过程:
- 初始化:ZGC初始化之后,整个内存空间的地址视图被设置为Remapped。程序正常运行,在内存中分配对象,满足一定条件后垃圾回收启动,此时进入标记阶段。
- 并发标记阶段:第一次进入标记阶段时视图为M0,如果对象被GC标记线程或者应用线程访问过,那么就将对象的地址视图从Remapped调整为M0。所以,在标记阶段结束之后,对象的地址要么是M0视图,要么是Remapped。如果对象的地址是M0视图,那么说明对象是活跃的;如果对象的地址是Remapped视图,说明对象是不活跃的。
- 并发转移阶段:标记结束后就进入转移阶段,此时地址视图再次被设置为Remapped。如果对象被GC转移线程或者应用线程访问过,那么就将对象的地址视图从M0调整为Remapped。
其实,在标记阶段存在两个地址视图M0和M1,上面的过程显示只用了一个地址视图。之所以设计成两个,是为了区别前一次标记和当前标记。即第二次进入并发标记阶段后,地址视图调整为M1,而非M0。
着色指针和读屏障技术不仅应用在并发转移阶段,还应用在并发标记阶段:将对象设置为已标记,传统的垃圾回收器需要进行一次内存访问,并将对象存活信息放在对象头中;而在ZGC中,只需要设置指针地址的第42~45位即可,并且因为是寄存器访问,所以速度比访问内存更快。

ZGC调优实践
ZGC不是“银弹”,需要根据服务的具体特点进行调优。网络上能搜索到实战经验较少,调优理论需自行摸索,我们在此阶段也耗费了不少时间,最终才达到理想的性能。本文 的一个目的是列举一些使用ZGC时常见的问题,帮助大家使用ZGC提高服务可用性。
调优基础知识
理解ZGC重要配置参数
以我们服务在生产环境中ZGC参数配置为例,说明各个参数的作用:
重要参数配置样例:
-Xms10G -Xmx10G
-XX:ReservedCodeCacheSize=256m -XX:InitialCodeCacheSize=256m
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC
-XX:ConcGCThreads=2 -XX:ParallelGCThreads=6
-XX:ZCollectionInterval=120 -XX:ZAllocationSpikeTolerance=5
-XX:+UnlockDiagnosticVMOptions -XX:-ZProactive
-Xlog:safepoint,classhisto*=trace,age*,gc*=info:file=/opt/logs/logs/gc-%t.log:time,tid,tags:filecount=5,filesize=50m
-Xms -Xmx:堆的最大内存和最小内存,这里都设置为10G,程序的堆内存将保持10G不变。
-XX:ReservedCodeCacheSize -XX:InitialCodeCacheSize: 设置CodeCache的大小, JIT编译的代码都放在CodeCache中,一般服务64m或128m就已经足够。我们的服务因为有一定特殊性,所以设置的较大,后面会详细介绍。
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC:启用ZGC的配置。
-XX:ConcGCThreads:并发回收垃圾的线程。默认是总核数的12.5%,8核CPU默认是1。调大后GC变快,但会占用程序运行时的CPU资源,吞吐会受到影响。
-XX:ParallelGCThreads:STW阶段使用线程数,默认是总核数的60%。
-XX:ZCollectionInterval:ZGC发生的最小时间间隔,单位秒。
-XX:ZAllocationSpikeTolerance:ZGC触发自适应算法的修正系数,默认2,数值越大,越早的触发ZGC。
-XX:+UnlockDiagnosticVMOptions -XX:-ZProactive:是否启用主动回收,默认开启,这里的配置表示关闭。
-Xlog:设置GC日志中的内容、格式、位置以及每个日志的大小。
理解ZGC触发时机
相比于CMS和G1的GC触发机制,ZGC的GC触发机制有很大不同。ZGC的核心特点是并发,GC过程中一直有新的对象产生。如何保证在GC完成之前,新产生的对象不会将堆占满,是ZGC参数调优的第一大目标。因为在ZGC中,当垃圾来不及回收将堆占满时,会导致正在运行的线程停顿,持续时间可能长达秒级之久。
ZGC有多种GC触发机制,总结如下:
- 阻塞内存分配请求触发:当垃圾来不及回收,垃圾将堆占满时,会导致部分线程阻塞。我们应当避免出现这种触发方式。日志中关键字是“Allocation Stall”。
- 基于分配速率的自适应算法:最主要的GC触发方式,其算法原理可简单描述为”ZGC根据近期的对象分配速率以及GC时间,计算出当内存占用达到什么阈值时触发下一次GC”。自适应算法的详细理论可参考彭成寒《新一代垃圾回收器ZGC设计与实现》一书中的内容。通过ZAllocationSpikeTolerance参数控制阈值大小,该参数默认2,数值越大,越早的触发GC。我们通过调整此参数解决了一些问题。日志中关键字是“Allocation Rate”。
- 基于固定时间间隔:通过ZCollectionInterval控制,适合应对突增流量场景。流量平稳变化时,自适应算法可能在堆使用率达到95%以上才触发GC。流量突增时,自适应算法触发的时机可能会过晚,导致部分线程阻塞。我们通过调整此参数解决流量突增场景的问题,比如定时活动、秒杀等场景。日志中关键字是“Timer”。
- 主动触发规则:类似于固定间隔规则,但时间间隔不固定,是ZGC自行算出来的时机,我们的服务因为已经加了基于固定时间间隔的触发机制,所以通过-ZProactive参数将该功能关闭,以免GC频繁,影响服务可用性。日志中关键字是“Proactive”。
- 预热规则:服务刚启动时出现,一般不需要关注。日志中关键字是“Warmup”。
- 外部触发:代码中显式调用System.gc()触发。日志中关键字是“System.gc()”。
- 元数据分配触发:元数据区不足时导致,一般不需要关注。日志中关键字是“Metadata GC Threshold”。
理解ZGC日志
一次完整的GC过程,需要注意的点已在图中标出。

注意:该日志过滤了进入安全点的信息。正常情况,在一次GC过程中还穿插着进入安全点的操作。
GC日志中每一行都注明了GC过程中的信息,关键信息如下:
- Start:开始GC,并标明的GC触发的原因。上图中触发原因是自适应算法。
- Phase-Pause Mark Start:初始标记,会STW。
- Phase-Pause Mark End:再次标记,会STW。
- Phase-Pause Relocate Start:初始转移,会STW。
- Heap信息:记录了GC过程中Mark、Relocate前后的堆大小变化状况。High和Low记录了其中的最大值和最小值,我们一般关注High中Used的值,如果达到100%,在GC过程中一定存在内存分配不足的情况,需要调整GC的触发时机,更早或者更快地进行GC。
- GC信息统计:可以定时的打印垃圾收集信息,观察10秒内、10分钟内、10个小时内,从启动到现在的所有统计信息。利用这些统计信息,可以排查定位一些异常点。
日志中内容较多,关键点已用红线标出,含义较好理解,更详细的解释大家可以自行在网上查阅资料。

理解ZGC停顿原因
我们在实战过程中共发现了6种使程序停顿的场景,分别如下:
- GC时,初始标记:日志中Pause Mark Start。
- GC时,再标记:日志中Pause Mark End。
- GC时,初始转移:日志中Pause Relocate Start。
- 内存分配阻塞:当内存不足时线程会阻塞等待GC完成,关键字是"Allocation Stall"。

- 安全点:所有线程进入到安全点后才能进行GC,ZGC定期进入安全点判断是否需要GC。先进入安全点的线程需要等待后进入安全点的线程直到所有线程挂起。
- dump线程、内存:比如jstack、jmap命令。


调优案例
我们维护的服务名叫Zeus,它是美团的规则平台,常用于风控场景中的规则管理。规则运行是基于开源的表达式执行引擎Aviator。Aviator内部将每一条表达式转化成Java的一个类,通过调用该类的接口实现表达式逻辑。
Zeus服务内的规则数量超过万条,且每台机器每天的请求量几百万。这些客观条件导致Aviator生成的类和方法会产生很多的ClassLoader和CodeCa che,这些在使用ZGC时都成为过GC的性能瓶颈。接下来介绍两类调优案例。
内存分配阻塞,系统停顿可达到秒级
案例一:秒杀活动中流量突增,出现性能毛刺
日志信息:对比出现性能毛刺时间点的GC日志和业务日志,发现JVM停顿了较长时间,且停顿时GC日志中有大量的“Allocation Stall”日志。
分析:这种案例多出现在“自适应算法”为主要GC触发机制的场景中。ZGC是一款并发的垃圾回收器,GC线程和应用线程同时活动,在GC过程中,还会产生新的对象。GC完成之前,新产生的对象将堆占满,那么应用线程可能因为申请内存失败而导致线程阻塞。当秒杀活动开始,大量请求打入系统,但自适应算法计算的GC触发间隔较长,导致GC触发不及时,引起了内存分配阻塞,导致停顿。
解决方法:
开启”基于固定时间间隔“的GC触发机制:-XX:ZCollectionInterval。比如调整为5秒,甚至更短。
增大修正系数-XX:ZAllocationSpikeTolerance,更早触发GC。ZGC采用正态分布模型预测内存分配速率,模型修正系数ZAllocationSpikeTolerance默认值为2,值越大,越早的触发GC,Zeus中所有集群设置的是5。
案例二:压测时,流量逐渐增大到一定程度后,出现性能毛刺
日志信息:平均1秒GC一次,两次GC之间几乎没有间隔。
分析:GC触发及时,但内存标记和回收速度过慢,引起内存分配阻塞,导致停顿。
解决方法:增大-XX:ConcGCThreads,加快并发标记和回收速度。ConcGCThreads默认值是核数的1/8,8核机器,默认值是1。该参数影响系统吞吐,如果GC间隔时间大于GC周期,不建议调整该参数。
GC Roots 数量大,单次GC停顿时间长
案例三:单次GC停顿时间30ms,与预期停顿10ms左右有较大差距
日志信息:观察ZGC日志信息统计,“Pause Roots ClassLoaderDataGraph”一项耗时较长。
分析:dump内存文件,发现系统中有上万个ClassLoader实例。我们知道ClassLoader属于GC Roots一部分,且ZGC停顿时间与GC Roots成正比,GC Roots数量越大,停顿时间越久。再进一步分析,ClassLoader的类名表明,这些ClassLoader均由Aviator组件生成。分析Aviator源码,发现Aviator对每一个表达式新生成类时,会创建一个ClassLoader,这导致了ClassLoader数量巨大的问题。在更高Aviator版本中,该问题已经被修复,即仅创建一个ClassLoader为所有表达式生成类。
解决方法:升级Aviator组件版本,避免生成多余的ClassLoader。
案例四:服务启动后,运行时间越长,单次GC时间越长,重启后恢复
日志信息:观察ZGC日志信息统计,“Pause Roots CodeCache”的耗时会随着服务运行时间逐渐增长。
分析:CodeCache空间用于存放Java热点代码的JIT编译结果,而CodeCache也属于GC Roots一部分。通过添加-XX:+PrintCodeCacheOnCompilation参数,打印CodeCache中的被优化的方法,发现大量的Aviator表达式代码。定位到根本原因,每个表达式都是一个类中一个方法。随着运行时间越长,执行次数增加,这些方法会被JIT优化编译进入到Code Cache中,导致CodeCache越来越大。
解决方法:JIT有一些参数配置可以调整JIT编译的条件,但对于我们的问题都不太适用。我们最终通过业务优化解决,删除不需要执行的Aviator表达式,从而避免了大量Aviator方法进入CodeCache中。
值得一提的是,我们并不是在所有这些问题都解决后才全量部署所有集群。即使开始有各种各样的毛刺,但计算后发现,有各种问题的ZGC也比之前的CMS对服务可用性影响小。所以从开始准备使用ZGC到全量部署,大概用了2周的时间。在之后的3个月时间里,我们边做业务需求,边跟进这些问题,最终逐个解决了上述问题,从而使ZGC在各个集群上达到了一个更好表现。
升级ZGC效果
延迟降低
TP(Top Percentile)是一项衡量系统延迟的指标:TP999表示99.9%请求都能被响应的最小耗时;TP99表示99%请求都能被响应的最小耗时。
在Zeus服务不同集群中,ZGC在低延迟(TP999 < 200ms)场景中收益较大:
- TP999:下降12~142ms,下降幅度18%~74%。
- TP99:下降5~28ms,下降幅度10%~47%。
超低延迟(TP999 < 20ms)和高延迟(TP999 > 200ms)服务收益不大,原因是这些服务的响应时间瓶颈不是GC,而是外部依赖的性能。
吞吐下降
对吞吐量优先的场景,ZGC可能并不适合。例如,Zeus某离线集群原先使用CMS,升级ZGC后,系统吞吐量明显降低。究其原因有二:第一,ZGC是单代垃圾回收器,而CMS是分代垃圾回收器。单代垃圾回收器每次处理的对象更多,更耗费CPU资源;第二,ZGC使用读屏障,读屏障操作需耗费额外的计算资源。
总结
ZGC作为下一代垃圾回收器,性能非常优秀。ZGC垃圾回收过程几乎全部是并发,实际STW停顿时间极短,不到10ms。这得益于其采用的着色指针和读屏障技术。
Zeus在升级JDK 11+ZGC中,通过将风险和问题分类,然后各个击破,最终顺利实现了升级目标,GC停顿也几乎不再影响系统可用性。
最后推荐大家升级ZGC,Zeus系统因为业务特点,遇到了较多问题,而风控其他团队在升级时都非常顺利。
参考文献
- ZGC官网
- 彭成寒.《新一代垃圾回收器ZGC设计与实现》. 机械工业出版社, 2019.
- 从实际案例聊聊Java应用的GC优化
- Java Hotspot G1 GC的一些关键技术
附录
如何使用新技术
在生产环境升级JDK 11,使用ZGC,大家最关心的可能不是效果怎么样,而是这个新版本用的人少,网上实践也少,靠不靠谱,稳不稳定。其次是升级成本会不会很大,万一不成功岂不是白白浪费时间。所以,在使用新技术前,首先要做的是评估收益、成本和风险。
评估收益
对于JDK这种世界关注的程序,大版本升级所引入的新技术一般已经在理论上经过验证。我们要做的事情就是确定当前系统的瓶颈是否是新版本JDK可解决的问题,切忌问题未诊断清楚就采取措施。评估完收益之后再评估成本和风险,收益过大或者过小,其他两项影响权重就会小很多。
以本文开头提到的案例为例,假设GC次数不变(10次/分钟),且单次GC时间从40ms降低10ms。通过计算,一分钟内有100/60000 = 0.17%的时间在进行GC,且期间所有请求仅停顿10ms,GC期间影响的请求数和因GC增加的延迟都有所减少。
评估成本
这里主要指升级所需要的人力成本。此项相对比较成熟,根据新技术的使用手册判断改动点。跟做其他项目区别不大,不再具体细说。
在我们的实践中,两周时间完成线上部署,达到安全稳定运行的状态。后续持续迭代3个月,根据业务场景对ZGC进行了更契合的优化适配。
评估风险
升级JDK的风险可以分为三类:
- 兼容性风险:Java程序JAR包依赖很多,升级JDK版本后程序是否能运行起来。例如我们的服务是从JDK 7升级到JDK 11,需要解决较多JAR包不兼容的问题。
- 功能风险:运行起来后,是否会有一些组件逻辑变更,影响现有功能的逻辑。
- 性能风险:功能如果没有问题,性能是否稳定,能稳定的在线上运行。
经过分类后,每类风险的应对转化成了常见的测试问题,不再属于未知风险。风险是指不确定的事情,如果不确定的事情都能转化成可确定的事情,意味着风险已消除。
升级JDK 11
选择JDK 11,是因为在JDK 11中首次支持ZGC,而且JDK 11属于长期支持(Long Term Support,LTS)版本,至少会被维护三年,普通版本(如JDK 12、JDK 13和JDK 14)只有6个月的维护周期,不建议使用。
本地测试环境安装
从两个源OpenJDK和OracleJDK下载JDK 11,二个版本的JDK主要区别是长时期的免费和付费,短期内都免费。注意JDK 11版本中的ZGC不支持Mac OS系统,在Mac OS系统上使用JDK 11只能用其他垃圾回收器,如G1。
生产环境安装
升级JDK 11不仅仅是升级自己项目的JDK版本,还需要编译、发布部署、运行、监控、性能内存分析工具等项目支持。美团内部的实践:
编译打包:美团发布系统支持选择JDK 11进行编译打包。
线上运行 & 全量部署:要求线上机器已安装JDK 11,有3种方式:
- 新申请默认安装JDK 11的虚拟机:试用JDK 11时可用这种方式;全量部署时,如果新申请机器数量过多,可能没有足够机器资源。
- 通过手写脚本给存量虚拟机安装JDK 11:不推荐,业务同学过多参与到运维当中。
- 使用容器提供的镜像部署功能,在打包镜像时安装JDK 11:推荐方式,不需要新申请资源。
监控指标:主要是GC的时间和频率,我们通过美团的CAT监控系统支持ZGC数据的收集(CAT已开源)。
性能内存分析:线上遇到性能问题时,还需要借助Profiling工具,美团的性能诊断优化平台Scalpel已支持JDK 11的性能内存分析。如果你的公司没有相关工具,推荐使用JProfier。
解决组件兼容性
我们的项目包含二十多万行代码,需要从JDK 7升级到JDK 11,依赖组件众多。虽然看起来升级会比较复杂,但实际只花了两天时间即解决了兼容性问题。具体过程如下:
- 编译,需要修改pom文件中的build配置,根据报错作修改,主要有两类:
a. 一些类被删除:比如“sun.misc.BASE64Encoder”,找到替换类java.util.Base64即可。 b. 组件依赖版本不兼容JDK 11问题:找到对应依赖组件,搜索最新版本,一般都支持JDK 11。
- 编译成功后,启动运行,此时仍有可能组件依赖版本问题,按照编译时的方式处理即可。
升级所修改的依赖:
<dependency>
<groupId>javax.annotation</groupId>
<artifactId>javax.annotation-api</artifactId>
<version>1.3.2</version>
</dependency>
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.4</version>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator-parent</artifactId>
<version>6.0.16.Final</version>
</dependency>
<dependency>
<groupId>com.sankuai.inf</groupId>
<artifactId>patriot-sdk</artifactId>
<version>1.2.1</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.9</version>
</dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.6</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.39.Final</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
</dependency>
JDK 11已经出来两年,常见的依赖组件都有兼容性版本。但是,如果是公司内部提供的公司级组件,可能会不兼容JDK 11,需要推动相关组件进行升级。如果对方升级较为困难,可以考虑拆分功能,将依赖这些组件的功能单独部署,继续使用低版本JDK。随着JDK 11的卓越性能被大家悉知,相信会有更多团队会用JDK 11解决GC问题,使用者越多,各个组件升级的动力也会越大。
验证功能正确性
通过完备的单测、集成和回归测试,保证功能正确性。
本文的阅读有一定的门槛,请先了解 GC 的基本只知识。
现代垃圾收集器的演进大部分都是往减少停顿方向发展。
像 CMS 就是分离出一些阶段使得应用线程可以和垃圾回收线程并发,当然还有利用回收线程的并行来减少停顿的时间。
基本上 STW 阶段都是利用多线程并行来减少停顿时间,而并发阶段不会有太多的回收线程工作,这是为了不和应用线程争抢CPU,反正都并发了慢就慢点(不过还是得考虑内存分配速率)。
而 G1 可以认为是打开了另一个方向的大门:只回收部分垃圾来减少停顿时间。
不过为了达到只回收部分 reigon,每个 region 都需要 RememberSet 来记录各 region 之间的引用。这个内存的开销其实还是挺大的,可能会占据整堆的20%或以上。
并且 G1 还有写屏障的开销,虽说用了 logging wtire barrier,但也还是有开销的。
当然 CMS 也用了写屏障,不过逻辑比较简单,啥都没判断就单纯的记录。
其实 G1 相对于 CMS 只有在大堆的场景下才有优势,CMS 比较伤的是 remark 阶段,如果堆太大需要扫描的东西太多。
而 G1 在大堆的时候可以选择部分收集,所以停顿时间有优势。
今天的主角 ZGC 和 G1 一样是基于 reigon 的,几乎所有阶段都是并发的,整堆扫描,部分收集。
而且 ZGC 还不分代,就是没分新生代和老年代。
那它为啥比 G1 要牛皮?今天咱们就来盘一盘。
本文会先介绍 ZGC 的特性,或者说几个关键点,然后再简述下整体回收流程。
基本上看下来对 ZCG 心中就有数了,作为普通的 Javaer,了解到这个程度就差不多了。

好了,让我们进入今天的正题!
ZGC 的目标
垃圾收集器设计出来都有目标的,有些是为了更高的吞吐,有些是为了更低的延迟。
所以我们先看看 ZGC 的目标:

可以看到它的目标就是低延迟,保证最大停顿时间在几毫秒之内,不管你堆多大或者存活的对象有多少。
可以处理 8MB-16TB 的堆。
咱们就按 openjdk 的 wiki 来展开今天的内容。

关键字:并发、基于Region、整理内存、支持NUMA、用了染色指针、用了读屏障,对了 ZGC 用的是 STAB。
Concurrent
这个 Concurrent 的意思是和应用线程并发执行,ZGC 一共分了 10 个阶段,只有 3 个很短暂的阶段是 STW 的。

可以看到只有初始标记、再标记、初始转移阶段是 STW 的。
初始标记就扫描 GC Roots 直接可达的,耗时很短,重新标记一般而言也很短,如果超过 1ms 会再次进入并发标记阶段再来一遍,所以影响不大。
初始转移阶段也是扫描 GC Roots 也很短,所以可以认为 ZGC 几乎是并发的。
而且之所以说停顿时间不会随着堆的大小和存活对象的数量增加而增加,是因为 STW 几乎只和 GC Roots 集合大小有关,和堆大小没啥关系。
这其实就是 ZGC 超过 G1 很关键的一个地方, G1 的对象转移需要 STW 所以堆大需要转移对象多,停顿的时间就长了,而 ZGC 有并发转移。
不过并发回收有个情况就是回收的时候应用线程还是在产生新的对象,所以需要预留一些空间给并发时候生成的新对象。
如果对象分配过快导致内存不够,在 CMS 中是发生 Full gc,而 ZGC 则是阻塞应用线程。
所以要注意 ZGC 触发的时间。
ZGC 有自适应算法来触发也有固定时间触发,所以可以根据实际场景来修改 ZGC 触发时间,防止过晚触发而内存分配过快导致线程阻塞。
还有设置 ParallelGCThreads 和 ConcGCThreads,分别是 STW 并行时候的线程数和并发阶段的线程数来加快回收的速度。
不过 ConcGCThreads 数量需要注意,因为此阶段是和应用线程并发,如果线程数过多会影响应用线程。
其实 ZGC 的每个阶段都是串行的,所以理论上其实可以不需要分两类线程,那为什么分了这两类线程?
就是为了灵活设置。分成两类就可以通过配置来调优,达到性能最大值。
对了上面提到 ZGC 的 STW 和 GC Roots 集合大小有关系,所以如果在会生成很多线程、动态加载很多 ClassLoader 等情况下会增加 ZGC 的停顿时间。
这点需要注意。
Region-based
为了能更细粒度的控制内存的分配,和 G1 一样 ZGC 也将堆划分成很多分区。
分了三种:2MB、32MB 和 X*MB(受操作系统控制)。
下图为源码中的注释:

对于回收的策略是优先收集小区,中、大区尽量不回收。
Compacting
和 G1 一样都分区了所以肯定从整体来看像是标记-复制算法,所以也是会整理的。
因此 ZGC 也不会产生内存碎片。
具体的流程下文再做分析。
NUMA-aware
以前的 G1 是不支持的,不过在 JDK14 G1 也支持了。

可能有的同学对 NUMA 不太熟悉,没事我先来解释一波。
在早期处理器都是单核的,因为根据摩尔定律,处理器的性能每隔一段时间就可以成指数型增长。
而近年来这个增长的速度逐渐变缓,于是很多厂商就推出了双核多核的计算机。
早期 CPU 通过前端总线到北桥到内存总线然后才访问到内存。

这个架构被称为 SMP (Symmetric Multi-Processor),因为任一个 CPU 对内存的访问速度是一致的,不用考虑不同内存地址之间的差异,所以也称一致内存访问(Uniform Memory Access, UMA )。
这个核心越加越多,渐渐的总线和北桥就成为瓶颈,那不能够啊,于是就想了个办法。
把 CPU 和内存集成到一个单元上,这个就是非一致内存访问 (Non-Uniform Memory Access,NUMA)。

简单的说就是把内存分一分,每个 CPU 访问自己的本地的内存比较快,访问别人的远程内存就比较慢。
当然也可以多个 CPU 享受一块内存或者多块,如下图所示:

但是因为内存被切分为本地内存和远程内存,当某个模块比较“热”的时候,就可能产生本地内存爆满,而远程内存都很空闲的情况。
比如 64G 内存一分为二,模块一的内存用了31G,而另一个模块的内存用了5G,且模块一只能用本地内存,这就产生了内存不平衡问题。
如果有些策略规定不能访问远程内存的时候,就会出现明明还有很多内存却产生 SWAP(将部分内存置换到硬盘中) 的情况。
即使允许访问远程内存那也比本地内存访问速率相差较大,这是使用 NUMA 需要考虑的问题。
ZGC 对 NUMA 的支持是小分区分配时会优先从本地内存分配,如果本地内存不足则从远程内存分配。
对于中、大分区的话就交由操作系统决定。
上述做法的原因是生成的绝大部分都是小分区对象,因此优先本地分配速度较快,而且也不易造成内存不平衡的情况。
而中、大分区对象较大,如果都从本地分配则可能会导致内存不平衡的情况。
Using colored pointers
染色指针其实就是从 64 位的指针中,拿几位来标识对象此时的情况,分别表示 Marked0、Marked1、Remapped、Finalizable。

我们再来看下源码中的注释,非常的清晰直观:

0-41 这 42 位就是正常的地址,所以说 ZGC 最大支持 4TB (理论上可以16TB)的内存,因为就 42 位用来表示地址。
也因此 ZGC 不支持 32 位指针,也不支持指针压缩。
然后用 42-45 位来作为标志位,其实不管这个标志位是啥指向的都是同一个对象。
这是通过多重映射来做的,很简单就是多个虚拟地址指向同一个物理地址,不过对象地址是 0001… 还是0010…还是0100…对应的都是同一个物理地址即可。
具体这几个标记位怎么用的,待下文回收流程分析再解释。
不过这里先提个问题,为什么就支持 4TB,不是还有很多位没用吗?
首先 X86_64 的地址总线只有 48 条 ,所以最多其实只能用 48 位,指令集是 64 位没错,但是硬件层面就支持 48 位。
因为基本上没有多少系统支持这么大的内存,那支持 64 位就没必要了,所以就支持到 48 位。
那现在对象地址就用了 42 位,染色指针用了 4 位,不是还有 2 位可以用吗?
是的,理论上可以支持 16 TB,不过暂时认为 4TB 够了,所以暂做保留,仅此而已没啥特别的含义。
Using load barriers
在 CMS 和 G1 中都用到了写屏障,而 ZGC 用到了读屏障。
写屏障是在对象引用赋值时候的 AOP,而读屏障是在读取引用时的 AOP。
比如 Object a = obj.foo;,这个过程就会触发读屏障。
也正是用了读屏障,ZGC 可以并发转移对象,而 G1 用的是写屏障,所以转移对象时候只能 STW。
简单的说就是 GC 线程转移对象之后,应用线程读取对象时,可以利用读屏障通过指针上的标志来判断对象是否被转移。
如果是的话修正对象的引用,按照上面的例子,不仅 a 能得到最新的引用地址,obj.foo 也会被更新,这样下次访问的时候一切都是正常的,就没有消耗了。
下图展示了读屏障的效果,其实就是转移的时候找地方记一下即 forwardingTable,然后读的时候触发引用的修正。

这种也称之为“自愈”,不仅赋值的引用时最新的,自身引用也修正了。
染色指针和读屏障是 ZGC 能实现并发转移的关键所在。
ZGC 回收流程解析
ZGC 的步骤大致可分为三大阶段分别是标记、转移、重定位。
- 标记:从根开始标记所有存活对象
- 转移:选择部分活跃对象转移到新的内存空间上
- 重定位:因为对象地址变了,所以之前指向老对象的指针都要换到新对象地址上。
并且这三个阶段都是并发的。
这是意识上的阶段,具体的实现上重定位其实是糅合在标记阶段的。
在标记的时候如果发现引用的还是老的地址则会修正成新的地址,然后再进行标记。
简单的说就是从第一个 GC 开始经历了标记,然后转移了对象,这个时候不会重定位,只会记录对象都转移到哪里了。
在第二个 GC 开始标记的时候发现这个对象是被转移了,然后发现引用还是老的,则进行重定位,即修改成新的引用。
所以说重定位是糅合在下一步的标记阶段中。

我再简单说一下十个步骤。
不过步骤里有些不影响整体回收流程的,我就不多加分析了。
这篇文章的目的不是深入 ZGC 实现的细节,而是了解 ZGC 大致的突出点和简单流程即可。
因此想知道细节的自行查阅,或者可以看看我文末推荐的书籍。
初始标记
这个阶段其实大家应该很熟悉,CMS、G1 都有这个阶段,这个阶段是 STW 的,仅标记根直接可达的对象,压到标记栈中。
当然还有其他动作,比如重置 TLAB、判断是否要清除软引用等等,不做具体分析。
并发标记
就是根据初始标记的对象开始并发遍历对象图,还会统计每个 region 的存活对象的数量。
这个并发标记其实有个细节,标记栈其实只有一个,但是并发标记的线程有多个。
为了减少之间的竞争每个线程其实会分到不同的标记带来执行。
你就理解为标记栈被分割为好几块,每个线程负责其中的一块进行遍历标记对象,就和1.7 Hashmap 的segment 一样。
那肯定有的线程标记的快,有的标记的慢,那么先空闲下来的线程会去窃取别人的任务来执行,从而实现负载均衡。
看到这有没有想到啥?没错就是 ForkJoinPool 的工作窃取机制!
再标记阶段
这一阶段是 STW 的,因为并发阶段应用线程还是在运行的,所以会修改对象的引用导致漏标的情况。
因此需要个再标记阶段来标记漏标的那些对象。
如果这个阶段执行的时间过长,就会再次进入到并发标记阶段,因为 ZGC 的目标就是低延迟,所以一有高延迟的苗头就得扼制。
这个阶段还会做非强根并行标记,非强根指的是:系统字典、JVMTI、JFR、字符串表。
有些非强根可以并发,有些不行,具体不做分析。
非强引用并发标记和引用并发处理
就是上一步非强根的遍历,然后引用就软引用、弱引用、虚引用的一些处理。
这个阶段是并发的。
重置转移集
还记得标记时候的重定位么?在写读屏障时候提到的 forwardingTable 就是个映射集,你可以理解为 key 就是对象转移前的地址,value 是对象转移后的地址。
不过这个映射集在标记阶段已经用了,也就是说标记的时候已经重定位完了,所以现在没用了。
但新一轮的垃圾回收需要还是要用到这个映射集的。
因此在这个阶段对那些转移分区的地址映射集做一个复位的操作。
回收无效分区
回收那些物理内存已经被释放的无效的虚拟内存页面。
就是在内存紧张的时候会释放物理内存,如果同时释放虚拟空间的话也不能释放分区,因为分区需要在新一轮标记完成之后才能释放。
所以就会有无效的虚拟内存页面存在,在这个阶段回收。
选择待回收的分区
这和 G1 一样,因为会有很多可以回收的分区,会筛选垃圾较多的分区,来作为这次回收的分区集合。
初始化待转移集合的转移表
这一步就是初始化待回收的分区的 forwardingTable。
初始转移
这个阶段其实就是从根集合出发,如果对象在转移的分区集合中,则在新的分区分配对象空间。
如果不在转移分区集合中,则将对象标记为 Remapped。
注意这个阶段是 STW,只转移根直接可达的对象。
并发转移
这个阶段和并发标记阶段就很类似了,从上一步转移的对象开始遍历,做并发转移。
这一步很关键。
G1 的转移对象整体都需要 STW,而 ZGC 做到了并发转移,所以延迟会低很多。
至此十个步骤就完毕了,一次 GC 结束。
可以还能同学对染色指针的几个标记位有点懵,没事看了下文就懂了。
染色指针的标记位
来分析下几个标记位,M0、M1、Remapped。
先来介绍个名词,地址视图:指的就是此时地址指针的标记位。
比如标记位现在是 M0,那么此时的视图就是 M0 视图。
在垃圾回收开始前视图是 Remapped 。
在进入标记标记时。
标记线程访问发现对象地址视图是 Remapped 这时候将指针标记为 M0,即将地址视图置为 M0,表示活跃对象。
如果扫描到对象地址视图是 M0 则说明这个对象是标记开始之后新分配的或者已经标记过的对象,所以无需处理。
应用线程 如果创建新对象,则将其地址视图置为 M0,如果访问的对象地址视图是 Remapped 则将其置为 M0,并且递归标记其引用的对象。
如果访问到的是 M0 ,则无需操作。
标记阶段结束后,ZGC 会使用一个对象活跃表来存储这些对象地址,此时活跃的对象地址视图是 M0。
并发转移阶段,地址视图被置为 Remapped 。
也就是说 GC 线程如果访问到对象,此时对象地址视图是 M0,并且存在或活跃表中,则将其转移,并将地址视图置为 Remapped 。
如果在活跃表中,但是地址视图已经是 Remapped 说明已经被转移了,不做处理。
应用线程此时创建新对象,地址视图会设为 Remapped 。
此时访问对象如果对象在活跃表中,且地址视图为 Remapped 说明转移过了,不做处理。
如果地址视图为 M0,则说明还未转移,则需要转移,并将其地址视图置为 Remapped 。
如果访问到的对象不在活跃表中,则不做处理。
那 M1 什么用?
M1 是在下一次 GC 时候用的,下一次的 GC 就用 M1来标记,不用 M0。
再下一次再换过来。
简单的说就是 M1 标识本次垃圾回收中活跃的对象,而 M0 是上一次回收被标记的对象,但是没有被转移,在本次回收中也没有被标记活跃的对象。
其实从上面的分析以及得知,如果没有被转移就会停留在 M0 这个地址视图。
而下一次 GC 如果还是用 M0 来标识那混淆了这两种对象。
所以搞了个 M1。
至此染色指针这几个标志位应该就很清晰了,我在用图来示意一下。

不清晰的同学建议再多看几遍标记位的变更,不复杂的。
最后
简单的总结下,ZGC 就是通过多阶段的并发和几个短暂的 STW 阶段来达到低延迟的特性。
利用指针染色技术和读屏障实现并发转移对象,利用 STAB 保证并发阶段不会漏标对象。
这一波一下相信大家对于 ZGC 有了一定的了解。
我个人认为重点就掌握官网罗列的那几个要点就行,毕竟咱们也不是写 GC 的,作为了解即可。
到时候和学妹呀,或者在面试官前面呀都可以小吹一下。
如果想深入了解当然可以,可先看看《新一代垃圾回收器ZGC设计与实现》这本书,然后再源码走起。
ZGC 的不分代其实是它的缺点,因为分代比较难实现,不过以后应该会加上吧。
其实从现代垃圾收集器的演进可以看出就是往并发上面靠,目标就是减少停顿的时间。
不过并发需要注意内存分配的速率,因为并发导致一次垃圾回收总的时间变长了。
如果内存分配过快那就回收不过来了,因此都需要预留内存空间或者说要更大的内存空间来应对快速的分配速率。
可能大伙还惦记这标题吧?ZGC 的 Z 是什么意思?
其实没啥意思,就是个名字而已。
