5.内存回收机制设计
# 33.内存回收机制设计
📍 本篇位置:第 4 卷 · 内存与资源 · 第 3 篇(卷扛鼎之作) 🎯 核心矛盾:自动释放 vs 性能可预测 —— 让程序员省心,就要让运行时多干活 🧭 设计灵魂:所有 GC 都在三件事上做取舍——怎么找垃圾 / 什么时候找 / 找的时候要不要停 🌐 跨语言覆盖:Java(分代 + G1/ZGC) · JavaScript(V8 增量标记) · Go(三色并发) · Python(引用计数+循环检测) · Swift(纯 ARC) 🔗 延伸阅读:→ 34.多种引用技术设计 · ← 32.堆和栈内存的设计 · ← 31.内存模型技术设计
flowchart LR
A[根本矛盾<br/>谁来释放内存?] --> B1[手动派<br/>C / C++<br/>程序员负责]
A --> B2[自动派<br/>GC / ARC<br/>运行时负责]
B2 --> C1[找垃圾<br/>引用计数 vs 可达性]
B2 --> C2[何时找<br/>分代假说]
B2 --> C3[要不要停<br/>STW vs 并发]
C1 & C2 & C3 --> D[设计天花板<br/>吞吐 / 延迟 / 内存<br/>三角不可能]
style A fill:#f8d7da
style D fill:#fff3cd
2
3
4
5
6
7
8
9
# 目录介绍
- 00.一次凌晨3点Full GC事故
- 01.GC 之前的世界:手动管理时代
- 02.找垃圾:计数到可达跜迁
- 03.三色标记:并发 GC 的灵魂
- 04.分代假说:用统计学打败暴力
- 05.回收算法的演进
- 06.并发GC:从STW到不可感知
- 07.GC 的吞吐-延迟-内存三角
- 08.跨语言 GC 设计对比
- 09.经典陷阱与回应
- 10.一句话总结:GC 的设计哲学
# 00.一次凌晨3点Full GC事故
# 0.1 接到告警的那个晚上
某互联网公司,订单系统稳定运行了 18 个月,QPS ~3000,平均延迟 50ms。2022 年 11 月 11 日大促当晚 02:47,监控告警齐刷刷红了:
告警 1:订单接口 P99 延迟 8.2 秒(基线 80ms)
告警 2:JVM 堆内存使用率 96%(基线 65%)
告警 3:YoungGC 频率 2 次/秒(基线 1 次/30 秒)
告警 4:Full GC 触发,单次暂停 12.3 秒 ★★★
2
3
4
值班工程师第一反应是"接口超时是不是数据库慢了"——查了 SQL,没有慢查询。再看 Redis、网卡,也都正常。然后他打开了 GC 日志:
2022-11-11T02:47:13.412+0800: [Full GC (Ergonomics)
[PSYoungGen: 524288K->0K(524288K)]
[ParOldGen: 1572863K->1572860K(1572864K)] ← 几乎无回收
1572863K->1572860K, [Metaspace: 89234K->89234K(1132544K)],
12.3271234 secs] [Times: user=98.43 sys=0.21, real=12.33 secs]
2
3
4
5
12.3 秒的 Full GC,老年代基本没回收下来。 紧接着每 30 秒就来一次 Full GC,每次 12 秒——业务实际上已经卡死。
# 0.2 用 GC 日志倒推现场
凌晨 4 点,工程师把 jmap 导出的堆转储拖到 MAT 里看:
Histogram of Heap (Top 5):
─────────────────────────────────────────────────────
Class Instances Size
─────────────────────────────────────────────────────
char[] 8,234,567 1.4 GB ★ 异常
java.lang.String 8,234,510 197 MB
com.x.OrderCacheEntry 1,234,890 142 MB
byte[] 234,567 89 MB
java.util.HashMap$Node 7,234,567 231 MB
─────────────────────────────────────────────────────
GC Roots Path(保留路径):
com.x.OrderCacheManager.cache (static HashMap)
└── 1,234,890 个 OrderCacheEntry
└── 持有 6,234,567 个 char[]
2
3
4
5
6
7
8
9
10
11
12
13
14
15
根因瞬间清晰——大促前研发团队为了抗压,加了一层"订单详情本地缓存",但忘记设过期时间。18 个月里订单数据持续累积,老年代被它一点点撑满。大促 QPS 翻 5 倍,新对象大量分配到 Eden,Eden 满 → Young GC → 部分晋升到老年代 → 老年代满 → Full GC → 缓存对象都是强引用,回收不下来 → Full GC 持续触发。
# 0.3 老司机的灵魂三问
第二天复盘会上,架构师问了三个穿透性的问题:
问题 1:为什么 18 个月一直没事,偏偏大促爆?
工程师:因为 QPS 涨了 5 倍。
架构师:错。日常 QPS 时也在累积,只是累积速度慢——
老年代每天涨 0.3%,18 个月才到 65%。
大促把 Eden 填满频率推到极限,
存活对象晋升老年代速度暴增 → 老年代瞬间到 96% → Full GC。
根因不是 QPS,是缓存设计错了。
2
3
4
5
6
问题 2:为什么 Full GC 12 秒不能回收?
工程师:因为缓存还在被用。
架构师:再深一步——为什么 GC 认为它"还在被用"?
因为 OrderCacheManager 是 static,static 字段是 GC Root。
Root 直接持有那个 HashMap,HashMap 持有所有 entry——
从 Root 出发可达 → GC 算法层面就"必须保留"。
这不是 GC 的错,是你写的"强可达"不允许它收。
工程师:那应该用什么?
架构师:SoftReference 或 LRU + 过期。GC 永远只服从一条法则:
从根可达即不回收。你想让它回收,得告诉它"这是软引用"或者"这条路断了"。
2
3
4
5
6
7
8
9
问题 3:为什么平时没有这种监控告警?
工程师:因为日常情况老年代涨得慢。
架构师:所以 GC 监控不能只看"现在是不是高",要看"涨速"。
如果你每天监控老年代 promotion rate(晋升速率),
发现连续一周比上周高 3 倍,就该排查了——
GC 问题永远是"温水煮青蛙",提前一周发现 vs 当晚救火,差一个数量级的成本。
2
3
4
5
# 0.4 这次事故揭示了什么
事故的本质,不是 GC 算法有 bug,而是研发对"对象生命周期"的直觉建立在错误的心智模型上:
我以为:
把对象塞进 HashMap 当缓存,反正 JVM 会自动回收"用不到的"
实际:
HashMap 强引用所有 entry → 这些 entry 在 GC 看来"100% 还在用"
→ JVM 永远不会回收
→ 我以为的"自动管理"实际是"自动累积"
2
3
4
5
6
7
| 视角 | 你以为的 | 实际发生的 |
|---|---|---|
| 写代码 | "GC 会自动管内存" | GC 只回收"不可达"对象 |
| 缓存层 | "缓存是临时的" | static + HashMap = 永久强引用 |
| 大促 | "QPS 高才是问题" | 真问题是积累 18 个月的隐患被引爆 |
| Full GC | "停一会就好了" | 强引用挡道,停 12 秒也回收不下来 |
整个 GC 设计的核心矛盾就藏在这里:
GC 自动化的前提是"程序员正确表达对象的生命周期"。GC 算得再快,也算不出"哪些对象你逻辑上不再需要"——它只能算"哪些对象从根不可达"。这两件事不是同一件事。
# 0.5 五个层层递进的追问
带着这次事故,整篇文章其实就是在回答下面五个递进的问题:
| 追问 | 答案章节 |
|---|---|
| 没有 GC 的 C 程序员怎么管内存?为什么这么累? | §01 |
| GC 怎么"找垃圾"?为什么不能用引用计数? | §02 |
| 并发 GC 怎么做到"边运行边回收"? | §03 |
| 为什么所有现代 GC 都搞分代?分代真有那么神? | §04 / §05 |
| ZGC 把 STW 压到 1ms 是怎么做到的?还能不能更快? | §06 / §07 |
带着这一晚踩过的坑,进入正题——你将看到,所有抽象的"三色标记、写屏障、记忆集、染色指针"原理,最终都能落到这次事故的根因图上。
# 01.GC 之前的世界:手动管理时代
要真正理解 GC 的价值,最好先回到没有 GC 的世界看看。
# 1.1 C 程序员的手动账本
C 语言里,每一块堆内存都要程序员"申请-记账-归还":
typedef struct Node {
int value;
struct Node* next;
} Node;
Node* create_list(int n) {
Node* head = malloc(sizeof(Node)); // 申请 1
head->next = malloc(sizeof(Node)); // 申请 2
head->next->next = malloc(sizeof(Node)); // 申请 3
// ... n 个节点
return head;
}
void use_list() {
Node* list = create_list(100);
// ... 用 list 干点事 ...
// ★ 现在必须手动释放
Node* cur = list;
while (cur != NULL) {
Node* next = cur->next;
free(cur); // ← 漏掉就泄漏,free 两次就崩
cur = next;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
C 程序员每写一个 malloc,脑子里都要同步记一笔:"哪里 free?谁来 free?free 几次?"——这就是手动管理的"心智负担"。
# 1.2 手动派的三大死结
手动管理的痛不只是"麻烦",它有三种根本性的错误模式:
# 死结一:内存泄漏(Memory Leak)
char* buf = malloc(1024);
process(buf);
return; // ★ 忘了 free → 这 1024 字节永远丢失
2
3
为什么这个 bug 难定位? 因为程序还在跑,没有立刻报错——只是内存慢慢涨。Web 服务跑一个月才 OOM,但定位泄漏点要复现一个月的工作负载。微软统计:70% 的安全漏洞与 C/C++ 内存安全相关,泄漏只是其中一类。
# 死结二:野指针(Dangling Pointer)
char* p = malloc(100);
free(p);
// ... 100 行代码 ...
p[0] = 'A'; // ★ p 指向的内存已经被 free,写过去就是越界
2
3
4
p 还指着那个地址,但那块内存可能已被分配给别人。这就是 Use-After-Free 漏洞的根源,2014 年 OpenSSL Heartbleed、2017 年某些浏览器 0day 都属于这类。
# 死结三:双重释放(Double Free)
free(p);
free(p); // ★ 同一块内存被 free 两次 → 堆元数据损坏 → 后续 malloc 崩溃
2
malloc/free 内部用元数据组织空闲链表,double free 会把链表搞乱,往往不是当场崩,而是在几百行之后某次 malloc 崩——调试地狱。
# 1.3 GC诞生:1958年Lisp给出答案
1958 年,John McCarthy 设计 Lisp 时遇到了一个问题:Lisp 大量构造 cons cell((a . b) 结构),如果靠程序员手动 free,整个语言根本没法用。
McCarthy 的解决方案极其大胆:让运行时定期"扫描所有还在用的内存",没扫到的就是垃圾,自动回收。
这就是历史上第一个 GC——Mark-Sweep(标记-清除)。它解决了三大死结:
泄漏 → 不可达对象自动回收,永远不会"忘记 free"
野指针 → 还在用的对象不会被回收
double free → 程序员根本碰不到 free,没机会调两次
2
3
代价是什么? —— Stop-The-World:扫描时所有业务线程暂停。McCarthy 当年在 IBM 704 上跑 Lisp,扫描一遍 32KB 内存可能要几百毫秒。这个"停一下"的代价,从 1958 年开始就成了 GC 设计的核心矛盾——后面所有进化(增量、并发、染色指针)都是在和这个矛盾较劲。
# 02.找垃圾:计数到可达跜迁
# 2.1 朴素方案:引用计数
如果让你设计一个 GC,最直觉的方案是什么?大部分人第一反应:
每个对象记一个计数器:有人引用我,+1;没人引用我了(计数=0),就回收。
这就是 引用计数(Reference Counting, RC)。CPython、Objective-C、Swift、C++ shared_ptr 都用这个方案:
# Python 简化模型
class PyObject:
refcount: int # 每个对象都有这个字段
# 赋值:a = b
# CPython 内部:
# Py_INCREF(b); # b 的 refcount + 1
# Py_DECREF(a_old); # a 原来指的对象 refcount - 1
# a = b; # 真正赋值
2
3
4
5
6
7
8
9
优点显而易见:
- 对象一变垃圾立刻回收,无 STW
- 实现简单,每个赋值/拷贝插入两条计数指令
- 内存释放时机可预测(析构调用顺序确定)
# 2.2 引用计数的致命伤:循环引用
但你写两行代码就能戳破它:
class Node:
def __init__(self):
self.ref = None
a = Node() # a.refcount = 1
b = Node() # b.refcount = 1
a.ref = b # b.refcount = 2
b.ref = a # a.refcount = 2
del a # a.refcount = 1(不为 0,不回收)
del b # b.refcount = 1(不为 0,不回收)
# ★ 现在外面没人能访问 a 和 b 了,但它们的 refcount 都是 1
# ★ 永远不会被释放——内存泄漏
2
3
4
5
6
7
8
9
10
11
12
13
14
这是 RC 的"原罪":它只看"有几个引用指着我",看不到"这些引用是不是从根可达的"。两个对象互相指着对方,就算外面没人用了,counter 也归不到 0。
CPython 的解决办法是引用计数 + 循环检测:定期跑一个"循环垃圾收集器",扫描可疑的容器对象(list/dict/class)找循环。但这就回到了 GC 的老路——既然要扫,那 RC 单独存在的意义就被稀释了。
真正在生产中纯 RC 还能活下来的,是 ARC(Swift/Objective-C):编译器在编译期插入 retain/release,并要求程序员用 weak / unowned 显式打破循环。代价是程序员要把循环引用的判断负担背回去——这是工程妥协,不是技术进化。
# 2.3 范式切换:对象自报转根集追踪
现代 GC 主流(Java/Go/JS/.NET)走的是完全不同的路:可达性分析(Reachability Analysis)。
核心思路:
不要问"这个对象有几个人引用它",
要问"从程序的'根'(栈、静态、寄存器)出发,能走到这个对象吗"。
能走到 → 还在用 → 保留
走不到 → 不可能被使用了(程序根本拿不到它的引用)→ 回收
2
3
4
5
flowchart LR
subgraph GCRoots[GC Roots]
R1[栈变量]
R2[静态字段]
R3[活动线程]
R4[JNI 引用]
end
R1 --> O1[对象 A]
R2 --> O2[对象 B]
O1 --> O3[对象 C]
O2 --> O3
O4[对象 D]
O5[对象 E]
O4 --> O5
O5 --> O4
style O1 fill:#d4edda
style O2 fill:#d4edda
style O3 fill:#d4edda
style O4 fill:#f8d7da
style O5 fill:#f8d7da
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
D 和 E 互相引用——但它们从根集出发不可达,所以可达性分析直接判定为垃圾。循环引用问题被天然解决。
# 2.4 GC Roots 究竟是什么
那么"根"到底是什么?这是面试常问、但很少人说全的问题。Java GC Roots 包括:
| 类别 | 含义 | 例子 |
|---|---|---|
| 虚拟机栈中的引用 | 当前每个线程栈帧里的局部变量 | 方法里的 Order o = ... |
| 本地方法栈中的引用 | JNI 调用栈 | C/C++ 代码持有的 jobject |
| 方法区中的静态字段 | 类的 static 引用 | static Map cache = ... ★ §0 事故元凶 |
| 方法区中的常量引用 | 字符串常量池 | "hello" 这种字面量 |
| 被同步锁持有的对象 | synchronized(obj) 里的 obj | 锁对象 |
| JVM 内部引用 | Class 对象、异常对象、加载器 | ClassLoader 等 |
§0 事故的"凶器" static HashMap cache 就是第三类——任何 static 字段都是 Root,static 持有的所有对象链都是"强可达"。这就是为什么把它改成 SoftReference 或 LRU 缓存能解决问题:一旦从 Root 到对象的路径不再是"全强引用",GC 就有权介入。
# 2.5 两种范式的本质对比
| 维度 | 引用计数(RC) | 可达性分析(Tracing GC) |
|---|---|---|
| 回收时机 | 计数归零→立即回收 | 周期性扫描批量回收 |
| 循环引用 | 无法处理(需辅助算法) | 天然处理 |
| 吞吐成本 | 每次赋值 2-4 条指令 | 仅扫描时集中开销 |
| 暂停模式 | 析构链可能很长(雪崩) | STW 或并发 |
| 典型代表 | CPython、Swift ARC | Java、Go、V8 |
深层洞察:RC 是"分布式"的(每个对象自治),Tracing 是"中心化"的(GC 统一裁决)。没有银弹——CPython 选 RC 是因为 1991 年硬件慢、并发 GC 还不成熟;Java 选 Tracing 是因为 1995 年面向"大型企业应用",对象图复杂、循环引用难免。
接下来我们聚焦主流的 Tracing GC,看它是怎么从"暴力扫描全部"演进到"边跑边收"的。
# 03.三色标记:并发 GC 的灵魂
# 3.1 为什么需要三种颜色
最朴素的可达性分析叫两色标记:未访问(白)、已访问(黑),扫描完后白色的就是垃圾。但这个方案有个致命问题——它必须 STW。
为什么?想象一下:
GC 正在扫描,标记到 A 是黑色
此时业务线程跑了一行:a.field = newObj
newObj 是新创建的,GC 还没看到 → 是白色
GC 扫描完成 → 把所有白色当垃圾回收 → newObj 被错杀!
2
3
4
业务线程"边修改边给 GC 添乱"——GC 被迫停下所有业务线程才能保证扫描的一致性。对于 100GB 堆,STW 可能持续 5-10 秒——大型系统不能接受。
突破口是 1978 年 Dijkstra 提出的三色标记(Tri-color Marking):
白色:从未被发现(潜在垃圾)
灰色:已发现但还没扫描它的引用(待办)
黑色:自己和所有引用都扫描过了(确认存活)
2
3
stateDiagram-v2
[*] --> White: 初始
White --> Gray: 被根集或灰对象引用
Gray --> Black: 自身的引用全部扫描完
White --> [*]: GC 结束→回收
Black --> [*]: GC 结束→保留
2
3
4
5
6
三色相比两色多了一个"中间态"灰——这一个状态成了并发 GC 的关键。
# 3.2 三色不变式的数学美
三色标记的数学本质是两条不变式(Invariant):
| 不变式 | 含义 |
|---|---|
| 强三色不变式 | 黑色对象不能直接指向白色对象 |
| 弱三色不变式 | 黑色对象可以指向白色对象,但必须存在一条灰色路径能到达这个白色对象 |
为什么? 因为黑色意味着"我已经扫描过了,不会再回头"。如果黑色直接指向白色,GC 永远不会再访问到那个白色对象,扫描结束后它会被错杀。
flowchart LR
subgraph 合法[合法状态]
B1[黑] --> G1[灰] --> W1[白]
end
subgraph 非法[非法状态]
B2[黑] -.直接.-> W2[白]
end
style 非法 fill:#f8d7da
2
3
4
5
6
7
8
只要算法保证"从黑色出发的所有白色都至少有一条灰色路径",GC 就可以在业务线程跑的同时扫描堆——不再需要 STW 整个扫描过程。这是并发 GC 的数学基础。
# 3.3 并发标记的灾难:错杀活对象
但仅有三色规则还不够。看这个并发场景:
T0:GC 扫描 A,A 标黑(A 没指向 X)
T1:业务线程执行 A.x = X ← X 当时是白色
T2:业务线程执行 B.x = null ← 切断了 B 到 X 的路径
T3:GC 扫描 B,B 标黑
T4:GC 扫描结束 → X 还是白色 → 被回收 ★ 错杀
2
3
4
5
sequenceDiagram
participant GC
participant App as 业务线程
participant A
participant B
participant X
GC->>A: 扫描 A,标黑(无引用 X)
App->>A: A.x = X(黑→白!违反不变式)
App->>B: B.x = null(切断灰→白路径)
GC->>B: 扫描 B,标黑
Note over X: 永远是白色 → 被错杀
2
3
4
5
6
7
8
9
10
11
12
根因:业务线程"偷偷"修改了引用关系,破坏了三色不变式。
# 3.4 写屏障:1%代价捒50%暂停
为了在并发场景下不错杀,GC 要给所有"修改引用"的操作加一个钩子——这就是 写屏障(Write Barrier)。
朴素的赋值:
obj.field = newRef
加上写屏障后:
obj.field = newRef
write_barrier(obj, newRef) ← 偷偷做点事,恢复三色不变式
2
3
4
5
6
不同 GC 用不同的策略:
# Dijkstra 写屏障(插入屏障):守住"黑→白"边界
write_barrier(obj, newRef):
if newRef is white:
把 newRef 标灰 ← 谁被加引用,谁立刻"待办"
2
3
效果:黑色对象指向白色时,立刻把白色变成灰色——强不变式永远成立。Go 1.7 之前用这个策略。
# Yuasa 写屏障(删除屏障):守住"老引用"
write_barrier(obj, newRef):
if obj.field is not null:
把 obj.field (旧引用)标灰 ← 删除前先"立此存照"
2
3
效果:保护"删除瞬间还活着"的对象,本轮 GC 不杀它(下轮再处理)。CMS、G1 在某些阶段用。
# 混合屏障:Go 1.8+ 的妥协
write_barrier(obj, newRef):
把 newRef 标灰(插入)
把 obj.field 旧引用标灰(删除)
2
3
两侧都保护,但 Go 用了一个聪明优化:栈上对象一律视为黑色,只对堆做屏障——大幅降低屏障开销。
# 写屏障的代价
没有写屏障:每次赋值 1 条 mov 指令
有写屏障: 每次赋值 1 mov + 1 函数调用(约 5-10 条指令)
→ 整体业务吞吐下降约 1-3%
→ 换来 STW 从 5 秒降到 10 毫秒
2
3
4
5
这就是并发 GC 的根本权衡:用稳定的小开销(写屏障),换不可预测的大暂停(STW)。1% 吞吐换 50%+ 延迟收益——值。
# 04.分代假说:用统计学打败暴力
# 4.1 1984 年的一个反直觉发现
1980 年代初,研究者在跑 Smalltalk、Lisp 程序时观察到一个稳定的统计规律:
大部分对象都是"朝生夕死"的——活不过 1 次 GC。
这个发现来自 David Ungar 在 Berkeley 的实验:在 Smalltalk-80 程序里追踪每个对象的生存时间,画出柱状图:
存活时间 (GC 次数) │ 对象比例
─────────────────┼─────────
0 │ ████████████████ 80% ★ 大部分对象生命极短
1 │ ████ 12%
2 │ ██ 5%
3 │ █ 2%
... │ 极少
>10 │ < 0.5% ★ 真正长寿的对象很少
2
3
4
5
6
7
8
这个统计规律不是偶然,它源于编程范式本身:
- 函数返回值临时对象 → 立刻丢
- 字符串拼接中间结果 → 立刻丢
- 循环里的迭代器、临时容器 → 立刻丢
- 但单例、缓存、配置对象一旦被创建,就活到进程结束
# 4.2 分代假说的三个支柱
基于这个观察,1984 年 Ungar 提出分代假说(Generational Hypothesis):
| 假说 | 含义 | 工程含义 |
|---|---|---|
| 弱假说 | 大部分对象朝生夕死 | 把它们集中放一处,扫描时只扫这处即可 |
| 强假说 | 熬过几次 GC 的对象会更长寿 | 把它们移到另一处,今后少扫 |
| 跨代引用稀少 | 老对象引用新对象的情况少 | 扫新生代时几乎不用扫老年代 |
于是把堆切两半:
新生代 (Young Gen) 老年代 (Old Gen)
┌──────────────────┐ ┌────────────────────────┐
│ Eden + Survivor │ 晋升→ │ 长生命周期对象 │
│ 快速回收(频繁) │ │ 慢速回收(稀少) │
└──────────────────┘ └────────────────────────┘
每秒 GC 一次 每分钟/小时 GC 一次
2
3
4
5
6
为什么分代有用? 看这个数字:
不分代:堆 8GB,每次扫描遍历所有对象 → 扫描 800ms
分代:
新生代 512MB,扫描遍历 → 50ms(90%+ 对象直接回收,极快)
老年代 7.5GB,仅在老年代满时扫一次 → 600ms,但频率低 100 倍
总体开销下降 80%+,平均延迟下降 90%+
2
3
4
5
6
这就是分代的魔力:用对象生命周期的统计规律做空间分区,把"大暴力扫描"变成"小高频 + 大低频"。
# 4.3 跨代引用:分代设计的"代价"
但分代有个隐藏代价——跨代引用。
Eden 里的对象 X 被老年代里的对象 O 引用:
老年代 新生代
┌────┐ ┌────┐
│ O ├──── 引用 ────→│ X │
└────┘ └────┘
2
3
4
5
6
GC 想只扫新生代——但 X 是不是被引用了?要回答这个问题,必须扫一下老年代——分代的好处全没了。
如果强假说"跨代引用稀少"成立,绝大多数对象 X 只被新生代里的对象引用,扫一遍新生代就够了。但只要存在一个 O→X,就要把整个老年代扫一遍——成本爆炸。
# 4.4 记忆集:用空间换时间
工程上的解法是 记忆集(Remembered Set, RSet):
新增一个数据结构,专门记录:
"老年代中哪些区域可能引用了新生代"
每次老年代对象修改引用(写屏障捕获):
if 新引用指向新生代:
把这一片老年代区域加入 RSet
扫新生代时:
GC Roots ∪ RSet 中记录的老年代区域
↑ 这两个就是全部"可能指向新生代"的源头
2
3
4
5
6
7
8
9
10
记忆集的精度 vs 开销:
| 实现 | 精度 | 内存开销 | 代表 |
|---|---|---|---|
| 卡表(Card Table) | 一个 card = 512 字节区域 | 堆的 1/512 | HotSpot G1 |
| 位图 | 每对象一位 | 堆的 1/64 | 部分小型 GC |
| 精确表 | 每个引用一条记录 | 高 | 学术原型 |
HotSpot 用 卡表:把老年代切成 512 字节的"卡",每个卡一个 byte 的标志位。每次写引用只需 card[obj_addr >> 9] = 1,3-4 条指令。空间开销只有 1/512——堪称工程艺术。
# 05.回收算法的演进
找到垃圾后怎么回收?这是 GC 的另一半故事,也经历了 60 年的演进。
# 5.1 标记-清除:最朴素也最致命
McCarthy 1958 年的最早 GC 用的就是它:
1. 标记阶段:从 Roots 出发遍历,可达对象标 "活"
2. 清除阶段:扫描整个堆,把没标记的释放回空闲列表
2
flowchart LR
subgraph Before[回收前]
A1[活] --> A2[死]
A2 --> A3[活]
A3 --> A4[死]
A4 --> A5[活]
end
subgraph After[回收后]
B1[活] --> B2[空]
B2 --> B3[活]
B3 --> B4[空]
B4 --> B5[活]
end
2
3
4
5
6
7
8
9
10
11
12
13
优点:实现极其简单,对存活对象零拷贝。
致命缺点:内存碎片化。回收后空闲空间是"破碎的小块"——明明总剩余 200MB,但分配一个 50MB 的对象都失败(找不到连续 50MB)。
堆物理状态:
[活|死|活|死|活|死|活|死]
↓ 清除
[活|空|活|空|活|空|活|空]
↑ 200MB 总剩余,但最大连续块只有 8MB → 大对象分配失败
2
3
4
5
这就是为什么后续算法都在解决"碎片"问题。
# 5.2 复制算法:一半内存换零碎片
1969 年 Cheney 提出:
把堆切两半(From / To)。
分配只在 From 半区。
GC 时把活对象"复制"到 To 半区(紧凑排布),然后整个 From 半区清空。
下次 GC From / To 角色互换。
2
3
4
flowchart LR
subgraph From[From 半区·GC 前]
F1[活A]
F2[死]
F3[活B]
F4[死]
F5[活C]
end
subgraph To[To 半区·GC 后]
T1[活A] --> T2[活B] --> T3[活C] --> T4[空闲连续区]
end
From -->|复制活对象| To
2
3
4
5
6
7
8
9
10
11
12
优点:
- 零碎片(活对象紧凑排布)
- 分配极快(只需
bump pointer,移动指针即可) - 复制时只关心活对象——死对象多时极快("朝生夕死")
致命缺点:
- 永远只用一半内存——50% 的空间浪费
- 如果存活率高,复制成本巨大
这就是为什么复制算法专门给新生代用——新生代死亡率 80%+,复制开销小,碎片避免收益大;老年代死亡率低,再用复制就是亏本。
# Eden + 双 Survivor 的精妙
HotSpot 把新生代切成 Eden : S0 : S1 = 8 : 1 : 1:
Eden(80%) S0(10%) S1(10%)
分配区 老对象 新对象
2
每次 Young GC:
- Eden + 当前用的 Survivor → 复制存活对象到另一个 Survivor
- 历经多次仍存活 → 晋升到老年代
为什么 8:1:1 而不是 5:5? 因为分代假说告诉我们 90%+ 对象死在 Eden,所以 Eden 给大空间分配;S0/S1 只是"中转站",存放"活过 1-15 次 Young GC"的少数对象。只浪费 10% 内存(一个 Survivor 永远空)就换来零碎片 — 比标准复制算法的 50% 浪费经济得多。
# 5.3 标记-整理:在原地腾挪
老年代不能用复制(浪费太大),怎么办?
1974 年 Lisa 提出 标记-整理(Mark-Compact):
1. 标记阶段:和标记-清除一样
2. 整理阶段:把所有活对象"挤到"堆的一端,剩下的就是空闲
2
flowchart LR
subgraph Before[整理前]
A1[活A]
A2[死]
A3[活B]
A4[死]
A5[活C]
end
subgraph After[整理后]
B1[活A] --> B2[活B] --> B3[活C] --> B4[连续空闲]
end
2
3
4
5
6
7
8
9
10
11
优点:零碎片 + 不浪费内存。
致命缺点:移动对象 → 所有指向它们的引用都要更新。在大堆(百 GB 级)上,整理时间可能达到秒级——这就是 §0 事故里 Full GC 12 秒的根源。
# 5.4 分代收集:每代用最适合算法
终极方案是组合拳:
| 区域 | 死亡率 | 算法 | 理由 |
|---|---|---|---|
| 新生代 | 80%+ | 复制(Copy) | 死对象多,复制开销低 |
| 老年代 | 5-20% | 标记-整理 | 活对象多,复制太亏;标记-整理避免碎片 |
| 大对象区 | - | 直接老年代 | 避免复制大对象的代价 |
这就是 CMS、Parallel Scavenge 等经典 GC 的设计。但它们还有一个共同问题——Full GC 时整个堆 STW,老年代越大暂停越长。G1 / ZGC 给出了下一代答案。
# 06.并发GC:从STW到不可感知
# 6.1 Stop-The-World 的代价
回到 §0 事故:12.3 秒 STW 意味着什么?
12.3 秒 = 12,300 ms
QPS = 3000
12.3 秒内 = 3000 × 12.3 = 36,900 个请求堆积
P99 暴增 100+ 倍
连接池耗尽 → 上游服务也开始失败 → 雪崩
2
3
4
5
STW 不是"用户等一下",是"全链路雪崩"。所以从 G1 开始,所有现代 GC 都把"压低 STW"作为头号目标。
# 6.2 G1:把堆切成 Region 的妙招
G1(Garbage First,2009)的关键洞察:整堆扫描太贵,那就别整堆扫了。
传统:堆是 [新生代 | 老年代] 两大块
G1: 堆是 [Region1, Region2, ..., Region2048] 切成 ~2048 个 1-32MB 的小块
2
每个 Region 在不同时刻可以被标记为 Eden / Survivor / Old / Humongous(大对象专用):
┌─────────────────────────────────────────────┐
│ E S O O E O O O S E O O E O H H H O O E O ...│
└─────────────────────────────────────────────┘
每帧标识可动态变化
2
3
4
GC 时不扫整堆,而是:
- 算出每个 Region 的"垃圾比例"
- 选垃圾最多的几个 Region
- 只对它们做回收
这就是名字 "Garbage First"——每次只啃垃圾最多的那几块。用户可设置目标暂停时间(如 200ms),G1 自动选 Region 数量来满足。
典型表现:百 GB 堆,平均暂停 50-200ms,远好于 CMS 的秒级 Full GC。
# 6.3 ZGC/Shenandoah:染色指针读屏障
但 G1 还是有 STW,只是更短。真正的"无感 GC" 来自 ZGC(2018)和 Shenandoah(2014)。
它们的核心创新是 染色指针(Colored Pointers) + 读屏障:
64 位指针的高位空着没用 → 拿来塞 GC 状态:
[Marked0|Marked1|Remapped|Finalizable| ... | 实际地址 |
1 bit 1 bit 1 bit 1 bit 其它 42-44 bit
2
3
4
GC 阶段在指针的高位染色,而不是改对象本身:
- Marked:标记阶段在用
- Remapped:搬迁后地址需要更新
每次读取对象引用时,CPU 检查高位标记:
读屏障逻辑:
load ref from heap
if ref.color != current_phase_color:
slow_path: 修复引用(更新地址、重新标记)
else:
fast_path: 直接用
2
3
4
5
6
用读屏障的代价换得:
- 标记阶段几乎全程并发,STW < 1ms
- 整理阶段对象搬迁也并发,业务无感
- ZGC 官方目标:任意堆大小,STW < 10ms——TB 级堆上做到了
代价是什么? 读屏障比写屏障频率高得多(每次读字段都要检查)——大约 5-15% 的吞吐损失。但对低延迟系统(金融、实时推荐),这是值的。
# 6.4 Go 三色并发 GC 的另一条路
Go 选了一条"看似简单"的路:
- 不分代(Go 团队认为分代收益不足以抵消跨代引用复杂度)
- 三色标记 + 混合写屏障
- STW 目标 < 1ms
Go GC 关键阶段:
STW: 启动标记(< 1ms) → 让所有线程进入屏障状态
并发: 标记 ← 业务正常跑
STW: 标记终止(< 1ms) → 处理写屏障期间的脏页
并发: 清除 ← 业务正常跑
2
3
4
5
Go 的设计哲学:宁愿牺牲吞吐(GC 时业务变慢 ~10%),也不要长 STW。这适合 Go 主战场(云原生、API 服务)的"延迟敏感"特性。
但 Go GC 也有代价——GC 在大对象 / 内存密集型场景(比如游戏、大数据)表现远不如 JVM ZGC。这是设计取舍,不是 bug。
# 07.GC 的吞吐-延迟-内存三角
# 7.1 不可能三角的来历
经过 §02-§06,所有 GC 设计都在这个三角里取舍:
吞吐量(Throughput)
/ \
/ \
/ GC \
/ 设计三角\
/ \
/ \
延迟(Latency) ──── 内存(Memory)
2
3
4
5
6
7
8
| 顶点 | 含义 | 优化手段 |
|---|---|---|
| 吞吐量 | 业务时间占比 | 减少 GC 频率/总耗时 |
| 延迟 | 单次最大暂停 | 并发 GC、增量、染色指针 |
| 内存 | 堆空间消耗 | 减少冗余拷贝、复用 |
不可能三角的数学证明:你只能选两个,第三个必然受损。
| 想要的 | 牺牲的 | 典型代表 |
|---|---|---|
| 吞吐 + 内存 | 延迟(接受长 STW) | Parallel Scavenge(JVM 默认到 8) |
| 延迟 + 吞吐 | 内存(堆要预留更多余量) | ZGC(建议堆比工作集大 2x) |
| 延迟 + 内存 | 吞吐(业务变慢) | Go GC(强调延迟) |
# 7.2 真实场景的选型决策
flowchart TD
A[应用类型] --> B{P99 延迟敏感?}
B -->|是 < 100ms| C{堆大小?}
B -->|否| D[Parallel / ParallelOld<br/>追求吞吐]
C -->|< 4GB| E[G1 默认参数]
C -->|4-32GB| F[G1 + 调优 / ZGC]
C -->|> 32GB| G[ZGC 或 Shenandoah]
style D fill:#fff3cd
style E fill:#d4edda
style F fill:#d4edda
style G fill:#d1ecf1
2
3
4
5
6
7
8
9
10
11
12
13
典型场景:
Hadoop 离线作业:吞吐第一 → Parallel GC
Spring Boot Web 服务(中等堆):延迟 + 吞吐平衡 → G1
高频交易系统(百 GB 堆):延迟极致 → ZGC
小型 Docker 服务(< 2GB):内存敏感 → Serial GC(甚至不分代)
2
3
4
# 08.跨语言 GC 设计对比
# 8.1 Java 的分代收藏家路线
Java 的 GC 是"百花齐放"——任何应用都能找到合适的 GC:
Serial → Parallel → CMS → G1 → ZGC / Shenandoah
1996 2002 2004 2009 2018
吞吐为先 低延迟尝试 极致低延迟
2
3
Java 的策略是给开发者最大选择权,代价是参数极其复杂(-XX:+UseG1GC -XX:MaxGCPauseMillis=200 ...)。这反映了 Java 服务"通用平台"的定位——不预设你的场景。
# 8.2 JavaScript V8 的增量分代
V8(Chrome、Node.js):
- 新生代:Scavenge 复制算法
- 老生代:Mark-Sweep + Mark-Compact
- 增量标记:把标记切成 5-10ms 的小片段,穿插在 JS 执行间隙
- 空闲时 GC:浏览器空闲时(用户没操作)做老年代清理
V8 的精妙在于对人类感知的精确利用:人对 < 16ms 的暂停无感(一帧时间),所以把 GC 切到这个粒度,用户永远感觉不到 GC 的存在。
# 8.3 Go 的"延迟优先"哲学
Go 的设计反映了云原生时代的特点:
不分代(简化设计)
+ 并发标记(STW < 1ms)
+ 混合写屏障(栈零开销)
+ GOGC 参数(控制 GC 触发阈值)
2
3
4
优势:API 服务的 P99 极其稳定。劣势:吞吐和峰值内存比 JVM 差 10-30%。Go 团队的取舍很明确——延迟可预测 > 吞吐。
# 8.4 Python 引用计数 + 循环检测
CPython 选了别人都不选的路:
主:引用计数(实时回收)
辅:循环检测器(每 700/10/10 次分配跑一次,扫可疑容器)
2
优势:内存占用低、对象析构时机确定(适合资源管理)。劣势:每个赋值有原子计数开销、GIL 让多核 Python 性能受限。
# 8.5 Swift / Rust 的"无 GC"路线
Swift 用 ARC(编译期插入 retain/release)+ 程序员显式 weak/unowned 打破循环。优势:无 STW、可预测性能。劣势:循环引用要程序员负责。
Rust 更激进——靠所有权 + 借用检查让 GC 整个不存在。优势:零运行时开销。劣势:学习曲线极陡,复杂数据结构(图、双向链表)写起来很痛。
# 09.经典陷阱与回应
# 9.1 调了System.gc()为何不回收
System.gc() 是建议,不是命令——JVM 完全可以忽略它(-XX:+DisableExplicitGC)。生产代码里几乎不应该调用,因为:
- 你不知道 JVM 当前的 GC 策略适不适合
- Full GC 暂停可能比"留着不回收"代价更大
- 真有内存压力,JVM 自动会触发
正确做法:让 JVM 自动调度,监控 GC 日志找问题。
# 9.2 对象置null一定能被回收?
不一定。看这段代码:
public void hold() {
BigObject bo = new BigObject(); // bo 是局部变量
use(bo);
bo = null; // ★ 这一行通常没必要!
longRunningTask(); // 1 小时
}
2
3
4
5
6
JIT 已经做了逃逸分析 + 局部变量槽复用——bo 在 use(bo) 之后如果不再使用,槽位会被复用,对象自然不可达。手动置 null 大多数时候是噪音。
但有一种情况要置 null:
// 长期持有的字段
class Cache {
Object[] entries = new Object[1000];
void evict(int i) {
// entries[i] = null; ← 必须显式置 null
// 否则数组里那一格还强引用着对象,永不回收
}
}
2
3
4
5
6
7
8
9
§0 事故就是这种情况——HashMap 里的 entry 不显式 remove,永远是强可达。
# 9.3 为何Full GC后内存反而高
这通常是元空间扩容或直接内存(DirectByteBuffer)泄漏。GC 主要管堆,不管直接内存。-Xmx 之外还有:
- Metaspace(类元数据)
- Direct Memory(NIO Buffer)
- Code Cache(JIT 编译产物)
- Thread Stack(每线程 1MB+)
监控这些非堆区域是 GC 调优的另一半。
# 9.4 "ZGC 是不是 Java 的银弹"
不是。ZGC 的 trade-off:
- 吞吐损失 5-15%(读屏障开销)
- 建议堆比工作集大 2-3x(标记/搬迁要预留空间)
- 小堆(< 4GB)收益不明显
ZGC 适合:堆 > 16GB、延迟敏感(金融、低延迟服务)。 ZGC 不合适:内存受限的小型服务、批处理(Hadoop 这种)、追求峰值吞吐的场景。
# 10.一句话总结:GC 的设计哲学
# 10.1 三层认知阶梯
| 阶段 | 思维 | 工具 |
|---|---|---|
| 初级 | "GC 自动回收,我不用管" | 无 |
| 中级 | "看 GC 日志、调参数" | jstat / GCViewer / MAT |
| 高级 | "理解每个 GC 算法的取舍,按场景选型" | 上面所有 + ZGC / Shenandoah / 自研 |
# 10.2 GC 调优决策清单
问 1:现在卡在哪?
├─ STW 太长 → 换 G1 / ZGC,调小 -XX:MaxGCPauseMillis
├─ Young GC 太频繁 → 加大 -Xmn(新生代)
├─ 老年代涨太快 → 排查内存泄漏(MAT 看保留路径)
└─ Full GC 频繁 → 老年代算法换 G1,或加堆
问 2:内存泄漏怎么排?
├─ jmap -dump 拿堆快照
├─ MAT 打开看 Histogram
├─ 找异常多的类 → 看保留路径(GC Root 到它的链)
└─ 路径上谁是"罪魁"?通常是 static、缓存、监听器
问 3:选哪个 GC?
└─ 按 §7.2 决策树,结合压测数据微调
问 4:要不要自己调?
└─ 先用默认(G1)跑,有问题再改。盲目调参常见恶化性能
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 10.3 设计哲学一句话
GC 是一场"运行时和程序员的责任分配博弈"—— 程序员让出"何时释放"的控制权,运行时承担"判断+执行"的复杂度。 这个交换的核心货币是"对象生命周期信息"——程序员通过引用结构(强/弱/软)告诉 GC 自己的意图,GC 通过算法(可达性、三色、分代)去精准识别和回收。
当意图和算法对齐时,GC 是隐形的祝福;当意图模糊(§0 事故里把缓存写成 static HashMap),GC 就成了显形的灾难。
回到 §0 的"凌晨 3 点 Full GC"——真正的修复不是换更好的 GC,而是把 static HashMap cache 改成带过期的 LRU 或 SoftReference。意图对了,算法的活就轻松了。
# 10.4 与本卷其它章节的呼应
31.内存模型技术设计 ─→ JMM 是 GC 算法的并发基础
32.堆和栈内存的设计 ─→ 堆的分代布局是 GC 的物理基础
34.多种引用技术设计 ─→ 强/软/弱/虚是程序员表达意图的语言
35.数据拷贝设计原理 ─→ 复制算法是拷贝思想的极致应用
07.类的加载核心原理 ─→ Metaspace GC 与类卸载
03.第3卷-并发之道/19 ─→ STW 和上下文切换的关系
2
3
4
5
6
# 10.5 延伸阅读
- 论文:A Unified Theory of Garbage Collection (David Bacon, 2004)
- 书籍:《The Garbage Collection Handbook》(GC 圣经)
- 文档:ZGC: A Scalable Low-Latency GC (opens new window)
- 源码:HotSpot
gc/g1/g1CollectedHeap.cpp—— G1 主循环 - 工具:jstat / jcmd / JFR / Async-Profiler / GCViewer