1. JVM GC 工作思路
- 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 本质上要回答两个问题:
- 怎么判断一个对象"已经死了";
- 判断完之后, 怎么把死对象腾出来的空间高效利用;
下面整篇笔记基本上就是在围绕这两个问题展开;
GC 的第一个核心问题: 怎么判断对象死了
想法 1: 引用计数 (Reference Counting)
最直觉的做法: 每个对象身上挂一个计数器, 有人引用 +1, 引用没了 -1, 计数为 0 就回收;
听起来很美但有个致命问题 — 循环引用:
1 | A.next = B |
A 和 B 互相引用, 谁都不会归零, 但其实外面没有任何变量指向它们, 这两个对象是"死了但永远清不掉"; 这种地雷在像链表/图这种数据结构里到处都是;
所以主流 JVM (HotSpot) 没有用引用计数, 用的是下面这个;
想法 2: 可达性分析 (Reachability Analysis)
换个思路: 不去问"谁在引用这个对象", 而是问"从一组一定还活着的根出发, 我能不能走到这个对象"; 走得到 = 活, 走不到 = 死;
这组"一定还活着的根"叫做 GC Roots, 直觉上包含:
- 当前所有线程栈上的局部变量 (栈帧里的引用);
- 所有静态字段 (
static修饰的, 类生命周期级别); - JNI 引用 (native 代码持有的 Java 对象);
- 一些 JVM 内部结构;
GC 干的事情就是: 从 GC Roots 开始做一次图遍历 (BFS / DFS 都行), 标记所有能达到的对象为"活"; 剩下没标记的就是死的, 可以清;
1 | GC Roots (栈 / static / JNI ...) |
循环引用在这里自然不是问题, 因为只要外面没有 root 能走进这个环, 整个环都被判死;
- 引用计数: “谁在引用我” — 简单, 但被循环引用打死;
- 可达性分析: “我能不能从根走过来” — 复杂一点, 但循环引用天然干净;
- HotSpot JVM 全部走可达性分析;
GC 的第二个核心问题: 死对象腾出来的空间怎么用
知道哪些对象死了之后, 物理上要怎么把空间释放出来? 这里有三种经典算法, 你之后看任何 GC 收集器, 都是这三种的组合或变种;
算法 A: Mark-Sweep (标记-清除)
最朴素的做法:
- Mark: 跑一次可达性分析, 把活对象打上 “alive” 标签;
- Sweep: 扫整个 heap, 没打标签的当垃圾就地标为空闲, 加入一个"空闲块链表";
| 优点 | 缺点 |
|---|---|
| 实现简单 | 产生碎片, 空闲块零碎, 大对象分不出连续空间 |
| 不需要搬对象, 引用地址不变 | 分配新对象时还得维护空闲链表, 慢 |
算法 B: Mark-Compact (标记-压缩)
为了解决碎片问题:
- Mark: 同上;
- Compact: 把所有活对象搬到 heap 一端, 压实成一块连续区域, 后面整段都是空闲, 直接拿指针往后顺撸就能分配;
| 优点 | 缺点 |
|---|---|
| 没有碎片, 分配只要 bump 一个 pointer | 搬对象要重写所有指向它们的引用, 慢 |
| 大对象分配天然友好 | 搬动期间需要保证没人在读旧地址 — STW 必要性 |
算法 C: Copying (复制)
针对"大部分对象死得快"这个观察的特化做法:
- 把空间分成两半
From和To; - 平时只在
From里分配; - GC 触发时, 把
From里所有活对象一次性复制到To, 然后From整片清空; - 角色互换, 下次 GC 把
To当From;
| 优点 | 缺点 |
|---|---|
| 只关心活对象, 死对象一个都不碰, 速度极快 | 永远只能用一半空间, 浪费 |
| 复制完天然连续, 没有碎片 | 活对象多的时候反而很亏 |
关键洞察: 如果一个区域 90% 都是死对象, copying 就只搬 10%, 极其便宜; 反过来如果 90% 都是活的, copying 就退化成"白搬一遍"; 所以 copying 适合死亡率高的区域 — 这就引出了下一节;
分代假设: GC 设计的最大杠杆
经验观察
工业界几十年的统计反复确认两条规律:
- 绝大多数对象出生没多久就死了 (临时变量, 中间结果, builder 对象 …);
- 活过一段时间的对象, 大概率会继续活很久 (单例, 缓存, 长生命周期容器 …);
这两条合起来叫 Generational Hypothesis (分代假设); 如果它是真的, 那把整个 heap 一视同仁地扫显然亏 — 应该把"刚出生"和"活很久"的对象放在不同区域, 用不同策略对待;
HotSpot heap 的分代布局
1 | ┌─────────────────────────────────────────────────────────────────┐ |
- 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 | GC 扫到 A 时: A.next = B ← B 被标记为活 |
引用图在 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 级堆, 极致低延迟 |
一些值得记住的设计转折点
- Serial → Parallel: 简单的并行化, 多核时代红利;
- Parallel → CMS: 第一次尝试"边扫边跑", 但 CMS 的 mark-sweep 不做 compact, 长期有碎片问题, 最终被 G1 取代;
- CMS → G1: 把"大块区域"改成"很多小 region", 引入 可达性集合 (RSet) 跟踪跨 region 引用, 让 GC 可以局部回收, 不再是"年轻代/老年代"二元;
- G1 → ZGC: 利用 64 位指针的高位插入颜色标记 (colored pointers), 配合读屏障让 GC 和应用并发跑得更彻底, STW 跌到亚毫秒;
不用记每个收集器的内部细节, 你只要记住一个趋势线: STW 占比一代比一代低, 代价是 GC 自己更复杂, 用更多 CPU 和更多元数据; 这是工程上的典型 tradeoff;
一个 GC 事件长什么样
JDK 11+ 的 unified log 格式, 一次 Young GC 大概长这样:
1 | [18:00:59.569][gc,start ] GC(0) Pause Young (Allocation Failure) |
逐行翻译:
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 的设计思路可以总结为:
- 用可达性分析代替引用计数, 从 GC Roots 出发遍历, 自然解决循环引用;
- 用分代假设把 heap 分成 Young / Old, 不同区域用不同算法 (copying / mark-compact);
- STW 是不可避免的, 现代 GC 的全部工程努力都在缩短 STW, 不是消灭它;
- Safepoint 是 STW 的实际实现机制, 拉所有线程到 safepoint 的时间是独立信号, 跟 GC 算法快不快无关;
- 收集器演进趋势: Serial → Parallel → CMS → G1 → ZGC, 每一代都在用更复杂的内部结构换更短的 STW;
- 调优本质是在吞吐和延迟之间挑一个, 别想两个都要;
