概述
ZGC通过着色指针(Colored Pointers) 与 读屏障(Load Barrier) 的革命性设计,打破了传统GC"停顿时间随堆内存增长而变长"的魔咒,将最大停顿时间稳定在亚毫秒级,为Java在高实时性场景下的应用提供了有力支持。
🕰️ ZGC 的发展史
ZGC的演进之路,是从Oracle内部孵化、到社区开源、功能稳定,再到成熟生产就绪的历程。其关键里程碑如下:
| 版本/时间 | 关键事件与状态 | 主要特性/亮点 |
|---|---|---|
| 2015-2017 | 孕育与开源 Oracle内部孵化,目标是低延迟、大堆内存。2017年底正式开源。 | 确定了着色指针和读屏障的核心技术方向。 |
| JDK 11 (2018) | 实验性引入 作为实验功能通过JEP 333首次发布。 |
需解锁实验性参数(-XX:+UnlockExperimentalVMOptions)使用;早期仅支持Linux/x64平台。 |
| JDK 15 (2020) | 正式生产可用 JEP 377将其升级为生产特性,标志着其稳定性与成熟度。 |
核心优化:修复多项早期问题,优化根节点扫描算法,减少初始标记的STW时间。 |
| JDK 16 (2021) | 性能飞跃 停留时间优化至1ms内,实现亚毫秒级停顿,并引入JNI弱引用支持和NUMA感知改进。 | 停顿时间突破:GC停顿时间进入亚毫秒级。 |
| JDK 17 (2021) | 持续稳定 作为LTS版本,ZGC进一步优化,引入可扩展的线程栈处理,提升了整体稳定性。 | 稳定性增强:针对生产环境进行了大量稳定性与兼容性改进。 |
| JDK 21 (2023) | 架构革新 分代式ZGC (JEP 439) 正式发布。早期单代ZGC继续保留但标为"遗留"(Non-generational)模式。 |
分代回收:通过将堆划分为年轻代和老年代,提升了内存利用率和吞吐量。 |
| 未来规划 | 持续演进 | 分代模式将成为未来默认选项,并持续向更高吞吐量、更低延迟和更优内存效率演进。 |
⚙️ ZGC 的工作原理
ZGC通过两项核心技术创新,颠覆了传统的GC设计。
核心技术:着色指针与读屏障
-
着色指针 :传统GC将对象标记信息存在对象的对象头里,而ZGC则将这些信息编码在64位对象指针本身的高位 中,比如
Marked0、Marked1、Remapped等。这让GC在操作时无需访问对象头,提升了并发性能。 -
读屏障 :它是在应用程序从堆中读取对象引用时自动插入的一段轻量级代码。每当应用线程加载一个引用,读屏障会检查指针的颜色:
- 快速路径:如果指针颜色表示一切正常,屏障几乎零开销地直接返回引用。
- 慢速路径 :如果指针指示对象已被标记或移动,屏障会介入完成标记或"自愈"(即自动将引用更新到新地址),确保应用线程总是能正确访问到对象。
GC 周期:高度并发的四阶段循环
ZGC的整个GC周期高度并发,仅包含三个极短的STW阶段,可概括为四个主要步骤:
-
1. 标记阶段 (Marking)
- 初始标记 (STW) :暂停应用线程,仅扫描并标记GC Roots直接引用的对象。由于GC Roots数量通常有限,此阶段耗时极短。
- 并发标记 (Concurrent) :应用线程恢复执行,GC线程开始从GC Roots遍历对象图,并发标记所有存活对象。应用线程在访问对象时,读屏障会即时标记新访问到的对象,确保标记不遗漏。
- 再标记 (STW):一个极短的暂停,用于处理并发标记阶段中少量因应用线程修改引用而未能标记的对象。
-
2. 转移阶段 (Relocation)
- 并发选择待转移页 (Concurrent):GC线程在后台分析哪些内存页包含垃圾最多,选择这些页作为"转移集"以备清理。
- 初始转移 (STW) :暂停应用线程,将GC Roots直接指向的、位于转移集内的对象转移至新地址。
- 并发转移 (Concurrent) :恢复应用线程,GC线程继续将转移集中剩余的存活对象复制到新页。应用线程在访问任何对象时,若发现对象地址已变,读屏障会通过"自愈"机制即时修正引用,正是这一机制,让对象移动能与应用线程并发执行。
-
3. 重映射阶段 (Remapping)
ZGC将重映射工作巧妙地合并到下一个GC周期的并发标记阶段中 。在新一轮GC的并发标记时,通过指针的
Remapped位来判断并修正所有指向旧地址的引用,消除了一个专门的扫描阶段,进一步降低了开销。 -
4. 内存释放与复用
当一个内存页中的所有存活对象都被转移后,该页即被完全释放,可直接用于后续的对象分配。ZGC还能将未使用的物理内存归还给操作系统,提高了资源利用率。
⚖️ 优缺点与适用场景
ZGC以其极致的低延迟为核心优势,但也因此带来了相应的代价。
| 特点 | 优点 | 缺点 / 权衡 |
|---|---|---|
| 核心指标 | 极低延迟 :最大停顿时间亚毫秒级,且为O(1)复杂度,与堆大小无关。 可扩展性强 :可支持 16TB 的超大堆内存。 | 吞吐量略低:为实现并发,ZGC会消耗更多CPU资源,对应用吞吐量有约15%的影响。 |
| 资源使用 | 智能内存回收:可将未使用内存归还给操作系统,提升资源利用率。 | 内存占用较高:相比G1等其他回收器,其设计可能会导致更高的内存占用。 |
| 调优与兼容 | 调优简单:设计目标明确,生产环境通常无需复杂调优即可获得稳定性能。 | Java版本强依赖:需要较新版本的JDK(最好JDK 21及以上)才能获得完整的分代ZGC体验。 |
典型场景
- 微服务与实时系统:需要保证毫秒级甚至亚毫秒级的稳定响应时间。
- 大内存计算:如大型内存数据库、实时风控系统、推荐引擎等,堆内存动辄上百GB乃至TB级别。
- 终端用户交互应用:如交易系统、游戏服务器,需要消除GC停顿带来的界面卡顿。
不适用场景
- 吞吐量优先应用:对于批处理、科学计算等追求极致吞吐量而不关心响应延迟的场景,ZGC并非最优选。
- 小内存环境:在内存小于4GB的容器或嵌入式设备中,ZGC的优势不明显。
- 旧版JDK使用:在JDK 11或15这样只有老版本单代ZGC的JDK上,无法享受到JDK 21分代ZGC带来的性能飞跃。
着色指针与读屏障详细解释
1. 64位的对象引用是什么?多线程下一个对象有几个引用?为什么没有多线程竞争问题?
64位对象引用(指针)是什么?
在64位JVM中,每个对象在内存中都有一个地址。对象引用 本质上就是一个64位的整数 ,存储了这个对象的起始内存地址。例如,一个对象位于内存地址 0x7f3a2c001000,那么指向它的引用就是这个数值。
但是,ZGC并不把全部64位都用来表示地址。它会"借用"其中几个高位比特来存储颜色标记(比如3-4个比特)。所以,一个ZGC的指针结构大致如下:
高几位(颜色位) 中间位(地址位) 低位(可能未使用)
[MMM][AAAAAAAA...][0...]
- 颜色位 :存放
Marked0、Marked1、Remapped等状态。 - 地址位:剩余的比特位用来真正定位对象的内存位置(因此ZGC最大支持堆大小取决于地址位数,目前为16TB,即42位地址)。
- 指针的低位可能用作其他标志或对齐保留。
所以,ZGC的引用本身就是指针+状态的复合体,不需要额外去对象头里取信息。
多线程下一个对象有几个引用?
一个对象可以被任意多个线程同时引用。比如:
- 线程A的局部变量指向对象O。
- 线程B的局部变量也指向对象O。
- 全局静态变量也指向对象O。
- 其他对象的字段也指向对象O。
在内存中,对象O只有一个,但指向它的引用(指针)可以有很多份,分别存储在不同线程的栈帧、寄存器、堆字段等位置。
为什么ZGC的着色指针没有多线程竞争问题?
传统GC把标记信息存在对象头中。假设两个线程同时访问同一个对象,一个想修改对象头里的标记位,另一个想读取标记位,就必须加锁或使用原子操作,导致竞争。
ZGC的着色指针把标记信息存在引用本身里,而不是对象的公共头里。这就带来了一个关键改变:
不同的引用指向同一个对象时,它们各自携带不同的颜色信息是允许的。
举个例子:
- 对象O在物理内存中只有一个。
- 线程A持有的引用(指针)颜色可能是
Remapped(正常)。 - 线程B持有的引用颜色可能是
Marked0(表示该引用尚未参与本轮的标记)。 - 线程C持有的引用颜色可能是
Marked1(表示该引用已经参与了标记)。
这些不同的颜色同时存在,并不会产生矛盾 ,因为颜色只代表"这个引用视角下,对象处于什么GC状态"。当线程通过读屏障访问对象时,屏障会根据自己手里这个引用的颜色来决定是否需要修正或者标记,而不需要去修改对象头里的统一标志。
因此:
- 没有多个线程去争抢修改同一个对象头。
- 每个线程只读取和修改自己栈上或寄存器里的指针值(线程私有的),自然没有竞争。
比喻:对象O就像一栋楼。传统GC是在楼门口挂一块公告牌(对象头),谁路过都要去抢着改牌子,引发冲突。ZGC的着色指针是让每个人手里拿的门禁卡(引用)的颜色不一样,进门时保安(读屏障)只检查你手里的卡,你改自己的卡就行,不会影响别人手里的卡。
2. 颜色标记都有哪些,各自表示什么?
ZGC通常使用3种颜色(实际利用2个比特位,可以表示4种状态)。常见的有:
| 颜色名称 | 二进制标志 | 含义解释 |
|---|---|---|
| Remapped | 00 | 默认正常状态。表示该引用指向的对象地址是当前有效的、不需要任何GC操作。当GC完成一个周期后,所有引用最终都会变成Remapped。 |
| Marked0 | 01 | 第一轮标记颜色。表示该引用已经被本轮GC标记过(或者刚被标记)。通常用于区分不同GC周期的标记。 |
| Marked1 | 10 | 第二轮标记颜色。用于交替的GC周期。例如,第N次GC用Marked0作为标记位,第N+1次GC就用Marked1,避免混淆。 |
| (保留) | 11 | 可能未使用,或者用于特殊标志(如转发指针)。 |
具体含义与使用场景:
- Remapped:正常的引用,可以快速访问对象。
- Marked0 / Marked1 :在并发标记阶段 ,GC线程和应用线程通过读屏障,将可达对象的引用从
Remapped改成Marked0(或Marked1),表示"这个对象存活,且已被处理过"。标记结束后,ZGC就知道哪些对象没有被标记(即垃圾)。 - 在转移阶段 :当对象被移动到新地址后,旧指针的颜色会被改成一种特殊值(比如一个已移动标志,实际上可能复用Marked0/Marked1但配合转发表)。读屏障看到这个颜色,就会去查表找到新地址,并自愈为
Remapped颜色加新地址。
为什么要两个标记颜色(Marked0和Marked1)?
为了避免在一次完整GC周期结束前,新一轮GC又开始了。交替使用两个颜色,可以区分当前GC周期与上一周期的标记信息,而不需要清空所有指针的颜色(清空成本高)。
3. 如何表示"对象不用回收"?
GC判断对象是否存活(不用回收)的核心是:从GC Roots出发,所有可达的对象,在并发标记结束时,其指针颜色必须被设置为当前GC周期使用的标记颜色(Marked0或Marked1)。
详细流程(以某一轮GC周期为例,假设使用Marked0)
-
初始标记(STW) :
GC Roots直接引用的对象,其指针颜色被设置为
Marked0。这些对象显然存活。 -
并发标记 :
GC线程和应用线程(通过读屏障)一起遍历对象图。每当遇到一个指针颜色还不是
Marked0的对象引用,就将其改为Marked0,并将该对象压入标记栈以便继续遍历它的字段。最终,所有从GC Roots可达的对象,它们的指针颜色都会变成
Marked0。 -
标记结束时的判定:
- 指针颜色为
Marked0的对象 → 存活(不用回收) - 指针颜色仍然是
Remapped的对象 → 垃圾(可回收)
为什么?因为如果对象不可达,就不会有任何引用指向它(或者只有其他垃圾对象的引用),也就没有人会在并发标记期间将它的指针颜色改成
Marked0。 - 指针颜色为
-
回收阶段 :
ZGC会回收那些整个内存页中所有对象都是垃圾的页。对于混合页,它会把存活对象(颜色为
Marked0)转移到新页,然后释放旧页。
关键点澄清
-
颜色本身不是绝对标签 :同样的颜色
Marked0在这一轮表示存活,下一轮GC可能会使用Marked1作为存活标记,而Marked0反而变成"待处理"或"旧周期遗留"状态。 -
一个对象可能同时有多个引用指向它,但不同引用的颜色可以不同 :
例如,在并发标记过程中,对象O已被一个引用标记为
Marked0,但另一个线程持有的指向O的引用可能还没来得及更新,依然是Remapped。这不影响O的存活判定,因为只要至少有一个引用(而且是从GC Roots可达的路径)标记了O ,O就是存活的。最终在标记结束时,所有路径上的引用都会被修正为Marked0(或下一周期的标记色)。 -
读屏障的作用 :正是通过读屏障在应用线程访问引用时"顺便"将
Remapped改为Marked0,才使得所有可达对象都被染色,而不必暂停应用。
一句话总结
在ZGC的一轮GC周期中,对象存活(不用回收)的表示是:指向它的指针颜色被设置为当前周期使用的标记色(例如Marked0或Marked1)。凡是没有被染上该颜色的对象,就被视为垃圾,可以回收。
这也就是为什么ZGC的标记过程也常被称为"染色指针标记"。
4. 读屏障到底是什么?什么是移动对象?
读屏障的本质
读屏障不是一道物理屏障,而是一小段由JIT自动插入到应用程序中的机器码 。每次应用程序从堆中读取一个对象引用 (例如 Object o = obj.field;),JIT就会在读取指令后面加入这段屏障代码。
读屏障的逻辑用伪代码表示大致如下:
c
// 每次从堆中读取引用时,实际执行的是类似这样的函数
Object* load_barrier(Object* ptr) {
// 快速路径:如果ptr的颜色是Remapped,直接返回ptr
if (color_of(ptr) == REMAPPED) return ptr;
// 慢速路径:根据颜色处理
if (color_of(ptr) == MARKED0) {
// 帮助标记:标记这个对象(例如记录到标记队列)
mark_object(ptr);
// 将指针颜色改为Remapped(可选,也可以保持)
return set_color(ptr, REMAPPED);
}
if (color_of(ptr) == MARKED1) {
// 对象可能已被移动,需要转发
Object* new_ptr = forward_table_lookup(ptr);
if (new_ptr != NULL) {
// 自愈:将当前栈/寄存器里的指针更新为新地址+Remapped颜色
replace_pointer_in_register(ptr, new_ptr);
return new_ptr;
}
}
return ptr;
}
实际上ZGC的读屏障更精巧,但核心思想不变:根据指针颜色,决定是否要额外执行标记或地址修正。
什么是移动对象?
移动对象是指GC把存活对象从一个内存区域(旧页)复制到另一个内存区域(新页),然后回收旧页。这是为了内存压缩(消除碎片)和整理内存。
传统的GC在移动对象时,必须暂停所有应用线程,因为要扫描并更新所有指向旧地址的引用,否则应用拿着旧地址就会访问到错误的内存(可能已回收,或者数据被覆盖)。
ZGC如何做到并发移动对象(不暂停应用)?
- GC线程先把对象从旧页复制到新页,但不更新任何引用。
- 旧页上留下一个"转发指针"或维护一个转发表,记录旧地址 → 新地址的映射。
- 旧地址上的对象内容不再改变,但仍占着位置。
- 当应用线程通过旧引用访问该对象时,读屏障检查到指针颜色(表示"已移动"),自动查表找到新地址,然后把当前线程持有的旧指针更新为新地址(自愈)。
- 之后该线程再次访问同一个对象时,因为指针已自愈为Remapped+新地址,直接快速路径通过。
- 其他还没被访问到的旧引用,可以慢慢修正(甚至拖到下一轮GC)。
关键点 :移动对象的过程并不需要一次性修正所有引用,而是按需、延迟地修正(即仅当应用真正使用某个旧引用时才修正)。这样就把一个全堆扫描的大工作,分散到了应用运行的过程中,避免了STW。
总结:
- 着色指针:利用指针本身的高位存储GC状态,不同线程可以持有指向同一对象但颜色不同的引用,无竞争。
- 颜色标记:Remapped(正常)、Marked0/1(标记或已移动),用于表示引用与对象的关系。
- 读屏障:每次读引用时自动执行的轻量代码,根据颜色决定是否标记或自愈。
- 移动对象:复制对象到新地址,利用读屏障自愈机制,实现并发移动。
读屏障的必要性和工作原理
读屏障的必要性
在理解读屏障之前,先看ZGC的目标:并发完成几乎所有GC工作,包括对象移动,且停顿时间不超过1毫秒。这意味着:
- GC线程和应用线程同时运行。
- GC可以随时移动对象 ,而应用线程可能在任何时刻访问这些对象。
如果不用读屏障,当应用线程通过一个旧地址访问已经被移动的对象时,会发生什么?
- 直接访问旧地址:旧内存页可能已经被回收或覆盖,读取结果是未定义数据(很可能崩溃)。
- 使用虚拟内存保护(mprotect):GC移动对象前把旧页设为不可访问,应用线程访问时会触发SIGSEGV信号,信号处理程序再修正地址。这种方法虽然可行,但信号处理开销极大,而且在不同操作系统上表现不稳定,不适合高性能场景。
读屏障是更轻量、更可控的方案:它不需要操作系统信号机制,直接在JIT生成的代码中插入检查逻辑,开销远远低于信号处理,并且可以在任何平台上一致工作。
因此,读屏障是实现并发对象移动的必要技术。没有它,就无法在应用无感的情况下安全地搬移对象。
读屏障的工作原理(详细拆解)
读屏障本质是JIT在每次从堆中读取对象引用时自动插入的一段小代码 。它的完整工作流程分为快速路径 和慢速路径。
1. 快速路径(Fast Path)
触发条件 :读取到的指针颜色为 Remapped(正常状态)。
执行动作 :什么都不做,直接返回指针。
开销 :仅检查几个比特位,通常只需要1~2条CPU指令。
占比:绝大多数内存访问都落在快速路径(因为GC大部分时间不在移动对象或标记的激烈阶段)。
2. 慢速路径(Slow Path)
触发条件 :指针颜色不是 Remapped(可能是 Marked0、Marked1 或 Moving)。
执行动作 :根据具体颜色,执行额外操作,然后可能将指针"自愈"为 Remapped。
慢速路径又分为两种主要场景:
场景A:并发标记阶段的"染色"
-
当前颜色 :
Remapped→ 正常,不触发慢速路径。但如果指针是
Remapped但GC正在进行并发标记,且这个对象尚未被标记,读屏障也会主动将其改为Marked0(实际上标记阶段会特判:如果颜色是Remapped且当前标记周期活动,则进入慢速路径进行染色)。 -
执行动作:
- 将对象地址加入标记栈(GC内部数据结构)。
- 将该指针的颜色从
Remapped改为Marked0(或Marked1,取决于当前周期)。 - 返回原指针(但颜色已变)。
-
效果:应用线程"顺手"帮GC完成了标记,GC不需要暂停所有线程来扫描。
场景B:并发移动阶段的"自愈"
-
当前颜色 :表示"对象已被移动"(通常复用
Marked0或Marked1的某个值,或者一个专用比特位)。 -
执行动作:
- 通过当前指针(旧地址)查询转发表(Forwarding Table),找到对象的新地址。
- 如果找到了:
- 将当前线程持有的这个指针(位于栈或寄存器)替换为新地址,并设置颜色为
Remapped。 - 返回新地址。
- 将当前线程持有的这个指针(位于栈或寄存器)替换为新地址,并设置颜色为
- 如果没找到(理论上不应发生,但安全起见可能兜底)。
-
效果:应用线程第一次访问被移动的对象时,自动修正自己的指针;后续再访问直接走快速路径。其他线程如果还没访问,其旧指针依然保留旧地址,等待它们各自"自愈"。
3. 为什么"自愈"是突破点?
传统GC移动对象必须暂停所有线程,一次性修正所有指向旧地址的指针,代价高昂。
ZGC通过读屏障的"自愈"能力,把修正工作按需、延迟 地进行:只有那些真正被应用线程使用的旧指针才会被修正。那些永远不再被访问的旧指针(例如指向一个已被移动但不会再被读的对象)根本不需要修正,直接随线程消亡或被覆盖即可。这就消除了全局扫描的巨大开销。
读屏障的必要性总结
| 问题 | 不用读屏障的后果 | 用读屏障的解决方案 |
|---|---|---|
| 并发移动对象时,应用线程访问旧地址 | 程序崩溃或读到错误数据 | 读屏障截获访问,自愈为新地址 |
| 并发标记时,新分配或新引用的对象未及时标记 | 存活对象被误判为垃圾 | 读屏障在访问时染色,保证标记不漏 |
| GC如何知道哪些引用需要修正 | 必须暂停全部线程并扫描所有引用 | 读屏障按需、延迟修正,避免扫描 |
| 性能影响 | --- | 绝大多数访问走快速路径,开销极小 |
读屏障的代价与权衡
代价:
- 每次堆中读引用都会多执行几条指令(快速路径检查)。
- 慢速路径发生时会有额外开销(查表、加锁等)。
权衡结果:
- 现代CPU分支预测能力强,快速路径几乎零开销。
- 慢速路径的发生频率很低(GC标记和移动阶段占整个运行时间比例很小)。
- 换来的收益:亚毫秒级停顿,支持TB级堆内存。
ZGC的设计哲学就是:用少量、可控的运行时开销,换取极低的GC暂停时间。读屏障正是这一哲学的核心实现。