📝 术语别名
  • GC: Garbage Collection, 自动垃圾回收;
  • STW: Stop-The-World, GC 期间所有应用线程被 JVM 冻住;
  • Heap: JVM 管理的对象内存区, 所有 new 出来的东西都在这里;
  • GC Roots: 一组"一定还活着"的引用起点, 可达性分析的种子;

前置: 为什么 Java 需要 GC

C/C++ 程序员是手动管内存的, 你 malloc 一块就要负责 free 一块, 漏了就内存泄漏, 重复 free 就 double-free crash; Java 把这个负担拿走了, 程序员只负责 new Foo(), 永远不主动释放;

那对象怎么消失? 答案是 JVM 启动了一个叫 GC 的后台机制, 周期性扫整个 heap, 把"再也不会被用到的对象"自动清掉, 这一行为的代价是: 扫的时候得短暂冻住应用线程 (STW), 这就是 GC 给你带来的延迟代价;

所以理解 GC 本质上要回答两个问题:

  1. 怎么判断一个对象"已经死了";
  2. 判断完之后, 怎么把死对象腾出来的空间高效利用;

下面整篇笔记基本上就是在围绕这两个问题展开;

GC 的第一个核心问题: 怎么判断对象死了

想法 1: 引用计数 (Reference Counting)

最直觉的做法: 每个对象身上挂一个计数器, 有人引用 +1, 引用没了 -1, 计数为 0 就回收;

听起来很美但有个致命问题 — 循环引用:

1
2
A.next = B
B.next = A

A 和 B 互相引用, 谁都不会归零, 但其实外面没有任何变量指向它们, 这两个对象是"死了但永远清不掉"; 这种地雷在像链表/图这种数据结构里到处都是;

所以主流 JVM (HotSpot) 没有用引用计数, 用的是下面这个;

想法 2: 可达性分析 (Reachability Analysis)

换个思路: 不去问"谁在引用这个对象", 而是问"从一组一定还活着的根出发, 我能不能走到这个对象"; 走得到 = 活, 走不到 = 死;

这组"一定还活着的根"叫做 GC Roots, 直觉上包含:

  • 当前所有线程栈上的局部变量 (栈帧里的引用);
  • 所有静态字段 (static 修饰的, 类生命周期级别);
  • JNI 引用 (native 代码持有的 Java 对象);
  • 一些 JVM 内部结构;

GC 干的事情就是: 从 GC Roots 开始做一次图遍历 (BFS / DFS 都行), 标记所有能达到的对象为"活"; 剩下没标记的就是死的, 可以清;

1
2
3
4
5
6
7
8
9
GC Roots (栈 / static / JNI ...)


[活对象 A] ──► [活对象 B] ──► [活对象 C]


[活对象 D]

[死对象 X] ──► [死对象 Y] ← 没有任何 root 走得到, 整片清掉

循环引用在这里自然不是问题, 因为只要外面没有 root 能走进这个环, 整个环都被判死;

📝 关键直觉
  • 引用计数: “谁在引用我” — 简单, 但被循环引用打死;
  • 可达性分析: “我能不能从根走过来” — 复杂一点, 但循环引用天然干净;
  • HotSpot JVM 全部走可达性分析;

GC 的第二个核心问题: 死对象腾出来的空间怎么用

知道哪些对象死了之后, 物理上要怎么把空间释放出来? 这里有三种经典算法, 你之后看任何 GC 收集器, 都是这三种的组合或变种;

算法 A: Mark-Sweep (标记-清除)

最朴素的做法:

  1. Mark: 跑一次可达性分析, 把活对象打上 “alive” 标签;
  2. Sweep: 扫整个 heap, 没打标签的当垃圾就地标为空闲, 加入一个"空闲块链表";
优点 缺点
实现简单 产生碎片, 空闲块零碎, 大对象分不出连续空间
不需要搬对象, 引用地址不变 分配新对象时还得维护空闲链表, 慢

算法 B: Mark-Compact (标记-压缩)

为了解决碎片问题:

  1. Mark: 同上;
  2. Compact: 把所有活对象搬到 heap 一端, 压实成一块连续区域, 后面整段都是空闲, 直接拿指针往后顺撸就能分配;
优点 缺点
没有碎片, 分配只要 bump 一个 pointer 搬对象要重写所有指向它们的引用, 慢
大对象分配天然友好 搬动期间需要保证没人在读旧地址 — STW 必要性

算法 C: Copying (复制)

针对"大部分对象死得快"这个观察的特化做法:

  1. 把空间分成两半 FromTo;
  2. 平时只在 From 里分配;
  3. GC 触发时, 把 From 里所有对象一次性复制到 To, 然后 From 整片清空;
  4. 角色互换, 下次 GC 把 ToFrom;
优点 缺点
只关心活对象, 死对象一个都不碰, 速度极快 永远只能用一半空间, 浪费
复制完天然连续, 没有碎片 活对象多的时候反而很亏

关键洞察: 如果一个区域 90% 都是死对象, copying 就只搬 10%, 极其便宜; 反过来如果 90% 都是活的, copying 就退化成"白搬一遍"; 所以 copying 适合死亡率高的区域 — 这就引出了下一节;

分代假设: GC 设计的最大杠杆

经验观察

工业界几十年的统计反复确认两条规律:

  1. 绝大多数对象出生没多久就死了 (临时变量, 中间结果, builder 对象 …);
  2. 活过一段时间的对象, 大概率会继续活很久 (单例, 缓存, 长生命周期容器 …);

这两条合起来叫 Generational Hypothesis (分代假设); 如果它是真的, 那把整个 heap 一视同仁地扫显然亏 — 应该把"刚出生"和"活很久"的对象放在不同区域, 用不同策略对待;

HotSpot heap 的分代布局

1
2
3
4
5
6
7
8
9
10
11
┌─────────────────────────────────────────────────────────────────┐
│ Java Heap │
├──────────────────────────┬─────────────────────┬─────────────────┤
│ Young Generation │ Old / Tenured │ Metaspace │
│ (新对象出生地) │ (老兵养老院) │ (Class 元数据) │
│ │ │ │
│ Eden + S0 + S1 │ 一整块连续区域 │ 不在 heap 里 │
│ 死亡率极高 │ 死亡率低 │ 几乎不变 │
│ → 用 Copying │ → 用 Mark-Compact │ 只在卸载类时清 │
│ 清扫频繁但很便宜 │ 清扫罕见但很贵 │ │
└──────────────────────────┴─────────────────────┴─────────────────┘
  • Young Generation 内部又分 Eden + Survivor 0 + Survivor 1 三块, 这是 copying 算法的工程化变种, 把两块 To/From 改成一块 Eden 主分配区 + 两块小的 Survivor 互倒; 这样能让"活了一两轮但还没那么老"的对象有缓冲区, 不会一活下来就被搬到 Old;
  • Old Generation 容纳那些"活过 N 次 Young GC"的老兵; 这里活对象比例高, copying 不划算, 改用 Mark-Compact;
  • Metaspace 装的是 class 元数据 (字段表/方法表/常量池这种), 不是用户对象, 大多数应用启动后基本就稳定了, 所以这里 GC 极少;

Young GC 与 Full GC

由此自然产生两种 GC:

名字 触发条件 扫描范围 速度
Young GC (Minor GC) Young 区分配不出新对象 只扫 Young 快, ms 级
Full GC (Major GC) Old 满了 / 显式 System.gc() / Metaspace 满 整个 heap 慢, 几十到几百 ms

线上调优最重要的一条铁律: Full GC 越少越好, 因为 Full GC 一次几百 ms 的 STW 直接打穿 P99 延迟;

📝 这一段的关键直觉
  • 分代假设是一个观测出来的经验规律, 不是数学定理;
  • 它把 GC 从"全 heap 一刀切"变成"小而快地扫年轻区, 偶尔慢一次扫整片", 这是数量级的优化;
  • Young 区死得多 → copying 只搬活的, 极便宜;
  • Old 区死得少 → mark-compact 整理碎片, 慢但不频繁;

STW: 为什么扫的时候必须停世界

到这里你可能会问: 为什么 GC 不能边跑边扫? 应用线程一边在改对象引用, GC 一边跟着扫不行吗?

不行, 至少朴素版本不行, 想象这个场景:

1
2
3
4
GC 扫到 A 时:        A.next = B   ← B 被标记为活
GC 还没扫到 B 时: 应用线程把 A.next 改成 C, B 不再被引用
GC 继续扫: B 仍然是 alive 标签 (但其实已经死了)
C 没被扫到 (但其实是活的) ← 极端情况下被回收, 应用 crash

引用图在 GC 中途被改, 标记结果就不一致了; 最干脆的解法就是短暂冻住所有应用线程, 让引用图静止, 这就是 STW;

STW 是一切朴素 GC 的根基, 所有现代低延迟 GC 都是在想办法缩短 STW, 而不是消灭它 — 通常少不了至少几次几毫秒级的 STW;

Safepoint: STW 的实现机制

JVM 不能粗暴地一个信号把线程钉死在任意指令上, 比如线程正卡在一行 native call 里, 你强行停了它内部数据结构可能不一致; JVM 的解法是 safepoint:

  • JIT 编译出来的机器码里, 每隔几条指令就插入一个 “safepoint poll” — 类似 “老大喊停了吗?” 的小检查;
  • JVM 想做 STW 时, 设一个全局 flag;
  • 每个线程跑到下一个 poll 时主动停下, 报告"我到了";
  • 所有线程都到 safepoint 之后, JVM 才正式开始干 GC 这种全局工作;

这就解释了为什么"Stopping threads took"这个指标这么重要: 它衡量的是把所有线程拉到 safepoint 集合点的时间, 跟 GC 干活快不快无关; 如果某个线程卡在 native 调用或者被 OS 调度饿了, 整个 STW 都会被它拖长, 别的线程白等;

主流 JVM GC 收集器的设计思路

下面这部分不展开实现细节, 只讲每一代收集器为什么这样设计, 看完你能用一句话总结每一种;

收集器 设计思路一句话总结 适用场景
Serial GC 单线程, 简单到极致, STW 期间一颗核扫整个 heap 小堆 (<1 GB), 嵌入式, 单核环境
Parallel GC 把 Serial 的扫描多线程化, 减短 STW, 但仍是 STW 看重吞吐, 不在意单次延迟
CMS (历史) 把 Old 区的 mark 阶段改成和应用并发跑, 牺牲 CPU 换更短 STW 已废弃, JDK 9 deprecated
G1 GC 把 heap 切成大量等大小的 region, 每次只挑"垃圾最多的几个" 部分增量回收 大堆 (4 GB+), 想要可控的 pause time
ZGC / Shenandoah 几乎所有阶段都和应用并发, 只剩亚毫秒级 STW, 通过着色指针 / 读屏障搞定一致性 TB 级堆, 极致低延迟

一些值得记住的设计转折点

  1. Serial → Parallel: 简单的并行化, 多核时代红利;
  2. Parallel → CMS: 第一次尝试"边扫边跑", 但 CMS 的 mark-sweep 不做 compact, 长期有碎片问题, 最终被 G1 取代;
  3. CMS → G1: 把"大块区域"改成"很多小 region", 引入 可达性集合 (RSet) 跟踪跨 region 引用, 让 GC 可以局部回收, 不再是"年轻代/老年代"二元;
  4. G1 → ZGC: 利用 64 位指针的高位插入颜色标记 (colored pointers), 配合读屏障让 GC 和应用并发跑得更彻底, STW 跌到亚毫秒;
📝 不必死记

不用记每个收集器的内部细节, 你只要记住一个趋势线: STW 占比一代比一代低, 代价是 GC 自己更复杂, 用更多 CPU 和更多元数据; 这是工程上的典型 tradeoff;

一个 GC 事件长什么样

JDK 11+ 的 unified log 格式, 一次 Young GC 大概长这样:

1
2
3
4
5
6
[18:00:59.569][gc,start    ] GC(0) Pause Young (Allocation Failure)
[18:00:59.589][gc,heap ] GC(0) DefNew: 104960K -> 7205K (118016K)
[18:00:59.589][gc,heap ] GC(0) Tenured: 0K -> 0K (262144K)
[18:00:59.589][gc,metaspace] GC(0) Metaspace:12278K -> 12278K (1060864K)
[18:00:59.589][gc ] GC(0) Pause Young (Allocation Failure) 102M -> 7M (371M) 20.116ms
[18:00:59.589][gc,cpu ] GC(0) User=0.01s Sys=0.01s Real=0.02s

逐行翻译:

  • GC(0) Pause Young (Allocation Failure): 第 0 次 GC, 类型是 Young, 触发原因是 “Eden 分配新对象失败”;
  • DefNew: 104960K -> 7205K (118016K): Young 区从 102.5 MB 降到 7 MB, 容量 118 MB; 这一次 GC 干掉了约 95 MB 的死对象 (典型的高 Young 死亡率);
  • Tenured: 0K -> 0K: Old 区没动, 因为这是首次 GC, 还没人晋升过去;
  • Metaspace: 12278K -> 12278K: 元数据区也没动;
  • ... 20.116ms: 应用被冻了 20 ms — 这就是这次 GC 对业务的真实代价;
  • User=0.01s Sys=0.01s Real=0.02s: User+Sys ≈ Real, 说明这次 GC 是单线程跑的 (Serial GC 的特征);

如果是 Parallel GC, 你会看到 User+Sys > Real, 因为多线程并行;

调优时该盯什么指标

绝大多数线上 GC 调优问题不是"算法不够好", 而是"参数没配对"; 常见的几个可观测信号:

指标 含义 红线建议
STW 累计占比 单位时间应用被冻多久 / 总时长 < 5%; 超过 10% 严重
单次 STW 最大值 max pause 看业务 SLA, 一般 < 200 ms
Young GC 频率 每秒几次 太频繁说明 Young 太小
Full GC 频率 每分钟几次 理想 0; 出现就要查 Old 是不是泄漏
晋升速率 单位时间从 Young 进 Old 的字节数 越低越好, 高了说明对象太早被升老

调优思路上有两个互相打架的目标 — 吞吐 (throughput) vs 延迟 (latency), 不可兼得:

  • 想要吞吐最大: 用 Parallel GC, 让 STW 集中爆发, GC 期间多核全速干, 总 GC 时间最短;
  • 想要延迟稳定: 用 G1 / ZGC, 接受 GC 用更多 CPU 和内存, 换 STW 极短;

业务方向决定选哪个, 比如:

  • 离线批处理 / 大数据计算 → 吞吐优先, Parallel GC;
  • 在线请求服务 / 交易系统 → 延迟优先, G1 起步, 严苛场景上 ZGC;
📝 论文主流观点总结

把整篇浓缩成几句, JVM GC 的设计思路可以总结为:

  1. 可达性分析代替引用计数, 从 GC Roots 出发遍历, 自然解决循环引用;
  2. 分代假设把 heap 分成 Young / Old, 不同区域用不同算法 (copying / mark-compact);
  3. STW 是不可避免的, 现代 GC 的全部工程努力都在缩短 STW, 不是消灭它;
  4. Safepoint 是 STW 的实际实现机制, 拉所有线程到 safepoint 的时间是独立信号, 跟 GC 算法快不快无关;
  5. 收集器演进趋势: Serial → Parallel → CMS → G1 → ZGC, 每一代都在用更复杂的内部结构换更短的 STW;
  6. 调优本质是在吞吐和延迟之间挑一个, 别想两个都要;