作者介绍
大家好,我是 CodeStats。
一个在底层技术上"考古"了四年的硬核爱好者,也是 WWAIC(全周项目AI编程) 范式的提出者和实践者。我曾手写过一个完整的 Java Web 框架(从 IoC 容器到嵌入式 Tomcat,代码全开源),也喜欢用通俗的语言拆解 CPU、JVM、操作系统的运行本质。
我的技术信条:所有高深的技术,最后都能用大白话讲清楚。如果讲不清楚,说明还没真正理解。
📖 本文你将获取到什么?
-
不是"八股文式"的概念罗列,而是从问题出发,层层递进的原理推导
-
为什么会有垃圾回收?它到底在解决什么问题?
-
并发回收这个"不可能三角"------如何在不暂停业务的情况下,安全地回收内存?
-
从 Serial 到 ZGC,每一代回收器为了解决什么问题而诞生 ,又付出了什么代价
-
读完本文,你将能用自己的话,向别人讲清楚 G1 和 ZGC 到底是怎么做到"并发"的
📑 目录
-
Java 内存结构是如何划分的?各区域存储什么内容?为什么这样划分?
-
垃圾回收算法的本质是什么?标记-清除、复制、标记-整理各自的优劣和适用边界在哪里?
-
引用计数为什么被主流 JVM 抛弃?可达性分析的 GC Roots 到底包含哪些?为什么必须是这些?
-
Serial 和 Parallel:STW 暂停回收的极致------它们的工作原理是什么?为什么堆大了就撑不住?
-
CMS 的并发标记:三色标记 + 增量更新是如何工作的?为什么最终还是要 STW?老年代为什么只能用标记-清除?
-
G1 的并发回收:Region 化布局 + SATB 写屏障 + TAMS 指针,如何实现可预测停顿?完整流程原理拆解
-
ZGC 的超低延迟:着色指针 + 读屏障 + 转发表,如何把 STW 压到 1ms 以内?完整流程原理拆解
-
各回收器优缺点与适用场景总结
-
JVM GC 参数配置与调优思路
1. Java 内存结构是如何划分的?各区域存储什么内容?为什么这样划分?
🎯 核心观点
JVM 内存划分的本质,是根据数据的"生命周期"和"共享范围"做分类管理。分类越精细,GC 就越精准。堆是 GC 的主战场,栈是 GC Roots 的核心来源。
📦 线程共享区域
堆(Heap)
存储所有类实例和数组。它是 GC 的"主战场"。之所以把堆设计为 GC 的核心区域,是因为对象的生命周期差异极大------有的对象存活几毫秒,有的存活几小时。GC 的核心任务,就是把"死"的对象占用的内存腾出来。
方法区(Method Area / Metaspace)
存储类信息、常量、静态变量、JIT 编译后的代码。JDK 8 之前叫"永久代",之后叫"元空间"(使用本地内存)。静态变量指向的对象,是 GC Roots 的重要来源------只要类还在,静态变量引用的对象就不会被回收。
🧵 线程私有区域
程序计数器:记录当前线程执行到哪一条字节码指令。这是 JVM 规范中唯一没有规定 OOM 的区域。
Java 虚拟机栈 :每个方法执行时创建栈帧,存储局部变量表、操作数栈等。局部变量表中的对象引用,是 GC Roots 中最活跃的一类------只要栈帧还在,方法内的局部对象就不会被回收。
本地方法栈:为 Native 方法服务,与 Java 栈类似。
💡 为什么这样划分?
| 划分原则 | 原因 |
|---|---|
| 线程共享 vs 私有 | 共享区域需要 GC 管理(堆、方法区);私有区域随线程销毁而释放(栈、PC) |
| 堆内分代(或分区) | 不同生命周期的对象用不同算法处理,提高效率 |
| 元空间独立 | 避免 PermGen 大小难调、OOM 频发的问题 |
2. 垃圾回收算法的本质是什么?标记-清除、复制、标记-整理各自的优劣和适用边界在哪里?
🎯 核心观点
所有 GC 算法都是在"内存利用率"、"内存碎片"、"移动开销"三个维度之间做取舍。没有完美的算法,只有适合的场景。
🗑️ 标记-清除(Mark-Sweep)
-
流程:标记所有垃圾 → 直接清除
-
优点:实现简单,不需要移动对象
-
缺点 :内存碎片化严重,导致大对象分配失败,提前触发 Full GC
-
适用:对象存活率低的场景(如 CMS 老年代)
📋 复制(Copying)
-
流程:内存对半分 → 只用一个半区 → 满了就把活对象复制到另一个半区 → 原半区全部清空
-
优点:无碎片,实现简单
-
缺点 :内存利用率只有 50%(HotSpot 优化为 Eden:Survivor = 8:1,利用率提高到 90%)
-
适用:对象存活率低的场景(新生代)
🧹 标记-整理(Mark-Compact)
-
流程 :标记所有存活对象 → 将它们向一端移动 → 清理边界以外的内存
-
优点:无碎片,内存利用率高
-
缺点 :移动和更新引用开销大,必须 STW
-
适用:对象存活率高的场景(老年代)
📊 算法对比
| 算法 | 碎片 | 内存利用率 | 移动开销 | 适用场景 |
|---|---|---|---|---|
| 标记-清除 | 有 | 高 | 无 | 存活率低 |
| 复制 | 无 | 低(50%~90%) | 有(复制) | 存活率极低(新生代) |
| 标记-整理 | 无 | 高 | 大(移动+改引用) | 存活率高(老年代) |
💡 分代收集的本质
新生代用复制(因为大部分对象死得快),老年代用标记-整理或标记-清除(因为存活率高,移动不划算)。这就是分代收集的底层逻辑------用不同的算法处理不同生命周期的对象。
3. 引用计数为什么被主流 JVM 抛弃?可达性分析的 GC Roots 到底包含哪些?为什么必须是这些?
🎯 核心观点
引用计数解决不了循环引用,这是它的"原罪"。可达性分析从 GC Roots 出发,只有从根上不可达的对象才是真正的垃圾。GC Roots 必须是"一定不会回收"的对象------它们是判断对象是否存活的"锚点"。
🚫 引用计数的致命缺陷
java
class Node { Node next; }
Node a = new Node();
Node b = new Node();
a.next = b;
b.next = a; // 循环引用!
a = null;
b = null; // 现在 a 和 b 都无法被外部访问,但它们的引用计数器都是 1(互相指向)
// 引用计数算法永远不会回收它们——内存泄漏!
✅ 可达性分析的 GC Roots
GC Roots 是直接从 JVM 外部可达的引用,包括:
| GC Roots 类型 | 示例 | 为什么是根? |
|---|---|---|
| 栈帧中的局部变量 | 方法内的 Object obj = ... |
当前正在执行的方法一定需要它 |
| 静态变量 | static Object obj |
类还在,静态变量就在 |
| JNI 引用 | Native 方法中的 jobject |
JNI 层持有的对象,Java 层不能动 |
| 常量池引用 | final 常量 |
常量不可变,永远需要 |
| 同步锁持有的对象 | synchronized(obj) 中的 obj |
锁释放前,对象必须存活 |
| 活动线程 | Thread 对象本身 | 线程在运行,线程本身不能回收 |
4. Serial 和 Parallel:STW 暂停回收的极致------它们的工作原理是什么?为什么堆大了就撑不住?
🎯 核心观点
Serial 是单线程 STW,Parallel 是多线程 STW。它们的共同点是"回收时必须暂停所有业务线程"。堆越大,存活对象越多,复制/整理的时间越长,STW 就越长。这就是它们无法用于大堆的根本原因。
💻 Serial 收集器
-
新生代:复制算法(Eden → Survivor),单线程
-
老年代:标记-整理算法,单线程
-
STW 时间:与堆大小成正比,堆越大暂停越长
-
适用:单核 CPU、小堆(< 几百 MB)
🔄 Parallel 收集器(JDK 8 默认)
-
新生代(Parallel Scavenge) :复制算法,多线程并行
-
老年代(Parallel Old) :标记-整理算法,多线程并行
-
核心目标 :吞吐量最大化 (
-XX:GCTimeRatio控制) -
STW 时间:比 Serial 短,但仍随堆增大而显著增加
-
适用:多核 CPU、吞吐量优先、能接受秒级停顿的后台任务
⚠️ 为什么堆大了就不行?
STW 暂停的时间 ≈ (存活对象总量 / GC 线程吞吐量)。堆越大,存活对象越多,复制/整理的时间就越长。当堆超过 4GB,STW 可能达到数秒甚至数十秒------这对在线服务是灾难性的。
5. CMS 的并发标记:三色标记 + 增量更新是如何工作的?为什么最终还是要 STW?老年代为什么只能用标记-清除?
🎯 核心观点
CMS 的核心突破是把"标记"阶段拆成了"STW 初始标记 + 并发标记 + STW 最终标记",让最耗时的并发标记与业务线程同时运行。三色标记 + 增量更新解决了并发标记期间的"漏标"问题,但最终标记仍需 STW 来"收尾"。而为了并发清除,CMS 被迫选择了产生碎片的标记-清除算法。
🎨 三色标记(Tri-color Marking)
| 颜色 | 含义 |
|---|---|
| 白色 | 尚未被标记(本轮 GC 的"待定垃圾") |
| 灰色 | 已被标记,但引用的对象尚未全部扫描完("待办队列") |
| 黑色 | 已被标记,且引用的对象全部扫描完("已完成") |
⚠️ 并发标记的"漏标"问题
并发标记时,业务线程在跑。如果发生以下情况:
-
一个黑色对象 (已扫描完)新增了一个指向白色对象(未标记)的引用
-
所有指向该白色对象的灰色对象删除了引用
那这个白色对象就永远无法被标记------它被漏标了。
🛡️ CMS 的解法:增量更新(Incremental Update)
CMS 用写屏障(Write Barrier) 拦截"黑色→白色"的新增引用,把黑色对象 记录下来。在最终标记(Remark - STW) 阶段,重新扫描这些黑色对象,把漏标的白色对象救回来。
📋 CMS 完整流程
| 阶段 | 是否 STW | 做什么 |
|---|---|---|
| 初始标记 | ✅ STW(短) | 标记 GC Roots 直接可达的对象 |
| 并发标记 | ❌ 并发 | 遍历对象图,三色标记;写屏障记录"黑→白" |
| 并发预清理 | ❌ 并发 | 提前处理部分写屏障记录,减少 Remark 压力 |
| 最终标记 | ✅ STW(较长) | 处理所有写屏障记录,完成标记 |
| 并发清除 | ❌ 并发 | 标记-清除,回收垃圾内存 |
| 并发重置 | ❌ 并发 | 重置内部状态 |
🤔 为什么老年代只能用标记-清除?
CMS 要"并发清除",就不能移动对象(移动必须暂停业务线程改引用)。所以它只能选择不移动对象的标记-清除。代价是内存碎片。为了解决碎片,CMS 提供了 Full GC 时的压缩选项(
-XX:+UseCMSCompactAtFullCollection),但那是 STW 的。
6. G1 的并发回收:Region 化布局 + SATB 写屏障 + TAMS 指针,如何实现可预测停顿?完整流程原理拆解
🎯 核心观点
G1 的核心创新是把堆拆成多个 Region,每次只回收"垃圾最多"的 Region,把全堆回收的总 STW 时间打散成多次可控的小停顿。SATB 写屏障 + TAMS 指针解决了并发标记的安全性问题。可预测停顿的本质是"每次只干一部分活,绝不一次干完"。
🗺️ Region 化布局
G1 不再将堆物理划分为新生代/老年代,而是划分成多个大小相等的 Region(区域)。每个 Region 逻辑上可以扮演 Eden、Survivor 或老年代的角色。
TAMS(Top at Mark Start)指针 :每个 Region 的 nextTAMS 指针在初始标记时被设置为当前 top(已分配内存的顶)。并发标记只处理 TAMS 以下的"旧对象",TAMS 以上的新对象默认存活------这样就避免了并发标记和业务分配之间的锁竞争。
🛡️ SATB(Snapshot-At-The-Beginning)写屏障
G1 用**写前屏障(Pre-Write Barrier)**记录"被删除的旧引用"。当一个灰色对象删除指向白色对象的引用时,这个白色对象被记录到 SATB 队列中。最终标记时处理 SATB 队列,确保所有在"快照"中存活的对象都被标记。
SATB 破坏了"漏标"的必要条件之一:灰色对象删除白色引用。 因为删除时已经把白色对象"存档"了。
📋 G1 完整流程
| 阶段 | 是否 STW | 核心动作 | 为什么必须(或可以)并发? |
|---|---|---|---|
| 初始标记 | ✅ STW | 标记 GC Roots,设置 TAMS | 必须冻结快照,否则 TAMS 边界会变 |
| 并发标记 | ❌ 并发 | 遍历对象图,三色标记;SATB 记录 | 最耗时的操作,必须并发 |
| 最终标记 | ✅ STW(可控) | 处理 SATB 队列 | 队列有限,停顿可控 |
| 清理 | ✅ STW | 交换位图,统计存活率,排序 Region | 决策阶段,必须一致 |
| 混合收集 | ✅ STW(多次,每次可控) | 复制选中的 Region 中的存活对象 | 复制时必须 STW(G1 没有读屏障) |
🎯 "可预测停顿"的底层逻辑
G1 通过"每次只回收一部分 Region"来实现停顿可控。它根据用户设置的
MaxGCPauseMillis,动态计算本次 Mixed GC 回收多少个 Region。这种"切碎"策略,把全堆回收的 STW 从"一次性数秒"变成了"多次数十毫秒"。代价是总回收时间变长,吞吐量略降。
7. ZGC 的超低延迟:着色指针 + 读屏障 + 转发表,如何把 STW 压到 1ms 以内?完整流程原理拆解
🎯 核心观点
ZGC 把 GC 状态存进指针(着色指针),用读屏障在业务线程读对象时即时修正指针(自愈),用转发表把"改全堆指针"的巨量工作拆碎。核心思路是"把一切耗时的操作都搬到后台并发执行,业务线程只做纳秒级的检查"------这是它对"并发回收"的终极回答。
🎨 着色指针(Colored Pointers)
ZGC 在 64 位指针的高位中分配了 4 个元数据位:
text
[ 高18位未使用 | Finalizable | Remapped | Marked1 | Marked0 | 42位对象地址 ]
-
Marked0 / Marked1 :标记位,轮流使用(本轮用 M0,下轮用 M1),永远不用清空位图
-
Remapped:1 表示指向最新地址,0 表示指向旧地址(需要查转发表修正)
-
标记/修正对象,只需要修改指针的几位,不需要访问对象头,速度极快且天然原子
🛡️ 读屏障(Load Barrier)+ 自愈(Self-Healing)
G1 用写屏障拦截"赋值",ZGC 用读屏障拦截"读取"。 因为"写"的前提是"读"------业务线程要先读到一个对象,才能把它赋值给另一个字段。
读屏障的逻辑(伪代码):
c
if (指针.Remapped == 0) {
// 指向旧地址,查转发表
new_addr = ForwardTable[指针.地址];
// 修正当前指针(自愈)
指针.地址 = new_addr;
指针.Remapped = 1;
}
return 指针;
"自愈"的本质:业务线程每次读到一个旧地址,读屏障就帮它修正为新地址。没人读的旧指针,不急着修,留到下一轮 GC。
📋 ZGC 完整流程
| 阶段 | 是否 STW | 核心动作 | 为什么必须(或可以)并发? |
|---|---|---|---|
| 初始标记 | ✅ STW(<0.1ms) | 标记 GC Roots,设置指针颜色 | Roots 必须冻结,但 Roots 数量有限 |
| 并发标记 | ❌ 并发 | 遍历对象图;读屏障即时标活 | 最耗时操作,必须并发 |
| 最终标记 | ✅ STW(<0.5ms) | 处理弱引用等残留 | 残留工作极少 |
| 并发预备重分配 | ❌ 并发 | 计算垃圾最多的 ZPage | 只是算账,不动内存 |
| 初始转移 | ✅ STW(<0.1ms) | 准备根的转移引用 | 根引用必须瞬间一致 |
| 并发重分配 | ❌ 并发(最耗时) | 复制存活对象;建立转发表;读屏障自愈;空间立即回收;整页 CAS 释放 | 复制和空间回收都不动业务内存,完全并发 |
| 并发重映射 | ❌ 并发(实际合并到下轮标记) | 修正残留旧指针 | 不着急,可合并 |
🗑️ 并发回收的安全保障(重点)
ZGC 在"并发重分配"阶段,用两套机制保证安全:
-
空间回收(立即生效) :存活对象被复制走后,原 ZPage 里它们占用的空间立即被插入空闲链表。这些空间在纳秒级后就可被新对象使用。本轮 GC 已经回收了绝大多数垃圾空间。
-
整页释放(CAS 原子裁决) :只有当一个 ZPage 的存活对象计数为 0 时,GC 才尝试用 CAS 原子操作将其物理内存归还。如果在 CAS 的瞬间有业务线程复活了对象,CAS 失败,释放取消。"检查"和"释放"是一个 CPU 指令,没有时间窗口。
-
转发表保护:只要转发表里有记录,对应的旧 ZPage 物理内存就绝不归还。业务线程拿着旧地址进来,读屏障能通过转发表找到新地址。
ZGC 的"并发"不是无锁魔法,而是把"全局 STW 大锁"拆成了"ZPage 级的 CAS 小锁",配合着色指针和读屏障,让业务线程在绝大多数情况下只是多执行几条位运算指令。
8. 各回收器优缺点与适用场景总结
| 回收器 | 核心目标 | 核心机制 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|---|
| Serial | 单核吞吐 | 单线程 STW | 简单高效 | 堆大则暂停长 | 小堆、单核、开发测试 |
| Parallel | 吞吐量 | 多线程 STW | 吞吐量高 | 堆大暂停增长 | 批处理、离线计算 |
| CMS | 低延迟 | 并发标记 + 增量更新 | 停顿较短 | 碎片、Remark 不定、CPU 敏感 | 中等堆(<4GB),延迟敏感 |
| G1 | 可预测停顿 | Region + SATB + 混合收集 | 停顿可控、无碎片 | 调优复杂,吞吐略降 | 大堆(>4GB),需可预测停顿 |
| ZGC | 超低延迟 | 着色指针 + 读屏障 + 并发重分配 | STW <1ms,支持 TB 堆 | 吞吐量下降 5-10% | 超大堆(>16GB),极低延迟 |
| Shenandoah | 超低延迟 | 并发移动 + 读屏障 | 与 ZGC 类似 | 吞吐量开销 | 低延迟场景 |
9. JVM GC 参数配置与调优思路
🎯 核心观点
GC 调优的本质是"在吞吐量和延迟之间做取舍"。先明确目标,再监控数据,最后针对性调整。任何调优都必须基于 GC 日志分析,不能拍脑袋。
⚙️ 通用启动参数(JDK 17+,G1 示例)
bash
-Xms4G -Xmx4G # 堆大小固定
-XX:+UseG1GC # 使用 G1
-XX:MaxGCPauseMillis=200 # 目标停顿时间
-XX:InitiatingHeapOccupancyPercent=45 # 触发并发标记阈值
# GC 日志(必须)
-Xloggc:/var/log/app-gc.log
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=20M
🎯 调优三步走
-
明确目标:吞吐量优先?延迟优先?设定量化指标(如"GC 总停顿 < 50ms/天")
-
观察基线 :不加调优参数运行,用
jstat -gc <pid> 1s、GC 日志分析工具(GCViewer、GCEasy)观察 -
迭代调整:
-
堆太小 → 频繁 GC;堆太大 → Full GC 时间长
-
MaxGCPauseMillis设太小 → 频繁 Mixed GC,吞吐量暴跌 -
元空间(Metaspace)也要监控,防止无限增长
-
🚨 常见陷阱
-
G1 的
MaxGCPauseMillis设得过低:G1 会频繁做小批量回收,反而拖慢整体吞吐量。从 200ms 开始,实测调整。 -
忽视 GC 日志:不分析日志就调参,无异于蒙眼开车。
-
堆太大(>32GB)仍用 G1:建议评估 ZGC,G1 在超大堆上停顿可能突破 100ms。
💎 总结:一个完整的思想链条
我们把整个 GC 机制串起来看:
-
为什么需要 GC:堆内存有限,程序不断创建对象,必须回收"死"对象的内存。
-
怎么判断对象是死是活:从 GC Roots 出发做可达性分析------从根上不可达,就是垃圾。
-
单线程 STW 回收:Serial / Parallel,简单但堆大了就扛不住。
-
并发标记:CMS 用三色标记 + 增量更新,让标记阶段大部分并发。但最终还是逃不掉 STW 的 Remark 和碎片问题。
-
可预测停顿:G1 把堆切分成 Region,每次只收一部分,用 SATB 保证并发标记安全。把"一次性大暂停"拆成"多次小暂停"。
-
超低延迟:ZGC 把 GC 状态塞进指针,用读屏障让业务线程"顺手"修正引用,用转发表把修指针的巨量工作打碎。把"一切能并发的都并发",只留下 3 个 <1ms 的 STW 点。
每一次 GC 技术的进化,本质上都是在回答同一个问题:怎么让"找垃圾"和"清垃圾"这两件事,对正在跑的业务线程造成最小的干扰?答案从"完全暂停"到"尽可能并发",再到"几乎完全并发",每一步都是对并发安全的更精细控制。
如果本文让你对 JVM GC 有了真正"底层级"的理解,欢迎点赞、收藏、关注!
有疑问或想深入探讨某个细节,评论区见。😊