Skip to content
Lystran Paper
返回

GC性能之王:ZGC(含分代ZGC解释)

阅读量
提出修改

目录

点击展开

概论

本文需要读者对Java GC有基本的认识,同时本文会多次与G1垃圾回收器进行对比,便于理解

在JDK21中推出了分代zgc,相对于不分代zgc,进一步提升了吞吐量,且cpu算力使用效率更高,本文先进行不分代zgc的解释,然后解释分代zgc(基本原理相同)。

特点

由上可知,ZGC适合 大内存低延迟 服务的内存管理和回收

主要解决的痛点

1. 停顿时间长

在ZGC之前,jdk默认使用G1回收器,G1回收器使用标记-复制算法,在回收阶段(复制阶段)会暂停所有的应用线程(STW),复制完存活对象之后再恢复线程(虽然一般g1会为了保证一次的STW时间比较小,会分多次STW来对对象进行复制,但是总的STW时间是差不多的),这在堆的大小非常大的情况下时耗时非常高,会有较长的停顿时间,此时用户的感觉就是系统死机了一小段时间。

复制算法中的转移阶段需要分配新内存和复制对象的成员变量

而且G1的再标记阶段(重新标记那些在并发标记阶段发生变化的对象)也是STW的,这个停顿时间也是和堆的大小有关系的,但是主要的停顿时间还是来源于回收阶段

ZGC 的解法并发转移

2. 不可预测性

G1 的“不可预测性”随着堆变大而恶化

G1 的目标是“可预测停顿”(比如设置 -XX:MaxGCPauseMillis=200)。
G1 会尽力去满足这个目标,它会计算:“为了不超过 200ms,我这次只能回收 5 个 Region。”

关键技术

ZGC通过着色指针读屏障技术,解决了转移过程中准确访问对象的问题,实现了并发转移

并发转移需要应对的问题:GC线程在转移对象的过程中,应用线程也在不停地访问对象,如果一个对象被转移了,但是对象地址还未来得及更新,如何保证应用线程能够准确访问到正确的对象地址而不是这个旧地址?

在ZGC中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,读屏障会把读出来的指针更新到对象的新地址上,这样访问到的就是正确的对象地址了。

但是JVM是如何判断对象被移动过呢?就是利用对象引用的地址,即着色指针

着色指针对应计算机科学中的 标签指针 Tagged Pointers 这个概念
这个技术的物理基础在于:现代 CPU 的 64 位地址空间并没有被完全利用。
64 位指针可以寻址 2642^{64} 个字节(16 EB),这大得惊人;通常只使用了低 48 位来作为实际的内存地址(支持 256TB 内存),高 16 位是闲置的
既然高 16 位空着也是空着,我能不能在里面存点私货? 这就是“染色指针/带标签指针”的由来——利用指针中未使用的位(Bits)来存储额外的元数据。

ZGC实际仅使用64位地址空间的第041位,而第4245位存储元数据,第47~63位固定为0。
image.png

着色指针

ZGC仅支持64位系统,它把64位虚拟地址空间划分为多个子空间,如下图所示:
image.png

其中,[0~4TB) 对应Java堆,[4TB ~ 8TB) 称为M0地址空间,[8TB ~ 12TB) 称为M1地址空间,[12TB ~ 16TB) 预留未使用,[16TB ~ 20TB) 称为Remapped空间。

当应用程序创建对象时,首先在堆空间申请一个虚拟地址,但该虚拟地址并不会映射到真正的物理地址。ZGC同时会为该对象在M0、M1和Remapped地址空间分别申请一个虚拟地址,且这三个虚拟地址对应同一个物理地址,但这三个空间在同一时间有且只有一个空间有效。这是ZGC中经典的以空间换时间的做法。

设置 M0、M1 这两个状态,是为了解决一个核心问题:如何在不暂停所有线程的情况下,区分“这一轮 GC 标记过的对象”和“上一轮 GC 留下的旧标记”?。ZGC会有一个全局变量存储当前GC轮次的有效标记(M0或M1),每轮标记阶段中这个变量都会在M0和M1之间切换,当在为M1的轮次中访问了M0的地址,就说明这是上一轮GC的旧标记,需要进行一系列更新操作(后文讲)

设置Remapped是为了表示“地址已更新(无需更新)”。将指针改为Remapped标记这个动作发生在并发转移阶段,线程每搬一个对象就会在搬完后将指针改为Remapped,下一次遇到就无须再搬了。

另外:当M0/M1阶段遇到Remapped指针,会进行更新,更新为M0/M1,表示这个对象是存活的,只要理解:“标记阶段的任务是标记对象存活,会将对象标记更新为当前的合法标记,同时进行更新任务”就行

注:此处描述的是 ZGC 经典的基于多重映射的实现,JDK 21 的分代 ZGC 对此进行了底层架构层面的重构,详见后文‘分代 ZGC’章节。

基本原理

ZGC对标记-复制算法进行了重大改进:在标记转移重定位阶段几乎都是并发的,这是ZGC实现停顿时间小于10ms目标的最关键原因。

ZGC垃圾回收周期如下图所示:
image.png

ZGC内存结构

对于G1固定大小的region,会有内存碎片的问题,而且对于大对象而言就是噩梦(需要多个region拼凑,巨形对象Humongous Region),甚至还需要通过 Full GC 来清理内存碎片(无法分配连续的Region给巨型对象使用)。

ZGC 抛弃了“固定大小”的执念。它引入了 Page(页面) 的概念(其实就是 Region 的变体),但是它支持三种尺寸,而且是动态创建的。

ZGC 的核心逻辑是:“对象多大,我就给你造多大的盒子,绝不强行拼凑。”

ZGC 的 Page 分为三类:

A. Small Page(小页面)

B. Medium Page(中页面)

C. Large Page(大页面)—— 应对巨型对象

ZGC过程(一个GC周期)

第一步:初始标记 (Pause Mark Start) —— 【STW】

第二步:并发标记 (Concurrent Mark) —— 【并发】

G1和ZGC的并发标记对比

G1的并发标记阶段是用于对老年代的回收,当达到了设置的 IHOP(老年代在堆的占比),比如45%,G1才开始触发并发标记阶段
缺点:这是一种“反应式”的逻辑。如果你的流量突然暴涨,内存瞬间填满,G1 可能来不及反应,直接导致 Full GC。

ZGC引入了“分配速率自适应

触发规则

当(剩余可用内存 / 分配速率) < (GC 耗时 + 安全缓冲时间) 时,立即触发 GC。

同时,ZGC还引入了 流量整形(Allocation Pacing)
内存分配速率超过了gc回收速率,ZGC 不会直接抛出 OOM 错误,也不会像传统 GC 那样直接进行长时间的 Full GC 停顿。ZGC 会在应用线程申请内存时引入微小的停顿,强制应用线程“慢下来”。降低内存分配速率,至少避免了Full GC甚至OOM

第三步:标记结束停顿 (Pause Mark End) —— 【STW】

再标记阶段主要的任务:
它主要处理那些“不能靠读屏障解决”的边缘情况:

  1. 线程局部标记栈 (Thread-Local Mark Stacks)
    • 并发标记期间,每个线程都有自己的小本本,记了一些还没来得及提交到全局的标记信息。STW 时候要汇总一下。
  2. 弱引用/软引用/虚引用 (Weak/Soft/Phantom References)
    • 这些引用的处理逻辑比较特殊,通常需要在 STW 下做最终判定(虽然 ZGC 做了很多并发处理,但最终清理还是需要一点点 STW 时间)。
  3. 字符串去重/符号表清理
    • 一些 JVM 内部的杂活。
与G1再标记(Remark)区分

G1需要一个STW时间,去处理剩下的SATB日志缓冲区和所有更新,找出所有未被访问的存活对象,这些都是G1并发标记中用户线程操作对象导致的,是实打实的需要重新更新的对象

第四步:并发准备 (Concurrent Prepare for Relocate) —— 【并发】

干什么
1. 选出重分配集(Relocation Set):挑出那些垃圾最多的 Page,准备回收它们。
2. 创建转发表(Forwarding Table):为这些 Page 初始化一张表,用来记录“旧地址 -> 新地址”的映射。

第五步:初始转移 (Pause Relocate Start) —— 【STW】

第六步:并发转移 (Concurrent Relocation) —— 【并发】

第七步:并发重映射 (Concurrent Remap) —— 【并发】(通常合并到下一次 GC 的标记阶段)


ZGC只有三个STW阶段:初始标记再标记初始转移。其中,初始标记和初始转移分别都只需要扫描所有GC Roots,其处理时间和GC Roots的数量成正比,一般情况耗时非常短;再标记阶段STW时间很短,一般最多1ms,超过1ms则再次进入并发标记阶段。

分代ZGC (Gen ZGC)

分代zgc于jdk21被正式引入,分代zgc的出现,本质上是为了解决 “弱分代假说(Weak Generational Hypothesis)” 在不分代 ZGC中无法被利用而导致的效率瓶颈。(人话:重点扫年轻代,偶尔一起扫老年代)

分代zgc对于不分代zgc:吞吐量提升4倍、内存需求降低30%,Allocation Stall降低85%。

解决不分代ZGC的痛点

  1. 分配速率上限(Allocation Stall)”问题
    • 非分代 ZGC 的问题
      • 因为不分代,ZGC 每次回收都必须扫描全堆
      • 如果你的应用狂创建对象(高分配速率),而 ZGC 扫描全堆的速度赶不上gc的速度,内存就会被填满。
      • 后果:触发 Allocation Stall,强制暂停应用线程,直到腾出空间。这会导致原本承诺的 <1ms 停顿变成几百毫秒甚至更久。
    • 分代 ZGC 的解决
      • 引入年轻代。绝大多数对象是“朝生夕死”的。
      • ZGC 只需要高频、快速地扫描这一小块区域,就能回收掉 90% 以上的垃圾。
      • 效果:回收速度大幅提升,能扛住极高的对象分配速率,不再容易“卡壳”。
  2. CPU 资源利用率低
    • 非分代 ZGC 的问题
      • 陪跑现象:老年代对象通常长期存活。在非分代 ZGC 中,每次 GC 都要去扫描、标记、重定位这些根本没变的老对象
      • 这浪费了大量的 CPU 资源
    • 分代 ZGC 的解决
      • 忽略老年代:在 Minor GC 期间,完全不看 Old Gen(除非有跨代引用),CPU 资源全聚焦在“垃圾最多”的 Young Gen。
      • 结果:腾出了更多 CPU 给业务线程,吞吐量提升显著(通常提升 10%~20% 以上)。
  3. 内存开销过大
    • 非分代 ZGC 的问题
      • 为了维持低延迟,非分代 ZGC 需要预留较大的堆空间缓冲,否则容易来不及回收(Allocation Stall问题)。通常建议堆大小要比实际使用量大 2-3 倍。
      • 底层使用多重映射(M0 M1 Remapped),虽然物理内存不浪费,但虚拟内存地址占用极大。
    • 分代 ZGC 的解决
      • 因为回收效率高,不需要预留那么大的 堆空间缓冲 也能跑得稳。
      • 相同堆大小下,分代 ZGC 能承载更多的业务负载;或者说,同样的业务负载,分代 ZGC 可以用更小的堆跑起来。
  4. 单次 GC 周期过长
    • 非分代 ZGC 的问题
      • GC 周期耗时堆中存活对象的总数量成正比。如果存活对象多(比如几十 GB 的缓存),一轮 GC 可能要跑好几秒甚至更久。
      • 虽然停顿(STW)很短,但 GC 线程长时间占用 CPU,会持续干扰业务。
    • 分代 ZGC 的解决
      • Minor GC 的耗时只与Young Gen 里的存活对象有关,通常极短。

      • GC 变成了“短平快”的节奏。

分代模型

在非分代 ZGC 中,Page 只有大小之分(Small/Medium/Large Page)。 在分代 ZGC 中,Page 除了大小,还多了代(Generation) 的属性。

RSet(Remembered Set)

引入 RSet(Remembered Set) 来解决跨代引用,RSet是分代gc的通用做法,目的是为了在进行 Minor GC 时,不用扫描整个老年代。

对比G1的RSet:哈希表 + 卡表,每个 Region 都有一个独立的 RSet(哈希表),需要记录“谁引用了我”,例如,Region B 的 RSet 会记录:“Region A 的第 5 张卡片引用了我”,这样做内存占用高,且维护更加昂贵(需要一个优化线程不断处理写屏障产生的脏卡片)

分代ZGC 的 RSet:双重缓冲位图(Double-Buffered Bitmaps),精度是精确到具体的对象字段地址

处理跨代

由上一节可知:分代ZGC通过RSet来解决跨代引用问题,分代ZGC对于不分代ZGC,还新增了读屏障(Store Barrier),当一个老年代对象新增了对年轻代对象的引用指针时(oldObj.var = youngObj),写屏障会介入,它会先检查当前对象(oldObj)是否在老年代,如果是老年代对象,直接在位图上把这个老年代对象的这个引用字段对应的标记为1,开销很小。

进行Minor GC时,GC线程直接扫描RemSet Bitmap(记忆集位图) 中设置为1的位,直接根据位图定位到内存地址,读取指针,将其指向的年轻代对象标记为存活

且 ZGC 倾向于更快地让对象晋升,被老年代引用的这个年轻代对象只会经过较少次数的标记就能得到晋升,从而消除跨代引用,减少 RSet 的负担。

GC触发时机

在分代 ZGC 中,Major GC 和 Minor GC 是完全独立的,且也都是可以与应用线程并发执行的,甚至Major GC与Minor GC之间也可以并发执行

Major GC通过预测对象晋升速率来决定触发:

  1. 晋升速率 (Promotion Rate)
    • Minor GC 结束后,有多少对象存活并被移动到了老年代。
    • ZGC 会计算这个速率的平均值和峰值。
  2. 老年代回收耗时 (Old GC Duration)
    • 基于历史数据,预测完成一轮老年代并发标记和并发转移需要多少时间

触发公式逻辑
T老年代堆满的时间<T老年代回收耗时+安全缓冲时间T_{\text{老年代堆满的时间}} < T_{\text{老年代回收耗时}} + \text{安全缓冲时间}

当预测到“老年代剩余空间被填满的时间”快要小于“老年代回收所需的时间”时,ZGC 就会立即触发 Major GC


此外ZGC还引入了两个机制:

  1. 兜底触发规则:High Usage Rule (高占用阈值)

如果预测模型失效(例如流量极其突发,或者历史数据不足),ZGC 还有一个保底机制。

  1. 主动触发规则:Proactive Rule (主动回收)

如果系统当前比较空闲(晋升速率很低),老年代可能很久都不会满。为了避免垃圾长期堆积导致内存浪费,ZGC 会在系统负载较低时“主动”发起一次 Major GC。

染色指针优化

不分代ZGC使用多重映射(Multi-Mapping),即将多段虚拟内存映射到同一段物理内存(3个识视图M0、M1、Remapped,这3种是不同的堆内存指针),这种技巧非常消耗文件描述符和内存映射表,且不仅限制了堆大小(最大 16TB),还让元数据管理很麻烦。

而且操作系统可能还会错误地通报内存使用量,显示的内存占用是实际的3倍,而且因为虚拟地址的不同,对于Linux系统而言,还会占用更多的页表缓存 TLB(Translation Lookaside Buffer) ,而且每次都要进行切换

分代ZGC抛弃了多重映射,使用新的染色指针数据结构,它直接在 64 位指针的低位(不再是高位)存储元数据(颜色位),且优化了屏障代码,在读写屏障中直接处理这些颜色位,而不再依赖操作系统层面的虚拟地址别名,指令数更少,屏障指令更快。

显式的元数据

不同于不分代ZGC多重映射(通过操作系统虚拟地址别名实现的),操作系统“隐式”地解决了地址转换问题,只需要访问对应的虚拟地址就行。

分代ZGC中,依然利用指针存储颜色信息,但不再依赖操作系统映射,ZGC 的 JIT 编译器会在每次访问内存前,插入一条位运算指令消除颜色位,还原成纯净的内存地址,然后再交给 CPU 去访问,需要对元数据进行“显式”地读取和处理,这样就没有多重映射那些问题了。

这个消除颜色位的指令是一个位运算,CPU运行位运算指令的速度极快,基本都可以忽略

对于写屏障,处理的逻辑和不分代ZGC相同,就是查看指针的颜色是否为当前内存识图的颜色,如果是就直接放行,如果不是就进行一系列处理

内存屏障优化

引入写屏障

分代ZGC还引入了写屏障(Store Barrier),上文已经描述,用于解决跨代引用的相关问题。

同时,写屏障还有以下的功能:

自愈:当创建了一个引用时,写屏障介入,写屏障会帮忙将该对象标记为存活,将当前指针修改为当前视图的正确颜色。
注意:比如执行obj.field = var时,变量val可能来自寄存器或栈,它的指针颜色可能是“坏”的(例如指向旧地址,或者颜色位是上一轮 GC 的),此时写指针还有“修复指针颜色”,或者“查找转发表设置为正确的地址”的功能

SATB 标记屏障:这也是写屏障内部的一段逻辑代码,类似于G1的SATB,GC 流程开始时,逻辑上认为堆是一个静态快照。当执行obj.field = var时,zgc会将obj.field引用的原来的对象放入 SATB 缓冲区,GC线程会扫描这个缓冲区,把旧的那个对象标记为存活;下一小节会详细描述下。

轻量读屏障

众所周知,一个系统中读操作一般远大于写操作,不分代zgc引入了读屏障,导致进行读操作时还需要执行一段读屏障代码,所以一般情况下不分代ZGC的吞吐量会小于G1

一方面原因是不分代zgc的读屏障职责过重,不仅要进行重定位的操作(查询转发表修改为正确的地址),还要进行标记的工作(并发标记阶段读屏障会帮助gc线程进行标记任务)

分代ZGC进行了一些优化,让读屏障主要就进行重定位分代状态检查的工作,然后通过写屏障SATB(Snapshot-At-The-Beginning) 算法,将标记任务彻底转移给了写屏障和后台的并发标记线程

工作方式和G1的相似,即将断开的引用对象放入SATB 缓冲区,GC线程会扫描这个缓冲区,把旧的那个对象标记为存活,然后下一轮GC再进行处理 ,会产生浮动垃圾,但至少不会误清理存活的对象

这样做,有效地提高了系统的吞吐量

性能优化

快慢路径

分代 ZGC 中,因为要维护 RSet,应用线程每次修改老年代对象的引用时,都必须触发写屏障。如果每次写操作都直接调用 C++ 代码去更新位图,性能会拉胯。

所以,ZGC 设计了 快路径(Fast Path)慢路径(Slow Path) 的层级结构。

快路径检测是否需要额外的 GC 工作,当需要时,会跳转进入慢路径,开始相关工作。快路径由 JIT 实现,会在编译时直接将GC代码插入到屏障的位置,而无须进行函数调用。而慢路径不经常调用,所以使用 C++ 实现。

写屏障缓冲这(中间路径)

通过引入快路径,并且结合染色指针技术,可以有效减少对 C++ 慢路径函数的调用次数。除了快路径慢路径,分代 ZGC 还进一步对写屏障加入 JIT 编译的中间路径

如果快路径检查通过,说明需要记录 RSet。但如果立刻去更新那个复杂的“双重位图”,或者调用 C++ 函数,开销太大(需要寄存器保存、栈切换等操作)。

中间路径将待覆盖的值对象字段的地址存储在写屏障缓冲区中,随后直接返回至已编译的应用程序代码,从而避开了昂贵的慢路径。只有当写屏障缓冲区填满时,才会触发慢路径。这种机制分摊了从已编译应用代码切换到 C++ 慢路径代码所产生的性能损耗。

参考文献


提出修改
分享该文章到:

下一篇文章
理解Serverless:实现astro静态博客的阅读量统计