背景
在JDK 11中作为实验性功能推出的ZGC(JEP 333: ZGC: A Scalable Low-Latency Garbage Collector ),经过10个版本的迭代,终于在24年9月GA的JDK 23中将分代模式调整为默认模式(JEP 474: ZGC: Generational Mode by Default),版本趋于稳定并能够适配绝大多数实际生产场景。
本文将介绍ZGC分代回收机制和原理。
动机
在JAVA 30年的发展历程中,JVM提供了多种多样的垃圾回收器:
- 串行垃圾回收器(Serial Collector):一种简单的垃圾回收器,它会暂停所有应用程序线程,适用于客户端类型的机器,但不适用于多线程服务器环境。
- 并行垃圾回收器(Parallel Garbage Collector):在JDK5到JDK8中被使用,它是多线程环境下的一个不错选择。它使用多个线程来管理堆空间,但在执行垃圾回收时也会冻结其他应用程序。
- CMS(Concurrent Mark Sweep):核心设计上较之前垃圾回收器更为复杂。它更倾向于较短的垃圾回收暂停时间,并且在应用程序运行时能够与垃圾回收器共享处理器资源。CMS的平均响应速度较慢,但不会暂停应用程序线程来执行垃圾回收操作。
- G1(Garbage First Collector):CMS的替代产品,为拥有大内存空间的多处理器机器设计的,它将堆划分为多个Region,解决了CMS内存碎片化和回收暂停时间无法预先配置的问题。
- ZGC(Z Garbage Collector):ZGC是一种并发的、分页的、支持NUMA的垃圾回收器,它使用coloured指针、load barriers和store barriers。其中coloured指针是 ZGC 的核心概念,ZGC使用指针中的某些高位来标记对象所处的GC阶段。ZGC能处理大小从 8MB 到 16TB 的堆内存范围。
JDK 21之前的ZGC是不支持分代的,直到JDK 21引入了JEP 439: Generational ZGC。不分代时,ZGC将所有对象存储在一起,无论年龄大小,ZGC必须在每次运行时扫描所有对象。
当服务器压力较大时,这极有可能导致内存回收速率跟不上应用申请内存速率,进而触发Allocation Stall,即线程粒度的分配暂停,应用线程直到可以重新申请新内存方可继续执行,极大的影响服务可用性。
而分代回收主要是基于两个假说:
- 弱分代假说(Weak Generational Hypothesis):绝大多数对象是朝生暮死,在年轻时死亡。
- 强分代假说(Strong Generational Hypothesis):熬过多次垃圾回收的老年对象往往难以死亡。
因此,收集年轻对象消耗的资源较少,回收的内存较多;而收集老年对象消耗的资源较多,回收的内存较少。
所以,我们可以通过更频繁地收集新生代对象来提高使用ZGC的资源利用和性能。
分代ZGC的设计
内存模型
分代ZGC将堆内存划分为两个逻辑区域:新生代和老年代。
新创建的对象通常分配在新生代,而如果对象能活过几次GC周期,则将被晋升到老年代。在分代ZGC中,新生代和老年代仅为逻辑划分,它们在虚拟内存空间中并不需要连续。
MinorGC和MajorGC
分代ZGC中,新生代、老年代和应用线程的执行是完全并发的。
分代ZGC中,分两类GC
- Minor GC:仅回收新生代。
- 初始标记阶段的根包含GC roots中指向新生代的引用和老年代的remembered sets。
- Major GC:回收全堆,包括老年代和新生代。
- 从GC roots扫描全堆。
GC阶段
分代ZGC的GC阶段与ZGC的GC阶段类似,也分为3次STW和3次并发阶段。
- 第一次STW,初始标记。该阶段从GC roots和老年代remembered set出发找到根集合直接引用的活跃对象,并将其入栈。
- 第一个并发阶段,并发标记,将初始标记找到的对象作为根,深度遍历对象的成员变量进行标记。此阶段需要考虑引用关系变化导致的漏标记问题。
- 第二次STW,再标记和并行标记,在标记任务结束后尝试终止标记动作,由于GC线程和应用线程并发执行,有可能在GC工作线程结束标记后,应用线程又有了新的引用关系,因此需要STW判断是否真的结束了对象标记,如果没有结束,则需要并行标记。
- 第二次并发阶段,并发准备转移,并发选择待回收的页面,并发初始化待转移的页面,初始化Forwardding Table。
- 第三次STW,转移根对象引用的对象。
- 第三次并发阶段,并发转移,将对象移动到新页面。
核心机制
No multi-mapped memory(不再使用多重映射内存)
在不分代的ZGC中,通过指针中不同的标记位区分不同的虚拟空间,而这些不同标记位指向的不同的虚拟空间通过mmap映射到同一物理地址。coloured指针用于快速实现并发标记、转移和重定向。但是多重映射会导致在各种内存检测工具中,使用内存被计算为实际使用内存的3倍。
与不分代ZGC的4个颜色位相比,分代ZGC需要12个颜色位来标识不同的GC阶段,这显然不能用多重映射内存来实现了。
分代ZGC中使用了无色指针来解决这个问题:
- 在load屏障中擦除颜色位,得到一个无色的指针。
- 如上面两图所示,load屏障中相关实现相当简单,指针直接右移16位,即擦除了颜色。
/* 在x86_64架构下的示例 */
movq rax,0x10(rbx)
shrq rax,$address_shift
ja slow_path
- 在store屏障中还原颜色位
- 当往堆中存储一个对象引用时,检查此次存储是否是自上次垃圾回收阶段以来改字段的第一次存储,如是,则进入slow path以恢复颜色位。
testl Ox10(rbx), $store_bad_mask // 测试是否需要进入slow path
jnz slow_path
shlq rax, $address_shift // 左移
orq, rax, $colors // 修改颜色位
movq Ox10(rbx), rax
- 不使用多重映射内存
无色指针的设计有不少优点:
- 更大的可寻址的堆
- 更多的颜色位
- 不再有RSS、PSS的计算问题
- 在x86_64和AArch64架构下,只用到了两条指令
load屏障和store屏障
当JVM从堆加载引用时,load屏障将在fast path中判断指针是否需要修复指针;如果需要,则放入slow path中修复它。
通常fast path是直接插入到JIT即时编译的应用程序代码中,而slow path逻辑相对复杂,为了便于维护,则由c++实现。
store屏障的slow path主要工作如下:
- 并发的新生代SATB标记
- 并发的老年代SATB标记
- 并发维护remembered sets
SATB(Snapshot-at-beginning)
与非分代ZGC不同,分代ZGC采用了SATB机制,在标记开始阶段,GC对GC根进行快照,在标记结束时,确保标记了快照中所有可达对象。
因此,当对象引用关系中断时,内存屏障将要覆盖的引用值通知GC,然后GC将会标记引用的对象并标记从该对象字段上的引用。
记录集(remembered sets)
很多GC算法使用卡表来追踪从老年代到新生代的引用,通常卡表是一个大型byte数组,其中一位对应512字节的堆空间,如果老年代堆空间中的对象引用了新生代对象,则对应的卡表位设置为1。
G1则使用remembered set记录region之间的引用,每种不同的GC算法对于remembered set的具体实现均不同,分代ZGC使用位图精确对象位置。
另外分代ZGC有两个记录集,大约占用了3%的JVM内存消耗。
- current remembered set,应用线程负责写入。线程执行过程中,当有新增的从老年代指向新生代的引用,则应用线程将引用信息写入记录集。
- previous remembered set
- 由GC线程负责扫描和清理。新生代标记开始时,交换current remembered set和previous remembered set。
两个记录集的好处在于,不需要引入新的内存屏障和内存可行性机制,也避免了GC线程和应用线程的竞争。
Dense heap regions
GC在进行新生代对象转移时,不同page中的存活对象数量和其占用的内存量均不同。分代ZGC将分析新生代page的内存使用情况和预计回收情况,以确定哪些page值得转移、哪些page转移成本较高。某些page可能会由于转移成本过高,而原地晋升为老年代。
这种整个page晋升老年代的机制,将减少回收新生代的压力。
性能提升
分代ZGC通过频繁的回收新生代内存,更加有效的利用了CPU资源。
吞吐量:分代ZGC的吞吐量比JDK17中的ZGC提升了10%左右。
暂停时间:分代ZGC的平均暂停时间较JDK17中的ZGC略有提升,但暂停时间P99分位值获得了极大改善,降低了约20%。
ZGC的未来规划
Thread local GC
- 逃逸分析,识别出仅能被当前线程访问的对象。
- 针对不能逃逸的对象,专门进行局部的垃圾回收。
- 对CPU缓存机制更加友好。
总结
分代ZGC能够适用于绝大多数使用场景,
预计2025年3月发布的JDK 24中将移除ZGC的非分代模式(JEP 490: ZGC: Remove the Non-Generational Mode)。