Skip to content
Charles Shao
Go back

JVM 深入(二):垃圾回收机制全解 —— 从可达性分析到 ZGC

–views

上一篇我们看着一份字节码在运行时的堆里安了家。但堆的内存是有限的,对象迟早要面临回收。写过 C++ 的同学都体验过手动管理内存的痛苦 —— 忘了释放就会内存泄漏,释放错了就会引发段错误。而在 Java 中,程序员几乎不用操心这些问题,因为 JVM 会自动帮你回收不可达的对象。这个机制就是垃圾回收(Garbage Collection,简称 GC)。

这一篇是整个理论三篇中最硬核的一篇,我们会一路从「怎么判断一个对象是垃圾」讲到「工业级回收器 G1 与 ZGC 凭什么又快又不卡顿」。

本文是 JVM 系列的第 2 篇(共 6 篇)。 全系列:

  1. 入门篇 · 内存架构与类加载
  2. 核心篇 · 垃圾回收全解(本篇)
  3. 进阶篇 · JIT、内存模型与调优
  4. JDK 版本差异精讲(8 → 25)
  5. Grafana JVM 监控分析
  6. Arthas 诊断

一句话定位:回收必须先拿到一致快照,后面所有工程都是在把这次停顿压短。

TL;DR

Table of contents

Open Table of contents

一、什么样的对象算「垃圾」

判断一个对象是否为垃圾,核心标准只有一条:没有任何引用指向它。但如何准确判断「没有引用」?业界通常有两种截然不同的思路。

思路一:引用计数法

其原理是在对象内部维护一个计数器:被引用一次就 +1,引用解除就 -1,归零即被回收。这种方案虽然简单直观,却存在一个致命缺陷 —— 无法处理循环引用。

A a = new A();
B b = new B();
a.ref = b;
b.ref = a;
a = null;
b = null;

在上述代码中,a 和 b 互相引用导致计数器均为 1,即便外部已经没有变量指向它们,它们也永远不会被回收。正因如此,HotSpot JVM 坚决没有采用这种方案。

思路二:可达性分析算法

为了彻底解决循环引用的难题,JVM 选择了更为严谨的可达性分析算法:首先,定义一组明确存活的根对象作为 GC Roots;随后,从这些根对象出发,沿着引用链向下搜索;最终,所有能够被搜索到的对象均被判定为存活,而游离于引用链之外的对象则被视为垃圾。

一个直观类比:沿引用链可达

我们可以将内存中的对象关系视作一张庞大的有向图,而 GC Roots 正是这张图的起始顶点。

  • 只要顺着起始顶点(GC Roots)的引用链能够到达某个对象,就说明该对象仍参与业务逻辑,即活的;
  • 反之,那些断开连线的孤岛对象,哪怕内部互相连接,但已脱离了主引用链,便会被判定为垃圾。

GC Roots 就是那些 明确还在被使用 的引用,所以从它出发能连到的对象都还有用。

那么,究竟哪些对象有资格作为 GC Roots 呢?在 Java 体系中,主要包括以下几类:

GC Roots 来源对应你代码里的什么
虚拟机栈里的引用正在执行的方法里的 局部变量
静态变量类里的 static 字段
常量引用常量池里引用的对象
本地方法栈引用native 方法用到的对象

回到刚才那段循环引用的代码,当执行完 a = null; b = null; 后,外部的引用链已经彻底断开:

循环引用被判为垃圾的示意:GC Roots(局部变量 a / b)在 a=null·b=null 后已断开,与对象 A 断开;对象 A 指向对象 B、对象 B 又指回对象 A,两者互相引用但都已脱离 GC Roots,被判定为垃圾 a、b 置 null 后沿引用链已不可达,A、B 内部循环引用仍不可达故可回收,双双判为垃圾。

对比前一种方案:在引用计数法下,A 与 B 互相指引导致计数器不为零,从而引发内存泄漏;但在可达性分析下,由于 a 和 b 已经置为 null,A 和 B 彻底脱离了 GC Roots 的引用链,哪怕内部再怎么互相引用,也同样会被双双判为垃圾。

为什么可达性分析更优

可达性分析的核心优势在于,它关注的并非「是否被引用」,而是「是否与根节点连通」。因此,孤岛对象即便内部关系再复杂,只要与 GC Roots 断开连接,依然会被精准回收,循环引用的难题由此被天然化解。

补充:引用的四种强度

需要强调的是,前面所说的「没有引用就是垃圾」是一个绝对判断。在实际工程中,Java 将引用划分为四种不同的强度,以便开发者更精细地控制对象的生命周期:

引用类型回收时机典型用途
强引用只要还可达就 永不回收(Object o = new Object())平时写的绝大多数引用
软引用 SoftReference内存不够 时才回收内存敏感的缓存(如图片缓存)
弱引用 WeakReference下次 GC 必回收,撑不过一轮ThreadLocal、WeakHashMap 的 key
虚引用 PhantomReference形同虚设,仅用于对象被回收时收到 通知管理堆外内存(如 DirectByteBuffer)

一句话记忆

强引用不回收,软引用内存不足时回收,弱引用下轮必清,虚引用仅用于回收通知。

二、GC 怎么「停下世界」:Safepoint 与 OopMap

上一节确立了「从 GC Roots 出发」的理论基础,但在工程落地时却面临一个绕不开的现实难题:线上的业务线程一直在高速运行,对象之间的引用关系每时每刻都在发生变化。在这样一张不断运动的对象图上,GC 根本无法精确枚举出所有的 GC Roots。

为了拿到一致的内存快照,GC 必须让所有业务线程停在一个能够安全检查引用关系的位置,这个位置便被称为安全点(Safepoint)。而所有线程在此挂起等待的过程,正是大名鼎鼎的 Stop-The-World(STW) 真正发生的地方。

Safepoint 停顿枚举 GC Roots 流程:GC 线程发起收集请求后,各线程插入的 Safepoint 轮询点生效,业务线程跑到轮询点就挂起、已阻塞的线程则处于安全区域;所有线程到齐后借助 OopMap 精确枚举 GC Roots,枚举完再唤醒所有业务线程 GC 先让所有线程在安全点集合,再借 OopMap 精确枚举 GC Roots,枚举完才放行。

具体到 JVM 的底层实现,这一过程包含几个关键机制:

一句话本质

STW 不是「GC 想卡顿」,而是「要在一张一致的快照上数清活对象,就必须先触发 STW」。后面所有并发回收器的努力,本质都是 把这次停顿压得尽可能短。

三、怎么把垃圾扔掉:三种基础算法

在通过 Safepoint 拿到一致快照并完成可达性分析后,下一步便是执行垃圾回收。业界经典的回收算法主要有三种:

算法思路优点缺点
标记-清除标记活对象 → 清除没标记的实现简单留下大量 内存碎片
标记-复制内存分两半,把活对象复制到另一半没有碎片永远浪费一半空间
标记-整理标记活对象 → 全挪到一端 → 清空边界外没碎片、不浪费移动对象成本高

为了更直观地理解,我们将同一块内存(■代表活对象,□代表垃圾,·代表空闲)分别交给这三种算法处理,其回收后的内存布局如下:

原始内存:   ■ □ ■ □ □ ■ □ ■        (4 个活对象,4 个垃圾)

① 标记-清除  只清垃圾,活对象原地不动
  回收后:   ■ · ■ · · ■ · ■        ← 空闲被切碎,留下内存碎片

② 标记-复制  内存分两半,活对象搬到另一半并紧凑排列
  回收前:   左半[ ■ □ ■ □ □ ■ □ ■ ]  右半[ · · · · · · · · ]
  回收后:   左半[ · · · · · · · · ]  右半[ ■ ■ ■ ■ · · · · ]   ← 整段连续,但永远空着一半

③ 标记-整理  活对象全部推到左端,边界右侧整片清空
  回收后:   ■ ■ ■ ■ ┊ · · · ·       ← 无碎片,也不浪费空间(┊ 是边界)

一句话区分三者

都要先「标记」活对象,区别只在第二步:清除=原地删垃圾(留碎片);复制=活对象搬家到空白区(费空间);整理=活对象就地挪到一端再清边界外(费搬运)。

标记-复制为什么不亏

听起来永远浪费一半空间极为奢侈,但基于一个普遍的工程观察,这种妥协变得极为划算 —— 绝大多数对象都是「朝生夕灭」的。既然存活下来的只是极少数,那么复制这些少数派的开销便微乎其微。

四、分代收集:把三种算法组合起来

正如上一节末尾所言,JVM 工程师们在大量线上服务中发现了一个普遍规律 —— 对象的生命周期呈现出高度的两极化:

既然对象的存活特征如此鲜明,如果用同一套算法去处理整个堆,显然是不明智的。由此便诞生了分代收集的架构思想。JVM 将堆内存划分为两大部分,分别施加最适合的回收策略:

区域特点采用算法
年轻代新对象诞生地,对象大多很快不可达标记-复制(损失空间小)
老年代存放长寿对象,死得少标记-整理(避免反复移动)

随着堆内存被划分为年轻代与老年代,垃圾回收的作用范围也随之明确。在深入机制之前,我们必须先厘清几个极易混淆的高频术语:

先厘清:Minor GC / Major GC / Full GC

  • Minor GC(Young GC):只清理 年轻代,发生最频繁、速度最快(下面要演示的就是它);
  • Major GC(Old GC):只清理 老年代(注意:这个词有歧义,有人也用它指 Full GC,看上下文);
  • Full GC:清理 整个堆 + 方法区,最慢、停顿最长。线上要尽量压低 Full GC 频率 —— 它往往是性能问题的元凶。

回到刚才那条 new 路径,年轻代内部被进一步细分为三块区域:Eden 空间与两个 Survivor 空间(S0、S1),默认比例为 8 : 1 : 1。新创建的对象绝大多数会在 Eden 区诞生;当 Minor GC 发生时,JVM 会将 Eden 和其中一个正在使用的 Survivor 区内的存活对象,统一复制到另一个空白的 Survivor 区中。如果一个对象熬过了足够多次的 Minor GC,它就会被**晋升(Promote)**到老年代。

分代收集流程:年轻代(采用标记-复制)里,新对象在 Eden 诞生,Minor GC 存活后复制到 Survivor 0,再来回复制到 Survivor 1;对象熬过约 15 次 GC 后晋升到老年代(采用标记-整理) 年轻代用标记-复制(Eden ↔ Survivor 来回搬运),熬过足够多次 GC 的对象晋升到用标记-整理的老年代。

围绕分代收集与年轻代的流转,线上排障时常会遇到几个核心疑问,这里一次性讲清:

① 8 : 1 : 1 的空间比例意味着什么? 年轻代被划分为 10 份,其中 Eden 占 8 份,两个 Survivor 各占 1 份。这里的关键在于,任何时刻总有一个 Survivor 是完全为空的,专门留作下一次 GC 的复制目的地。因此,年轻代实际可用的内存容量是 Eden 加上一个 Survivor,即总容量的 90%。

② 复制时 Survivor 放得下吗? 在绝大多数情况下是绰绰有余的,因为一次 Minor GC 后 Eden 区 90% 以上的对象都会被回收,存活的少数派完全可以塞进一个 Survivor 区。万一遇到突发的流量洪峰导致存活对象过多,JVM 提供了分配担保机制:Survivor 装不下的对象将直接晋升到老年代。此外,对于一出生就极大的对象(如超大数组),JVM 会让其跳过 Eden 区,直接在老年代分配。

③ 晋升的年龄阈值是如何计算的? 在上一篇提到的对象头中,专门保留了用于记录年龄的计数器位。对象每熬过一次 Minor GC 并被成功复制,其年龄便会 +1。当年龄达到阈值(默认 15,可通过 -XX:MaxTenuringThreshold 控制)时,对象便会晋升到老年代。

④ 为什么不到 15 岁也会发生过早晋升? 很多开发者误以为对象必须熬满 15 次才会晋升,实际上 JVM 还有一条更灵活的动态年龄判定规则:如果 Survivor 空间中某个年龄及以下的所有对象总大小,超过了 Survivor 空间的一半,那么年龄大于或等于该值的对象将直接晋升,无需等到 15 岁。这一机制有效防止了 Survivor 区被长期霸占。

为了彻底打通这一流转过程,我们用内存格子来推演一遍(·代表空闲,上标数字代表对象年龄):

① 新对象在 Eden 出生(S0、S1 都空)
  Eden:[ a b c d e f · · ]   S0:[ · ]   S1:[ · ]

② 第 1 次 Minor GC:只有 a、c 存活 → 复制到空的 S0,年龄+1,清空 Eden
  Eden:[ · · · · · · · · ]   S0:[ a¹ c¹ ]   S1:[ · ]   ← S1 仍空着备用

③ 程序继续跑,Eden 又生出新对象 g、h、i
  Eden:[ g h i · · · · · ]   S0:[ a¹ c¹ ]   S1:[ · ]

④ 第 2 次 Minor GC:扫 Eden + 在用的 S0,存活 a、c、g → 复制到空的 S1,年龄+1
  Eden:[ · · · · · · · · ]   S0:[ · ]   S1:[ a² c² g¹ ]   ← S0/S1 角色对调

⑤ 一直循环……a 每熬过一次 GC 年龄就 +1:a¹ → a² → … → a¹⁵

⑥ a 年龄达到阈值 → 判定为长寿对象,晋升老年代,不再来回搬
  Eden:[ … ]   S0:[ … ]   S1:[ … ]   老年代:[ a ]

眼见为实:如果我们在启动参数中加入 -Xlog:gc(JDK 9+ 的统一日志配置),一次真实的年轻代 GC 大致长这样:

[2.145s][info][gc] GC(12) Pause Young (Normal) (G1 Evacuation Pause) 512M->48M(1024M) 6.114ms

这段日志清晰地记录了:这是 JVM 启动后的第 12 次 GC,属于年轻代回收;堆内存使用量从 512M 锐减到 48M(总容量 1024M),整个过程仅停顿了 6.1ms。这印证了绝大多数对象当场死亡、极少数存活被复制或晋升的客观规律。(在第三篇 §6.4 中,我们会逐字段拆解更复杂的 GC 日志。)

为什么必须留一个空的 Survivor

标记-复制算法需要一块空白区域当「搬家目的地」。所以 S0、S1 的角色 每次 GC 都对调:谁空着,谁就是这次的复制目的地,复制完原来那块就清空 —— 两块 Survivor 永远「一个在用、一个待命」。

至此,分代收集的宏观架构已经清晰。但在工程实现中,围绕年轻代还有两个经常被追问的底层细节,我们需要一并补齐。

4.1 为什么在 Eden 分配对象这么快:TLAB

在线上高并发场景中,成百上千个线程同时执行 new 指令。如果它们都在 Eden 区发生分配竞争,势必需要加锁排队,导致性能急剧下降。为了解决这个痛点,JVM 引入了 TLAB(线程本地分配缓冲区) 机制。JVM 会在 Eden 区为每个线程预先分配一小块私有内存,线程在自己的 TLAB 内分配对象时完全互不打扰、无需加锁;只有当 TLAB 耗尽时,才需要同步申请新的缓冲区。这也解释了为什么上一篇中提到的「指针碰撞」分配能够做到近乎飞快。

4.2 Minor GC 凭什么不用扫整个老年代:记忆集 + 卡表

要理解这个机制,得先想明白痛点在哪。Minor GC 依赖「从 GC Roots 出发能否到达」来判断年轻代对象的死活,但这里存在一个极易漏判的盲区 —— 跨代引用,即一个老年代对象直接引用了一个年轻代对象:

class Cache {
    static Cache INSTANCE = new Cache(); // 长寿,在老年代
    Object data;
}
Cache.INSTANCE.data = new Object();      // 老年代的 INSTANCE 指向了刚 new 的年轻代对象

在上述代码中,新创建的 new Object() 仅仅被老年代的 INSTANCE 引用,而线程栈和静态变量都没有直接指向它。如果 Minor GC 仅仅扫描年轻代自身的 Roots,就会将其误判为垃圾并回收掉。为了防止这种误杀,「老年代里指向年轻代的引用」也必须被强制算作 GC Roots 的一部分。

那么问题接踵而至:如何在浩如烟海的老年代中快速找出这些跨代引用?

在 HotSpot 的具体工程实现中,记忆集通常以卡表(Card Table)的形式存在。JVM 将老年代划分为一块块固定大小的卡页(Card Page,通常为 512 字节),并维护一个字节数组,其中每一个字节严格对应一张卡页。只要某张卡页内发生了「老年代指向年轻代」的引用赋值,JVM 就会将卡表中的对应字节标记为脏(dirty)。

老年代被切成若干卡页,每页对应卡表里的一个字节:

老年代:  [卡页0][卡页1][卡页2][卡页3][卡页4][卡页5] ...
              │     │     │     │     │     │
卡表:    [  0 ][  1 ][  0 ][  0 ][  1 ][  0 ] ...   ← 1=脏,0=干净
              ↑                 ↑
        卡页1里有对象指向年轻代   卡页4里也有

Minor GC:只扫描【脏卡页 1、4】里的对象,其余干净卡页全部跳过

那么,卡表究竟由谁来负责标脏呢?这依靠的是一种名为**写屏障(Write Barrier)**的底层机制。每当业务线程执行引用赋值操作时,JVM 会在机器码层面顺手插入一段拦截代码,检查这次赋值是否构成了跨代引用;如果是,便立即将对应的卡页标脏。通过将记账的开销平摊到平时的每一次赋值中,Minor GC 时只需扫描那些脏卡页,从而实现了大幅提速。

一句话类比

Minor GC 要找跨代引用,笨办法是每次翻遍整个老年代;卡表则是利用写屏障在引用更新时记录跨代引用,即卡表标脏,下次 GC 只扫描脏卡页。用一点平时记账的成本,换取 GC 时的大幅提速。

五、并发 GC 的核心难题:三色标记与写屏障

前文提到的 Minor GC 采用的是「全程 STW 停下来扫」的策略。然而,随着老年代容量的不断膨胀,如果依然采用整代 STW 扫描,停顿时间将彻底失控。正因如此,JVM 引入了并发回收器,允许 GC 线程与业务线程同时运行。但这随之带来了一个极为棘手的工程难题:GC 线程一边在遍历标记,业务线程一边在修改引用,这会不会导致标记结果出错?

为了清晰地推演并发标记的过程,JVM 理论界引入了三色标记法:

整个并发标记过程从 GC Roots 出发,像波纹一样将对象逐步从白色推进到灰色,最终变为黑色。当扫描彻底结束后,仍然保持白色的对象,即被判定为不可达的垃圾。

三色标记法:从 GC Roots 出发,对象逐步从白色(尚未访问)推进到灰色(自己扫完但引用待扫)再到黑色(自己和引用都扫完,确认存活);标记结束后仍为白色的对象被判定为垃圾 三色标记:对象从白 → 灰 → 黑推进,标记结束仍为白色的即为垃圾。

5.1 漏标:并发标记会「误杀活对象」

在并发标记期间,最致命的事故莫过于漏标:一个本该继续存活的白色对象,被 GC 错误地判定为垃圾并回收掉,直接导致业务抛出空指针异常。理论研究表明,漏标当且仅当同时满足以下两个必要条件时才会发生:

  1. 业务线程插入了一条从黑色对象指向该白色对象的新引用;
  2. 业务线程删除了所有从灰色对象通往该白色对象的直接或间接引用。

从直觉上理解:黑色对象已经被 GC 认定为「全部扫描完毕,绝不回头重新看」,此时它突然抓住了某个白色对象;而与此同时,原本还能通过灰色对象顺藤摸瓜找到该白色对象的路径,又被业务线程无情切断。于是,这个白色对象就成了「被黑色对象偷偷保护着、但 GC 标记流程再也无法触达」的漏网之鱼,最终惨遭误杀。

既然漏标必须同时满足这两个条件,那么只要破坏其中任意一个条件,漏标的惨剧就不会发生。这正好对应了业界两种主流的并发 GC 解法:

解法破坏哪个条件做法谁在用
增量更新(Incremental Update)破坏条件 1记录「黑 → 白」的新增引用,标记快结束时把这些黑对象 变回灰色重新扫一遍CMS
原始快照(SATB, Snapshot-At-The-Beginning)破坏条件 2引用 被删除前 先把旧引用记下来,相当于「以标记开始那一刻的快照为准」,被删的也当作还活着G1

需要强调的是,实现上述「记录」动作的底层机制,依然是我们刚刚在 4.2 节见过的写屏障。它与卡表标脏使用的是同一套拦截基础设施,只是在这里换了一个用途:在业务线程「修改引用」的前后插入一段 GC 钩子代码,将受影响的对象精准补记下来。

一句话收束

卡表用写屏障解决「跨代引用」,并发标记用写屏障解决「漏标」。写屏障 = JVM 在你每次改引用时顺手记的一笔账,是几乎所有现代 GC 的隐形地基。SATB 会带来一点「浮动垃圾」(本该死的当轮没清),但换来了正确性与低停顿,非常划算。

六、垃圾回收器的演进

前文探讨的回收算法与三色标记皆为理论基础,而垃圾回收器才是真正的工程落地。纵观 JVM 历史上的回收器演进,其本质就是一部**「如何让 GC 尽可能不卡顿用户线程」**的奋斗史。

世代代表回收器关键特征
第一代:串行Serial / Serial Old单线程 GC,全程 Stop-The-World,停顿长
第二代:并行Parallel多线程一起 GC,缩短 STW,但仍需全员暂停业务(JDK 8 默认)
第三代:并发CMS、G1GC 线程与业务线程同时运行,把 STW 压到最低
第四代:低延迟ZGC、ShenandoahSTW 压到 10ms 以内,且几乎与堆大小无关

垃圾回收器演进:第一代串行(Serial,STW 长)→ 第二代并行(Parallel,多线程 GC)→ 第三代并发(CMS / G1,STW 最低)→ 第四代低延迟(ZGC / Shenandoah,STW 小于 10ms) 回收器演进是一部「如何让 GC 不卡顿用户线程」的奋斗史:从串行到并行、并发,再到低延迟。

在这条演进主线中,第三代并发回收器与第四代低延迟回收器最值得我们深入剖析:

接下来,我们将把当下应用最广的默认选择 G1,以及代表未来方向的低延迟标杆 ZGC,分别拆解讲透。

七、重点看看 G1(默认回收器)

既然 G1 已经成为 JDK 9 及以后版本的默认选择,我们必须对其内部机制了如指掌。要真正理解 G1,首先要看它究竟解决了前几代回收器的什么痛点:传统的分代 GC 将堆内存死板地划分为一整块连续的年轻代与一整块连续的老年代。当堆内存达到 16GB 甚至更大时,一旦触发老年代回收,就必须进行整代回收。这不仅导致停顿时间极长且完全不可控,开发者也根本无法向 JVM 下达「我最多只能接受 200ms 停顿」的指令。

7.1 两个核心创新

① 内存布局的革命:化整为零的 Region

G1 彻底抛弃了「一大块年轻代 / 一大块老年代」的物理隔离,转而将整个堆切分为上千个大小相等的 Region(通常为 1~32MB)。这一设计的精妙之处在于,每个 Region 的角色都是动态赋予的 —— 这一刻它可能是 Eden 区,但在被回收清空后,下一轮它完全可以被重新分配为 Old 区或 Survivor 区:

传统:[========年轻代========][============老年代============]   连续大块

G1: [E][O][E][S][O][O][E][H][O][E][S][O][E][O][O][H] ...   一堆小方块
      E=Eden  S=Survivor  O=Old  H=Humongous(大对象专用)

② 核心调度策略:Garbage-First(垃圾优先)

这正是「Garbage-First」命名的由来。G1 会在后台持续统计每个 Region 中的垃圾占比,在回收时优先挑选垃圾最多、回收性价比最高的那几块 Region 进行清理,而不是盲目地全堆扫描。通过这种「按 Region 筛选回收」的策略,当你设定了 -XX:MaxGCPauseMillis=200 的目标时,G1 会根据历史耗时动态计算出「这次清理多少块 Region 刚好不会超时」,从而真正做到了可预测停顿。

7.2 G1 的一轮回收长什么样

G1 对老年代的处理绝不是一锤子买卖的 STW,而是展开为一个完整的并发标记周期。该周期被严密地划分为四个相位(前文 5.1 节重点讲解的 SATB 机制,正是应用在此处):

G1 回收相位:初始标记(标 GC Roots 直达对象,短暂 STW,搭 Young GC 一起做)→ 并发标记(与业务线程并跑,遍历整个对象图)→ 重新标记(用 SATB 补扫漏标,短暂 STW)→ 筛选回收(按性价比挑 Region,复制存活对象,STW),然后进入下一轮 G1 并发标记周期:初始标记 → 并发标记 → 重新标记 → 筛选回收,只有首尾几步短暂 STW。

在深入排查 G1 性能问题时,还有两个高频细节必须掌握:

一句话记住 G1

把大堆切成上千个小方块,优先扫垃圾最多的那几块(Garbage-First),用「初始标记 → 并发标记 → 重新标记 → 筛选回收」四步、只在首尾短暂 STW,从而在几个 GB 的大堆上做到「你定停顿目标、它尽量满足」。

八、低延迟标杆 ZGC:染色指针 + 读屏障

尽管 G1 已经足够强大,但它的停顿时间依然会随着堆内需要复制的存活对象增多而线性拉长(因为复制存活对象必须 STW)。当业务场景需要几十甚至上百 GB 的超大堆,且停顿时间必须死死稳定在个位数毫秒时,就轮到第四代低延迟标杆 ZGC 登场了。ZGC 的设计目标极为激进:将停顿时间压制在 10ms 以内(最新版本实测常在亚毫秒级别),且停顿时间几乎与堆大小完全无关。

它究竟凭什么做到如此逆天的指标?前文提到,G1 停顿的大头在于「复制存活对象」这一步的 STW。而 ZGC 的杀手锏,正是将对象的复制(转移)过程也彻底并发化 —— 业务线程与 GC 线程可以一边转移对象一边继续运行。要支撑起如此硬核的并发转移,ZGC 祭出了两大底层黑科技:

① 染色指针(Colored Pointers)

在 64 位操作系统中,一个对象指针实际上用不满全部的 64 位。ZGC 巧妙地借用了指针中几个空闲的高位,用于直接记录「该对象的 GC 状态」—— 例如对象是否已被标记、是否已被转移(remapped)。这意味着,GC 的状态信息不再需要额外查表,而是直接「染」在了指针本身之上。只要读取指针,就能瞬间获悉对象当前处于 GC 的哪个阶段。

② 读屏障(Load Barrier)

既然 GC 状态已经染在了指针上,那么每当业务线程从堆内存中读出一个对象引用时,ZGC 就会在底层插入一小段读屏障代码,用于实时检查该指针的染色位:

ZGC 读屏障流程:业务线程读取一个对象引用时,读屏障检查指针的染色位是否最新 —— 干净则直接使用;带标记位则顺手完成标记再返回;发现对象已被移动则查转发表并 self-heal 把指针修正到新地址,再使用 ZGC 读屏障:每次读引用都顺手检查染色位,必要时就地完成标记或修正到对象的新地址(self-heal)。

正因为 ZGC 将最耗时的「对象转移」工作,巧妙地摊销到了业务线程的每一次读操作中并发完成,它才得以将必须 STW 的工作极致压缩到只剩「扫描 GC Roots」这一小步。这也正是 ZGC 的停顿时间能够做到几乎不随堆大小而变长的根本原因。

对照记忆

  • G1 靠 写屏障 维护 SATB 和跨代引用,停顿主要花在「复制存活对象」;
  • ZGC 靠 读屏障 + 染色指针 把复制也并发化,停顿几乎只剩「扫 Roots」。
  • Shenandoah 思路类似(用 Brooks 转发指针 + 读屏障),是另一条低延迟路线。

那什么时候该换 ZGC

G1 是「平衡型」默认选择,普通在线服务用它即可。只有当你要 极致低延迟(停顿 < 10ms,且堆开到几十 GB 停顿也不变长)时,才是 ZGC 的主场。代价是它吞吐通常略低于 G1、且读屏障有一定开销。

一句话记住 ZGC

把「对象搬到哪了」这条信息塞进 指针本身的几个空闲位(染色指针),每次读引用都过一道 读屏障 顺手把旧指针修正到新地址(自愈)—— 于是连「搬对象」都能和业务线程并发进行,STW 只剩扫 Roots,停顿几乎与堆多大无关。

九、怎么选 GC:取舍三角

在真实的线上环境中,GC 调优永远是在三个核心指标之间进行痛苦的取舍:

  1. 吞吐量:业务代码执行时间占系统总运行时间的比例;
  2. 延迟:单次 GC 触发 STW 停顿的绝对时长;
  3. 内存占用:GC 算法为了维持高效运转,自身需要额外消耗的内存开销。

理解三者的关系

我们可以将程序想象成一个持续运转的系统:业务线程负责处理请求,GC 负责垃圾回收。随着堆内存不断被消耗,系统必须时不时停下来触发 STW 进行清理。

  • 吞吐量 = 系统真正用来处理请求的时间占比。其公式为:业务时间 / (业务时间 + GC 时间)。假设一天 480 分钟里只花了 8 分钟进行回收,那么系统的吞吐量就高达 98.3%;
  • 延迟 = 每次触发回收时被迫停工的那一下卡顿(即 Stop-The-World,所有业务线程瞬间冻结)。这里强调的是单次停顿的绝对时长;
  • 内存占用 = 为了让 GC 扫得更高效而额外付出的空间代价。例如复制预留空间(标记-复制算法永远空着一半)、并发标记时必须留出空地暂放杂物(G1/ZGC 必须预留缓冲,否则并发期间产生的新垃圾将无处安放)、以及维护SATB 或卡表来记录跨代引用(记忆集本身也需要消耗可观的内存)。内存给得越大,垃圾堆积很久才满,GC 就能从容应对;内存给得越小,一会儿就塞满,必然导致频繁触发回收。

在评估这三个指标时,有一个极易踩坑的反直觉认知 —— 吞吐量高绝对不等于用户体验好:

方案总 GC 时间停顿方式吞吐量延迟体验
A8 分钟1 次停 8 分钟高极差(一次卡 8 分钟)
B10 分钟1200 次,每次 0.5 秒略低好(几乎无感)

试想,一个网页请求平时只需 10ms 即可返回,一旦不幸撞上方案 A 那种长达数秒的 STW,这次请求就会被死死卡住,用户会立刻感知到明显的顿挫与卡顿。这也解释了为什么在线服务、金融交易、多人游戏极度看重延迟,而离线批处理任务则更看重整体吞吐量。

三者不可兼得

  • 想要 低延迟 → 让 GC 与业务线程 并发 同时跑,但会抢走 CPU、还要加读/写屏障 → 吞吐量下降;
  • 想要 省内存 → 堆开小,垃圾很快堆满 → 只能 更频繁地 GC → 停顿更多、吞吐也受影响。

GC 调优铁三角:吞吐量(干活时间占比高)、延迟(单次停顿短)、内存占用(额外内存少)三者互相牵制 —— 低延迟要并发会抢 CPU 从而拉低吞吐,省内存要更频繁 GC 从而增加停顿 吞吐量、延迟、内存占用构成铁三角,三者互相牵制,无法同时最优。

正因为这三者存在着不可调和的物理矛盾,所以业界从来没有「最好的 GC」,只有「最适合当前业务场景的 GC」。在实际的架构选型中,我们可以将其简化为以下决策树:

场景推荐 GC
离线批处理、看重总吞吐Parallel
普通在线服务G1(默认)
极致低延迟(金融、游戏)ZGC

至此,对象在堆内存中怎么生、怎么死,经过理论两篇的拆解,我们已经彻底打通:从可达性分析精准找垃圾,到 Safepoint 配合 OopMap 停顿枚举 Roots;从分代收集搭配三种基础算法,到三色标记配合读写屏障死守并发正确性;直到 G1 与 ZGC 登场,将停顿时间死死压制在毫秒级。但回顾整个链路,还有一个灵魂拷问尚未解答:既然 Java 代码必须经过 JVM 解释执行,它到底慢不慢?为什么一线大厂敢用 Java 去扛动辄百万级的超高并发? 这正是下一篇 进阶篇 · JIT、内存模型与调优 的核心主题 —— 我们将深入即时编译(JIT)、Java 内存模型(JMM),并推演出一套从「玄学调参」走向「科学度量」的线上调优方法论。

延伸阅读


–views
Share this post on:

Previous Post
JVM 深入(三):JIT 编译与内存模型 —— 从底层原理到调优实战
Next Post
JVM 深入(一):内存架构与类加载 —— 从字节码到对象的一生