上一篇我们看着一个对象在堆里安了家。但堆是有限的,对象迟早要死。写过 C++ 的同学都体验过 malloc 和 free 的痛苦 —— 忘了 free 就内存泄漏,free 错了就段错误。Java 程序员几乎不用操心这些,因为 JVM 会自动帮你回收没用的对象。这个机制就是垃圾回收(Garbage Collection,简称 GC)。
这一篇是整个系列最硬核的一篇,我们会一路从「怎么判断一个对象是垃圾」讲到「工业级回收器 G1 / ZGC 凭什么又快又不卡顿」。
TL;DR
- 判断垃圾用可达性分析(从 GC Roots 出发能否走到),天然化解引用计数搞不定的循环引用;引用还分强 / 软 / 弱 / 虚四种强度。
- STW 的真身:GC 靠 Safepoint 让所有线程停在一致快照上,再借 JIT 生成的 OopMap「照表点名」精确枚举 GC Roots。
- 三种算法(清除 / 复制 / 整理)+ 分代收集 是经典组合:年轻代 Eden : S0 : S1 = 8 : 1 : 1,靠年龄计数 + 动态年龄判定晋升,TLAB 让分配飞快,卡表让 Minor GC 不必扫全老年代。
- 并发标记靠三色标记 + 写屏障防「漏标」;漏标有两个必要条件,增量更新(CMS)和 SATB(G1)各破坏其中一个。
- G1(Region + Garbage-First + 四相位 + Mixed GC)是当下默认;ZGC(染色指针 + 读屏障) 把停顿压到毫秒级且几乎与堆大小无关。
- 吞吐 / 延迟 / 内存是不可兼得的铁三角,没有最好的 GC,只有最适合场景的 GC。
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 来源 | 对应你代码里的什么 |
|---|---|
| 虚拟机栈里的引用 | 正在执行的方法里的 局部变量 |
| 静态变量 | 类里的 static 字段 |
| 常量引用 | 常量池里引用的对象 |
| 本地方法栈引用 | native 方法用到的对象 |
回到上面那段循环引用的代码,执行完 a = null; b = null; 后是这样的画面 —— 那只「手」已经松开:
a、b 置 null 后整串葡萄从手上脱落,A、B 内部再互相引用也无济于事,双双判为垃圾。
- 引用计数法:A 被 B 指着(计数 = 1)、B 被 A 指着(计数 = 1),都不为 0 → 永不回收 → 内存泄漏;
- 可达性分析:
a、b已置 null,整串葡萄从手上掉了下来,A、B 内部再怎么互相抓着对方也没用 → 双双判为垃圾。
为什么可达性分析更优
它看的不是「有没有人指着我」,而是「能不能从根一路连到我」。所以孤岛对象就算内部互相引用,但跟 GC Roots 断了线,照样会被判死刑 —— 循环引用的难题就这么被天然化解了。
补充:引用其实分四种强度
前面说「没有引用就是垃圾」,其实「引用」本身还分强弱,这决定了对象在什么时机被回收:
| 引用类型 | 回收时机 | 典型用途 |
|---|---|---|
| 强引用 | 只要还可达就 永不回收(Object o = new Object()) | 平时写的绝大多数引用 |
软引用 SoftReference | 内存不够 时才回收 | 内存敏感的缓存(如图片缓存) |
弱引用 WeakReference | 下次 GC 必回收,撑不过一轮 | ThreadLocal、WeakHashMap 的 key |
虚引用 PhantomReference | 形同虚设,仅用于对象被回收时收到 通知 | 管理堆外内存(如 DirectByteBuffer) |
一句话记忆
强引用是「亲儿子」(绝不抛弃)、软引用是「内存紧张才忍痛割爱」、弱引用是「下轮必清」、虚引用是「只为收尸通知」。
二、GC 怎么「停下世界」:Safepoint 与 OopMap
上面说「从 GC Roots 出发」,但有个绕不开的现实问题:你的业务线程一直在跑,引用关系时刻在变。GC 怎么可能在一个奔跑的靶子上精确数清有哪些 Roots?
答案是:GC 需要先让所有业务线程 停在一个「可以安全被检查」的位置,这个位置叫 安全点(Safepoint)。这就是大名鼎鼎的 Stop-The-World(STW) 真正发生的地方。
GC 先让所有线程在安全点集合,再借 OopMap 精确枚举 GC Roots,枚举完才放行。
拆开看:
- 为什么不能边跑边数? 因为线程正在算一半,寄存器、栈帧里的引用处于「中间态」,这一刻数出来的 Roots 可能是错的。
- 安全点是什么? JVM 在编译代码里预先埋了一些「轮询点」(方法返回前、循环回跳处等)。线程执行到轮询点会检查一个标志位:GC 说要停,它就在这里乖乖挂起。
- 正在阻塞 / sleep 的线程怎么办? 它们进不了轮询点,于是有 安全区域(Safe Region) 的概念 —— 线程进入一段「引用关系不会变」的代码时先声明「我安全了」,GC 可以直接把它算进去,不用等它醒。
- OopMap 是什么? 如果每次都去猜「栈上哪些位置是引用、哪些是普通 int」,那太慢了。JIT 在编译时就顺手生成了一张 OopMap,精确记录「在这个位置,栈和寄存器的哪些槽是对象引用」。GC 停下后直接查表,枚举 Roots 从「全栈瞎猜」变成「照表点名」,这就是「准确式 GC」的基础。
一句话本质
STW 不是「GC 想卡你」,而是「要在一张一致的快照上数清活对象,就必须先按下暂停键」。后面所有并发回收器的努力,本质都是 把这个暂停键按得尽可能短。
三、怎么把垃圾扔掉:三种基础算法
判断完垃圾,下一步是回收。回收算法有三种:
| 算法 | 思路 | 优点 | 缺点 |
|---|---|---|---|
| 标记-清除 | 标记活对象 → 清除没标记的 | 实现简单 | 留下大量 内存碎片 |
| 标记-复制 | 内存分两半,把活对象复制到另一半 | 没有碎片 | 永远浪费一半空间 |
| 标记-整理 | 标记活对象 → 全挪到一端 → 清空边界外 | 没碎片、不浪费 | 移动对象成本高 |
把同一块内存(■=活对象,□=垃圾,·=空闲)丢给三种算法,回收后的布局一目了然:
原始内存: ■ □ ■ □ □ ■ □ ■ (4 个活对象,4 个垃圾)
① 标记-清除 只清垃圾,活对象原地不动
回收后: ■ · ■ · · ■ · ■ ← 空闲被切碎,留下内存碎片
② 标记-复制 内存分两半,活对象搬到另一半并紧凑排列
回收前: 左半[ ■ □ ■ □ □ ■ □ ■ ] 右半[ · · · · · · · · ]
回收后: 左半[ · · · · · · · · ] 右半[ ■ ■ ■ ■ · · · · ] ← 整段连续,但永远空着一半
③ 标记-整理 活对象全部推到左端,边界右侧整片清空
回收后: ■ ■ ■ ■ ┊ · · · · ← 无碎片,也不浪费空间(┊ 是边界)
一句话区分三者
都要先「标记」活对象,区别只在第二步:清除=原地删垃圾(留碎片);复制=活对象搬家到空白区(费空间);整理=活对象就地挪到一端再清边界外(费搬运)。
标记-复制为什么不亏
听起来浪费一半空间很奢侈,但有一个观察让它变得划算 —— 绝大多数对象都是「朝生夕灭」的(用完就扔),活下来的少数派复制起来开销很小。
四、分代收集:把三种算法组合起来
JVM 工程师们发现一个规律 —— 对象的生命周期高度两极化:
- 90% 以上的对象在创建后很快就死了(比如方法里的临时变量);
- 剩下的少数对象会活很久(比如缓存、单例)。
既然如此,为什么不针对这两类对象用不同的策略呢?这就是 分代收集 的思想。JVM 把堆分成两部分:
| 区域 | 特点 | 采用算法 |
|---|---|---|
| 年轻代 | 新对象诞生地,死得多活得少 | 标记-复制(损失空间小) |
| 老年代 | 长寿对象养老院,死得少 | 标记-整理(避免反复移动) |
分成两代之后,「GC」也就随之分出了不同的范围,这几个高频术语先一次性厘清:
先厘清:Minor GC / Major GC / Full GC
- Minor GC(Young GC):只清理 年轻代,发生最频繁、速度最快(下面要演示的就是它);
- Major GC(Old GC):只清理 老年代(注意:这个词有歧义,有人也用它指 Full GC,看上下文);
- Full GC:清理 整个堆 + 方法区,最慢、停顿最长。线上要尽量压低 Full GC 频率 —— 它往往是性能问题的元凶。
年轻代内部又细分成三块:Eden + 两个 Survivor(S0、S1),默认比例 8 : 1 : 1。新对象在 Eden 出生,GC 时把 Eden 和其中一个 Survivor 里的活对象复制到另一个 Survivor。一个对象熬过足够多次 GC 后,就被「晋升」到老年代。
年轻代用标记-复制(Eden ↔ Survivor 来回搬运),熬过足够多次 GC 的对象晋升到用标记-整理的老年代。
这里有几个常见疑问,一次性讲清。
① 8 : 1 : 1 是空间大小比例。 年轻代被切成 10 份:Eden 占 8 份,两个 Survivor 各占 1 份。关键在于 任何时刻总有一个 Survivor 是空的,专门留作「复制的目的地」,所以实际能装对象的是 Eden + 一个 Survivor(9 份)。
② 复制放得下吗? 绝大多数情况放得下 —— 一次 Minor GC 后 Eden 里 90%+ 的对象都死了,活下来的少数派一个 Survivor 通常够装。万一这次存活特别多装不下,JVM 有 分配担保:超出部分 直接晋升老年代。此外,一出生就很大的对象(如大数组)会跳过 Eden 直接进老年代。
③「熬过多次 GC」是次数概念。 每个对象的对象头里有个 年龄计数器,每被复制一次(熬过一次 Minor GC)就 +1,达到阈值(默认 15,由 -XX:MaxTenuringThreshold 控制)就晋升。
④ 不到 15 也可能提前晋升:动态年龄判定。 很多人误以为「非得熬满 15 次」,其实还有一条更实际的规则:如果 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
读法:第 12 次 GC、年轻代回收,堆从 512M 降到 48M(总 1024M),停顿 6.1ms —— 绝大多数对象当场死掉、少数存活被复制或晋升,正是上面格子演示的真实写照。(第三篇 §6.4 会逐字段拆解 GC 日志。)
为什么必须留一个空的 Survivor
标记-复制算法需要一块空白区域当「搬家目的地」。所以 S0、S1 的角色 每次 GC 都对调:谁空着,谁就是这次的复制目的地,复制完原来那块就清空 —— 两块 Survivor 永远「一个在用、一个待命」。
形象类比
这套机制就像公司里的员工流动 —— 新员工先放试用期(Eden),表现好的转正去 Survivor,几年后核心骨干(老员工)就进了高级管理层(老年代)。
围绕年轻代,还有两个常被追问的细节,一并补齐。
4.1 为什么在 Eden 分配对象这么快:TLAB
多个线程同时 new 对象,如果都往同一个 Eden 抢地盘,就得加锁排队,很慢。JVM 的办法是给 每个线程在 Eden 里预先圈一小块私有地,叫 TLAB(Thread Local Allocation Buffer)。线程在自己的一亩三分地里分配,互不打扰、无需加锁,只有 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。
问题来了:怎么找出这些引用?
- 笨办法:每次 Minor GC 把整个老年代(动辄几个 G)从头扫一遍 —— Minor GC 又频繁,这样就慢到没法用了;
- 聪明办法:与其 GC 时现场全扫,不如 平时就提前记账,记下「老年代哪些地方有指向年轻代的引用」。这本账就是 记忆集(Remembered Set)。
记忆集的具体实现叫 卡表(Card Table):把老年代切成一块块固定大小的 卡页(通常 512 字节),用一个字节数组,一个字节对应一张卡页;某张卡页里只要出现「指向年轻代」的引用,就把对应字节标记为 脏(dirty)。
老年代被切成若干卡页,每页对应卡表里的一个字节:
老年代: [卡页0][卡页1][卡页2][卡页3][卡页4][卡页5] ...
│ │ │ │ │ │
卡表: [ 0 ][ 1 ][ 0 ][ 0 ][ 1 ][ 0 ] ... ← 1=脏,0=干净
↑ ↑
卡页1里有对象指向年轻代 卡页4里也有
Minor GC:只扫描【脏卡页 1、4】里的对象,其余干净卡页全部跳过
那卡表由谁来标脏?靠的是一种叫 写屏障(Write Barrier) 的机制(下一章还会再用它解决并发标记问题):每当执行 老对象.字段 = 某对象 这类引用赋值时,JVM 顺手插一段代码检查「这次是不是让老年代指向了年轻代」,是就把对应卡页标脏。记账在赋值当下顺手完成,GC 时直接查现成的卡表即可。
一句话类比
老年代是有几百个书架的大图书馆,Minor GC 要找「馆里有没有书写着『参见年轻代某本书』」。笨办法是每次翻遍所有书架;卡表办法是门口挂块白板(卡表),谁往书里写了「参见年轻代」的便签(写屏障)就在对应格子打红叉(标脏),下次只翻打红叉的那几个书架。用一点平时记账的成本,换 GC 时的大幅提速。
五、并发 GC 的核心难题:三色标记与写屏障
前面的 Minor GC 是「停下来扫」。但老年代一大,停顿就受不了,于是有了 并发回收器(GC 线程和业务线程同时跑)。这带来一个棘手问题:我一边标记,你一边改引用,会不会标错?
GC 用 三色标记法 描述标记过程:
- 白色:还没被访问到的对象(初始全白);
- 灰色:自己被访问到了,但它引用的对象还没扫完;
- 黑色:自己和它引用的对象都扫完了,确认存活。
标记从 GC Roots 出发,把对象逐步从白 → 灰 → 黑推进。扫描结束后,仍是白色的就是垃圾。
三色标记:对象从白 → 灰 → 黑推进,标记结束仍为白色的即为垃圾。
5.1 漏标:并发标记会「误杀活对象」
并发标记最怕的事故是 漏标:一个本该存活的白对象,被错判成垃圾回收掉。研究表明,漏标 当且仅当同时满足两个条件:
- 业务线程 插入 了一条「黑对象 → 白对象」的新引用;
- 业务线程 删除 了「所有从灰对象通往该白对象」的引用。
直觉理解:黑对象已经被认定「扫完了、不会再回头看」,此时它突然指向一个白对象;而原本还能通过灰对象走到这个白对象的路又被切断了 —— 于是这个白对象成了「黑对象偷偷抓着、但标记流程再也走不到」的漏网之鱼,被冤杀。
只要 破坏其中任意一个条件,漏标就不会发生。这正好对应两种主流解法:
| 解法 | 破坏哪个条件 | 做法 | 谁在用 |
|---|---|---|---|
| 增量更新(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、G1 | GC 线程与业务线程同时运行,把 STW 压到最低 |
| 第四代:低延迟 | ZGC、Shenandoah | STW 压到 10ms 以内,且几乎与堆大小无关 |
回收器演进是一部「如何让 GC 不卡顿用户线程」的奋斗史:从串行到并行、并发,再到低延迟。
其中第三、四代最值得细看:
- CMS:老年代并发回收的开拓者(用的正是 5.1 的增量更新),但有内存碎片问题,JDK 9 被废弃、JDK 14 被移除;
- G1:把堆切成很多小块(Region),每次 GC 只挑「性价比最高」的几块回收,可预测停顿时间。JDK 9 起成为默认 GC;
- ZGC / Shenandoah:哪怕你堆开到 16TB,停顿依然能控制在毫秒级,是金融、实时交易等场景的救星。
下面把当前的默认选择 G1 和低延迟标杆 ZGC 分别讲透。
七、重点看看 G1(默认回收器)
既然 G1 是 JDK 9 起的默认选择,值得单独讲透。要理解它,先看它解决了前几代的什么痛点:传统分代 GC 把堆分成 一整块连续的年轻代 + 一整块连续的老年代,堆一大(比如 16GB),回收老年代就得 整块扫一遍,停顿长且不可控,你也没法跟它说「我最多只能接受停 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 的垃圾占比,优先回收垃圾最多、性价比最高的那几块,而不是傻乎乎全扫 —— 用最小代价回收最多空间。正因为是「挑着几块回收」,你设定 -XX:MaxGCPauseMillis=200,G1 会自己算「这次清几块刚好不超时」,只清那么多,从而做到 可预测停顿。
7.2 G1 的一轮回收长什么样
G1 的老年代回收不是一锤子买卖,而是一个 并发标记周期,分成四个相位(前面 5.1 的 SATB 就用在这里):
G1 并发标记周期:初始标记 → 并发标记 → 重新标记 → 筛选回收,只有首尾几步短暂 STW。
- 初始标记:只标 GC Roots 能直达的对象,很快,通常搭一次 Young GC 一起做(STW,但短);
- 并发标记:与业务线程 同时跑,顺着引用链遍历整个对象图,耗时但不停顿;
- 重新标记:用 SATB 记录的旧引用补扫并发期间的变动,修正漏标(STW,但短);
- 筛选回收(Evacuation):按性价比挑出最值得回收的 Region,把里面的存活对象 复制 到别的 Region,然后整块回收(STW,是停顿主要来源)。
两个还会被追问的细节:
- Mixed GC(混合回收):并发标记完成后,G1 的回收不再是「纯年轻代」,而是 年轻代 + 一部分挑出来的老年代 Region 一起收,这就是「Mixed GC」。它是 G1 控制老年代增长的主力,也是 G1 通常 不需要 Full GC 的原因(真触发 Full GC 说明回收跟不上分配,是危险信号)。
- Humongous 对象(巨型对象):一个对象大到超过 半个 Region,就被当成巨型对象,直接占用一段连续的 Humongous Region,不走 Eden。它的分配和回收都比较特殊,过多的巨型对象容易引发问题。
形象类比
老办法是大扫除 —— 每次必须把整个房子(老年代)从头擦到尾,房子越大停得越久;G1 则把家划成很多小格子,管家 每次只挑最脏的几格 打扫,而且你能规定「这次别超过 10 分钟」,他就挑差不多 10 分钟能扫完的格子先扫。
一句话记住 G1
把大堆切成上千个小方块,优先扫垃圾最多的那几块(Garbage-First),用「初始标记 → 并发标记 → 重新标记 → 筛选回收」四步、只在首尾短暂 STW,从而在几个 GB 的大堆上做到「你定停顿目标、它尽量满足」。
八、低延迟标杆 ZGC:染色指针 + 读屏障
G1 已经很强,但它的停顿仍随堆里存活对象增多而变长(复制存活对象要 STW)。当你需要 几十上百 GB 的堆、停顿还得稳定在个位数毫秒 时,就轮到第四代的 ZGC 出场了。ZGC 的目标很激进:停顿 < 10ms(现在实测常在亚毫秒),且几乎与堆大小无关。
它凭什么做到?前面 G1 停顿的大头是「复制存活对象」这一步的 STW。ZGC 的杀手锏是把 对象的复制(转移)也变成并发的 —— 业务线程和 GC 一边转移一边跑。要做到这点,靠两个核心机制:
① 染色指针(Colored Pointers)
在 64 位系统里,一个对象指针用不满 64 位。ZGC 借用指针里几个空闲的高位,存上「这个对象的 GC 状态」——比如是否已标记、是否已被转移(remapped)。GC 的状态信息不再单独存表,而是直接『染』在指针上,读指针就知道对象处于哪个阶段。
② 读屏障(Load Barrier)
既然状态在指针上,那每次 从堆里读出一个对象引用 时,ZGC 都插一小段 读屏障 代码检查这个指针的染色位:
ZGC 读屏障:每次读引用都顺手检查染色位,必要时就地完成标记或修正到对象的新地址(self-heal)。
- 干净:状态最新,直接用;
- 带标记位:顺手帮着完成标记再返回;
- 发现对象已被移动:查 转发表 找到新地址,并 顺手把这个引用改成新地址(self-heal,自愈)—— 下次再读就不用查表了。
正因为「转移」被摊进了每次读操作里并发完成,ZGC 才把需要 STW 的工作压缩到只剩「扫描 GC Roots」这一小步,于是停顿几乎不随堆变大而变长。
对照记忆
- G1 靠 写屏障 维护 SATB 和跨代引用,停顿主要花在「复制存活对象」;
- ZGC 靠 读屏障 + 染色指针 把复制也并发化,停顿几乎只剩「扫 Roots」。
- Shenandoah 思路类似(用 Brooks 转发指针 + 读屏障),是另一条低延迟路线。
那什么时候该换 ZGC
G1 是「平衡型」默认选择,普通在线服务用它即可。只有当你要 极致低延迟(停顿 < 10ms,且堆开到几十 GB 停顿也不变长)时,才是 ZGC 的主场。代价是它吞吐通常略低于 G1、且读屏障有一定开销。
一句话记住 ZGC
把「对象搬到哪了」这条信息塞进 指针本身的几个空闲位(染色指针),每次读引用都过一道 读屏障 顺手把旧指针修正到新地址(自愈)—— 于是连「搬对象」都能和业务线程并发进行,STW 只剩扫 Roots,停顿几乎与堆多大无关。
九、怎么选 GC:取舍三角
GC 调优永远在三个目标之间做取舍:
- 吞吐量:业务代码占总运行时间的比例;
- 延迟:单次 GC 停顿的时长;
- 内存占用:GC 算法本身要额外用多少内存。
用「边办公边打扫」理解三者
把程序想象成在家办公:业务线程 = 你办公,GC = 打扫卫生,屋子(堆)用着用着就脏了(产生垃圾),得时不时停下来扫。
- 吞吐量 = 一天里真正用来办公的时间占比。公式:
业务时间 / (业务时间 + GC 时间)。一天 480 分钟里只花 8 分钟打扫,吞吐量就有 98.3%;- 延迟 = 每次打扫时被迫停工的那一下卡顿(即 Stop-The-World,所有业务线程冻结)。指的是 单次 停顿有多长;
- 内存占用 = 为了扫得高效额外占的空间。还是办公室的例子:你得 空出一间储物房专门周转(标记-复制永远空着一半)、边办公边打扫时要 留出空地暂放杂物(G1/ZGC 预留缓冲,否则新垃圾没处堆)、还要一本 记账本记录东西放在哪(记忆集,本身也占地方)。办公室越大,垃圾堆很久才满、很久才扫一次(GC 从容);办公室越小,一会儿就塞满、得频繁打扫(GC 频繁)。
这里有个反直觉的点 —— 吞吐量高 ≠ 用户体验好:
| 方案 | 总 GC 时间 | 停顿方式 | 吞吐量 | 延迟体验 |
|---|---|---|---|---|
| A | 8 分钟 | 1 次停 8 分钟 | 高 | 极差(一次卡 8 分钟) |
| B | 10 分钟 | 1200 次,每次 0.5 秒 | 略低 | 好(几乎无感) |
一个网页请求平时 10ms 返回,一旦撞上 A 那种长 STW,这次请求直接卡几百毫秒,用户立刻感知到顿挫。所以 在线服务/金融/游戏看重延迟,离线批处理看重吞吐量。
三者不可兼得
- 想要 低延迟 → 让 GC 与业务线程 并发 同时跑,但会抢走 CPU、还要加读/写屏障 → 吞吐量下降;
- 想要 省内存 → 堆开小,垃圾很快堆满 → 只能 更频繁地 GC → 停顿更多、吞吐也受影响。
吞吐量、延迟、内存占用构成铁三角,三者互相牵制,无法同时最优。
正因为不可兼得,所以没有「最好的 GC」,只有「最适合你场景的 GC」。实际选型可以简化成:
| 场景 | 推荐 GC |
|---|---|
| 离线批处理、看重总吞吐 | Parallel |
| 普通在线服务 | G1(默认) |
| 极致低延迟(金融、游戏) | ZGC |
对象怎么生、怎么死,两篇下来都讲透了:可达性分析找垃圾、Safepoint + OopMap 停顿枚举 Roots、分代收集配三种算法、三色标记 + 读写屏障保住并发正确性,直到 G1 与 ZGC 把停顿压到毫秒级。但还有一个问题没答:既然 Java 要经过 JVM 翻译,它到底慢不慢?为什么大厂敢用 Java 扛高并发? 这正是下一篇 进阶篇 · JIT、内存模型与调优前沿 的主题 —— 即时编译、Java 内存模型,以及一套从「玄学」到「科学」的调优方法论。
延伸阅读
- 周志明.《深入理解 Java 虚拟机(第 3 版)》—— 第 3 章(垃圾收集器与内存分配策略)是本篇最直接的母本。
- Oracle. HotSpot Virtual Machine Garbage Collection Tuning Guide —— G1 / ZGC 官方调优指南。
- Oracle. Z Garbage Collector —— ZGC 染色指针与并发转移的一手资料。