垃圾回收(Garbage Collection)是 JVM 最核心的运行时机制,也是 Java 性能调优的关键知识点。很多人用了多年 Java,却对 GC 的工作原理一知半解——知道有 Minor GC 和 Full GC,知道要避免频繁 Full GC,但说不清楚 G1 和 CMS 有什么本质区别,也不知道为什么 GC 会让应用暂停。
本文从最基础的内存结构开始,用大量图示一步步拆解 GC 的每个环节,力求让每个概念都有直觉上的理解。
一、JVM 堆内存结构
GC 管理的核心区域是堆(Heap)。JVM 启动时会申请一块连续内存作为堆,所有 new 出来的对象都在这里分配。
graph LR
subgraph Young[新生代 Young Gen]
Eden[Eden 区 ~80%]
S0[S0 From ~10%]
S1[S1 To ~10%]
end
subgraph Old[老年代 Old Gen]
OldObj[长期存活对象 / 大对象]
end
subgraph NonHeap[非堆 不由GC管理]
Meta[Metaspace 类定义/常量]
Stack[虚拟机栈/PC寄存器]
end
Eden -->|Minor GC 复制| S1
S0 -->|Minor GC 年龄+1| S1
S1 -->|年龄达阈值/大对象| OldObj
新生代分三个区:
- Eden 区:新对象出生的地方,占新生代约 80%
- Survivor 0(From):上一次 Minor GC 后存活对象的暂居地
- Survivor 1(To):本次 Minor GC 时存活对象的目标地
这个设计来自一个重要的经验观察——分代假说。
二、分代假说:GC 设计的理论基础
JVM 内存分代设计基于两个经验规律:
弱分代假说:绝大多数对象都是"朝生夕死"的,极短命。
强分代假说:熬过多次 GC 的对象,未来也不太可能死。
用数据说话:实际 Java 程序中,一次 Minor GC 通常能回收 95%+ 的新生代对象。也就是说,大多数对象在 Eden 区被创建,在第一次 GC 时就死亡了,根本没有机会进入 Survivor 区。
对象存活率随年龄的变化(示意):
存活率
100% │
│ ███
50% │ ████
│ ████
10% │ ████████
5% │ █████████████████(趋于稳定)
0% └─────────────────────────────────────────────────→ GC 轮次
1次 2次 3次 4次 5次 6次 7次 8次 ...
绝大多数对象在第 1 次 GC 就被回收
少数"长命"对象在多次 GC 后趋于稳定存活
这个规律决定了:
- 新生代可以用复制算法——只有少数对象需要被复制,效率极高
- 老年代对象密度高,不适合复制,用标记-整理或标记-清除
- 新生代 GC(Minor GC)可以频繁运行,开销小;老年代 GC(Full GC)代价大,要尽量避免
三、Minor GC:新生代的清理过程
当 Eden 区满了,就触发 Minor GC。整个过程分四步:
第一步:Eden 区满,触发 GC
第二步:标记存活对象(可达性分析)
graph TD
GCRoot1["GC Root (线程栈局部变量)"]
GCRoot2["GC Root (静态变量 / JNI)"]
GCRoot1 -->|引用| A["对象 A ✓ 存活"]
GCRoot1 -->|引用| F["对象 F ✓ 存活"]
GCRoot2 -->|引用| H["对象 H ✓ 存活"]
GCRoot2 -->|引用| X["对象 X ✓ 存活"]
A -->|引用| C["对象 C ✓ 存活"]
X -->|引用| Y["对象 Y ✓ 存活"]
B["对象 B ✗ 不可达 = 垃圾"]
D["对象 D ✗ 不可达 = 垃圾"]
E["对象 E ✗ 不可达 = 垃圾"]
G["对象 G ✗ 不可达 = 垃圾"]
style A fill:#d4edda,stroke:#28a745
style C fill:#d4edda,stroke:#28a745
style F fill:#d4edda,stroke:#28a745
style H fill:#d4edda,stroke:#28a745
style X fill:#d4edda,stroke:#28a745
style Y fill:#d4edda,stroke:#28a745
style B fill:#f8d7da,stroke:#dc3545
style D fill:#f8d7da,stroke:#dc3545
style E fill:#f8d7da,stroke:#dc3545
style G fill:#f8d7da,stroke:#dc3545
第三步:复制存活对象到 S1(To 区),年龄 +1
Eden 中的存活对象(A C F H)复制到 S1,年龄 0→1;S0 中的存活对象(X Y)复制到 S1,年龄 1→2;Eden 和 S0 整体清空,无需逐个释放。
第四步:S0/S1 角色互换
何时晋升老年代?
两种情况会让对象从新生代晋升到老年代:
1. 年龄阈值(默认 15):
对象每熬过一次 Minor GC,年龄 +1
年龄达到 MaxTenuringThreshold(默认 15)时,晋升老年代
年龄: 1 2 3 ... 15 → 进入老年代
↑ ↑ ↑ ↑
GC1 GC2 GC3 GC15
2. 大对象直接进老年代:
超过 -XX:PretenureSizeThreshold 的对象直接分配在老年代
避免大对象在 Eden/Survivor 区来回复制的巨大开销
四、Full GC:最昂贵的操作
Full GC 会回收整个堆(新生代 + 老年代),期间会触发 Stop-The-World(STW)——暂停所有用户线程,直到 GC 完成。
gantt
title Stop-The-World 示意(Full GC)
dateFormat X
axisFormat %s ms
section 用户线程
正常运行 :active, u1, 0, 40
暂停(STW 期间) :crit, stw, 40, 80
恢复运行 :active, u2, 80, 120
section GC 线程
等待 :done, g0, 0, 40
Full GC 执行 :active, gc1, 40, 80
空闲 :done, g1, 80, 120
STW 期间所有用户请求无响应! Full GC 的停顿时间从几百毫秒到几秒不等,这就是为什么 Full GC 对线上服务是灾难性的。
触发 Full GC 的常见原因:
1. 老年代空间不足
┌────────────────────────────────────┐
│ 老年代(95% 已满) │ ← 新对象晋升时放不下
│ ████████████████████████████████ │ → 触发 Full GC
└────────────────────────────────────┘
2. Minor GC 晋升失败(Promotion Failure)
老年代剩余空间 < 新生代存活对象总大小
JVM 无法保证晋升成功 → 触发 Full GC
3. 显式调用 System.gc()(强烈不建议)
4. Metaspace 空间不足(类加载过多)
5. CMS GC 的并发失败(Concurrent Mode Failure)
五、可达性分析:判断对象是否存活
GC 如何知道哪些对象可以回收?答案是可达性分析(Reachability Analysis)。
graph TD
GCRoots([GC Roots])
GCRoots -->|引用| A[对象 A 存活]
GCRoots -->|引用| B[对象 B 存活]
A -->|引用| C[对象 C 存活]
A -->|引用| D[对象 D 存活]
C -->|引用| E[对象 E 存活]
F[对象 F 不可达]
G[对象 G 不可达]
F -->|循环引用| G
G -->|循环引用| F
style GCRoots fill:#d1ecf1,stroke:#17a2b8
style A fill:#d4edda,stroke:#28a745
style B fill:#d4edda,stroke:#28a745
style C fill:#d4edda,stroke:#28a745
style D fill:#d4edda,stroke:#28a745
style E fill:#d4edda,stroke:#28a745
style F fill:#f8d7da,stroke:#dc3545
style G fill:#f8d7da,stroke:#dc3545
GC Roots 包含哪些?
graph TD
GCRoots([GC Roots])
R1[① 虚拟机栈局部变量引用 方法内的局部对象]
R2[② 方法区静态变量引用 static Object shared]
R3[③ 方法区常量引用 static final 常量]
R4[④ JNI 本地方法持有的引用]
GCRoots --> R1
GCRoots --> R2
GCRoots --> R3
GCRoots --> R4
style GCRoots fill:#d1ecf1,stroke:#17a2b8
style R1 fill:#f8f9fa,stroke:#6c757d
style R2 fill:#f8f9fa,stroke:#6c757d
style R3 fill:#f8f9fa,stroke:#6c757d
style R4 fill:#f8f9fa,stroke:#6c757d
六、四种引用类型:影响 GC 行为的关键
强度递减 →
强引用(Strong Reference) 弱引用(Weak Reference)
Object obj = new Object(); WeakReference<Object> wr = ...
↓ 只要存在,永远不会被 GC 回收 ↓ 下次 GC 时必然被回收,不管内存是否充足
软引用(Soft Reference) 虚引用(Phantom Reference)
SoftReference<Object> sr = ... PhantomReference<Object> pr = ...
↓ 内存充足时保留,内存不足时回收 ↓ 几乎等于没有引用,专门用于追踪 GC 事件
(适合实现内存敏感的缓存)
// 软引用:实现 LRU 缓存的经典方案
Map<String, SoftReference<BigImage>> imageCache = new HashMap<>();
// 存入缓存
imageCache.put("logo", new SoftReference<>(loadImage("logo.png")));
// 取出缓存
SoftReference<BigImage> ref = imageCache.get("logo");
BigImage img = ref != null ? ref.get() : null; // 可能为 null(已被 GC 回收)
if (img == null) {
img = loadImage("logo.png"); // 重新加载
imageCache.put("logo", new SoftReference<>(img));
}
// 优点:内存充足时命中缓存(快),内存紧张时 GC 自动清理(不 OOM)
七、三种基础 GC 算法
标记-清除(Mark-Sweep)
graph LR
subgraph Before[GC 前:内存布局]
direction LR
b1["A 活"] --- b2["B 死"] --- b3["C 活"] --- b4["D 死"] --- b5["E 死"] --- b6["F 活"] --- b7["G 死"] --- b8["H 活"] --- b9["I 死"] --- b10["J 活"]
end
Before -->|"① 标记存活对象 ② 清除未标记对象"| After
subgraph After[GC 后:产生内存碎片]
direction LR
a1["A ✓"] --- a2["空洞"] --- a3["C ✓"] --- a4["空洞"] --- a5["空洞"] --- a6["F ✓"] --- a7["空洞"] --- a8["H ✓"] --- a9["空洞"] --- a10["J ✓"]
end
warn["⚠ 问题:大量不连续小空洞 分配大对象时找不到足够连续空间"]
style b2 fill:#f8d7da,stroke:#dc3545
style b4 fill:#f8d7da,stroke:#dc3545
style b5 fill:#f8d7da,stroke:#dc3545
style b7 fill:#f8d7da,stroke:#dc3545
style b9 fill:#f8d7da,stroke:#dc3545
style a1 fill:#d4edda,stroke:#28a745
style a3 fill:#d4edda,stroke:#28a745
style a6 fill:#d4edda,stroke:#28a745
style a8 fill:#d4edda,stroke:#28a745
style a10 fill:#d4edda,stroke:#28a745
style a2 fill:#e2e3e5,stroke:#6c757d
style a4 fill:#e2e3e5,stroke:#6c757d
style a5 fill:#e2e3e5,stroke:#6c757d
style a7 fill:#e2e3e5,stroke:#6c757d
style a9 fill:#e2e3e5,stroke:#6c757d
style warn fill:#fff3cd,stroke:#ffc107
标记-复制(Mark-Copy)
graph LR
subgraph GCBefore[GC 前]
direction LR
subgraph From[From 半区(有对象)]
f1["A 活"] --- f2["B 死"] --- f3["C 活"] --- f4["D 死"] --- f5["E 活"] --- f6["F 死"] --- f7["G 活"]
end
subgraph To0[To 半区(空)]
t0["(空)"]
end
end
GCBefore -->|"复制存活对象到 To From 整体清空"| GCAfter
subgraph GCAfter[GC 后]
direction LR
subgraph From2[From 半区(清空)]
f8["(空)"]
end
subgraph To2[To 半区(紧凑排列,无碎片)]
t1["A ✓"] --- t2["C ✓"] --- t3["E ✓"] --- t4["G ✓"]
end
end
note["优点:无碎片,分配极快(移动指针) 缺点:内存利用率仅 50% 新生代优化:Eden 80% + S0 10% → S1 10% 实际利用率 90%,只浪费 10%"]
style f2 fill:#f8d7da,stroke:#dc3545
style f4 fill:#f8d7da,stroke:#dc3545
style f6 fill:#f8d7da,stroke:#dc3545
style t1 fill:#d4edda,stroke:#28a745
style t2 fill:#d4edda,stroke:#28a745
style t3 fill:#d4edda,stroke:#28a745
style t4 fill:#d4edda,stroke:#28a745
style note fill:#d1ecf1,stroke:#17a2b8
标记-整理(Mark-Compact)
graph LR
subgraph Before[GC 前:存活与死亡对象交错]
direction LR
b1["A 活"] --- b2["B 死"] --- b3["C 活"] --- b4["D 死"] --- b5["E 死"] --- b6["F 活"] --- b7["G 死"] --- b8["H 活"]
end
Before -->|"① 标记存活对象 ② 向一端移动整理"| After
subgraph After[GC 后:存活对象紧凑排列 + 连续空闲空间]
direction LR
a1["A ✓"] --- a2["C ✓"] --- a3["F ✓"] --- a4["H ✓"] --- a5["空闲(连续大块)"]
end
note["优点:无碎片,内存利用率 100% 缺点:移动对象需更新所有引用指针,开销大 适合老年代:存活率高,偶尔整理可接受"]
style b2 fill:#f8d7da,stroke:#dc3545
style b4 fill:#f8d7da,stroke:#dc3545
style b5 fill:#f8d7da,stroke:#dc3545
style b7 fill:#f8d7da,stroke:#dc3545
style a1 fill:#d4edda,stroke:#28a745
style a2 fill:#d4edda,stroke:#28a745
style a3 fill:#d4edda,stroke:#28a745
style a4 fill:#d4edda,stroke:#28a745
style a5 fill:#e2e3e5,stroke:#6c757d
style note fill:#d1ecf1,stroke:#17a2b8
八、五大主流垃圾收集器
Serial GC:单线程,最简单
特点:GC 时只用一个线程,STW 期间用户线程全部暂停。适用于客户端应用、单核 CPU、几十到几百 MB 堆。启用:-XX:+UseSerialGC
gantt
title Serial GC 时间轴
dateFormat X
axisFormat %s
section 用户线程
正常运行 :active, u1, 0, 30
暂停(STW) :crit, stw1, 30, 70
恢复运行 :active, u2, 70, 100
section GC 线程(单线程)
等待 :done, g0, 0, 30
串行 GC 执行 :active, gc1, 30, 70
空闲 :done, g1, 70, 100
Parallel GC:多线程 Serial,吞吐量优先
特点:GC 用多个线程并行工作,缩短 STW 时间,但仍然 STW。JDK 8 默认收集器,适用于后台计算任务,吞吐量优先。启用:-XX:+UseParallelGC
gantt
title Parallel GC 时间轴(多线程并行,STW 更短)
dateFormat X
axisFormat %s
section 用户线程
正常运行 :active, u1, 0, 30
暂停(STW 更短) :crit, stw1, 30, 55
恢复运行 :active, u2, 55, 100
section GC 线程 1
等待 :done, g0, 0, 30
并行 GC :active, gc1, 30, 55
空闲 :done, g10, 55, 100
section GC 线程 2
等待 :done, g20, 0, 30
并行 GC :active, gc2, 30, 55
空闲 :done, g21, 55, 100
section GC 线程 3
等待 :done, g30, 0, 30
并行 GC :active, gc3, 30, 55
空闲 :done, g31, 55, 100
CMS GC:并发收集,低停顿(已废弃)
目标:让 GC 和用户线程尽量并发执行,减少 STW 时间。JDK 14 中被废弃,建议迁移到 G1。
gantt
title CMS GC 四阶段时间轴
dateFormat X
axisFormat %s
section 用户线程
正常运行 :active, u1, 0, 10
暂停(初始标记 STW) :crit, stw1, 10, 18
并发阶段同时运行 :active, u2, 18, 75
暂停(重新标记 STW) :crit, stw2, 75, 85
并发清除同时运行 :active, u3, 85, 110
section GC 线程
等待 :done, g0, 0, 10
初始标记(STW 很短) :crit, gc1, 10, 18
并发标记(与用户并发):active, gc2, 18, 75
重新标记(STW 较短) :crit, gc3, 75, 85
并发清除(与用户并发):active, gc4, 85, 110
CMS 存在的问题:① 并发清除时产生「浮动垃圾」;② 使用标记-清除,产生内存碎片;③ 老年代在并发阶段被填满时触发 Serial Old 兜底(极慢!)
G1 GC:分区设计,可预测停顿(JDK 9+ 默认)
G1 最大的创新:把堆划分为大量小的「Region」,每个 Region 约 1-32MB。
graph TD
subgraph G1Heap[G1 堆布局(Region 视图)]
subgraph Row1[第一行 Region]
E1["E Eden Region"]
E2["E Eden Region"]
S1["S Survivor Region"]
O1["O Old Region"]
E3["E Eden Region"]
O2["O Old Region"]
H1["H Humongous Region (大对象)"]
O3["O Old Region"]
end
subgraph Row2[第二行 Region]
O4["O Old Region"]
E4["E Eden Region"]
H2["H Humongous Region (续)"]
O5["O Old Region"]
S2["S Survivor Region"]
E5["E Eden Region"]
O6["O Old Region"]
Free["(空闲) 待分配"]
end
end
Principle["G1 核心原则:优先回收「垃圾最多」的 Region 每个 Region 记录回收收益和代价 选择收益/代价最高的 Region 集合回收 可设定停顿目标:-XX:MaxGCPauseMillis=200"]
style E1 fill:#fff3cd,stroke:#ffc107
style E2 fill:#fff3cd,stroke:#ffc107
style E3 fill:#fff3cd,stroke:#ffc107
style E4 fill:#fff3cd,stroke:#ffc107
style E5 fill:#fff3cd,stroke:#ffc107
style S1 fill:#d1ecf1,stroke:#17a2b8
style S2 fill:#d1ecf1,stroke:#17a2b8
style O1 fill:#f8d7da,stroke:#dc3545
style O2 fill:#f8d7da,stroke:#dc3545
style O3 fill:#f8d7da,stroke:#dc3545
style O4 fill:#f8d7da,stroke:#dc3545
style O5 fill:#f8d7da,stroke:#dc3545
style O6 fill:#f8d7da,stroke:#dc3545
style H1 fill:#e2d9f3,stroke:#6f42c1
style H2 fill:#e2d9f3,stroke:#6f42c1
style Free fill:#e2e3e5,stroke:#6c757d
style Principle fill:#d4edda,stroke:#28a745
flowchart TD
Start(["开始 G1 Mixed GC"])
Phase1["阶段 1:初始标记 🔴 STW(很短) 随 Minor GC 一起执行 标记 GC Roots 直接引用的对象"]
Phase2["阶段 2:并发标记 🟢 与用户线程并发 从 GC Roots 遍历整个堆 记录每个 Region 的存活数据 (可能持续几百毫秒)"]
Phase3["阶段 3:最终标记 🔴 STW(很短) 处理 SATB 缓冲区 修正并发阶段的引用变化"]
Phase4["阶段 4:筛选回收 🔴 STW(可控时长) 按回收价值排序 Region 在停顿目标内选最有价值的 Region 复制存活对象到空 Region 原 Region 整体释放(无碎片)"]
End(["GC 完成 内存已整理,无碎片"])
Start --> Phase1 --> Phase2 --> Phase3 --> Phase4 --> End
style Phase1 fill:#f8d7da,stroke:#dc3545
style Phase2 fill:#d4edda,stroke:#28a745
style Phase3 fill:#f8d7da,stroke:#dc3545
style Phase4 fill:#fff3cd,stroke:#ffc107
ZGC:亚毫秒级停顿(JDK 15+ 生产可用)
ZGC 的目标:无论堆多大(TB 级),停顿时间始终 < 10ms(通常 1-2ms)。适用于金融交易、实时推荐、游戏服务器等延迟敏感场景。启用:-XX:+UseZGC(JDK 11+,生产推荐 JDK 15+)
gantt
title ZGC 时间轴(几乎全程并发,STW 极短)
dateFormat X
axisFormat %s
section 用户线程
持续运行(全程) :active, u1, 0, 100
section GC 线程
并发标记 :active, gc1, 5, 35
并发转移准备 :active, gc2, 35, 55
并发转移(移动对象) :active, gc3, 55, 90
并发重映射 :active, gc4, 90, 100
section STW 停顿
初始标记(~1ms) :crit, stw1, 5, 7
再标记(~1ms) :crit, stw2, 33, 35
ZGC 关键技术:① 染色指针:64位指针高4位存储 GC 状态,无需额外标记位图;② 读屏障:每次读取引用时顺带完成重定位,将移动对象的引用更新分散到每次访问;③ 并发整理:对象可在用户线程运行时被移动。
五大收集器对比总结
| 收集器 | 新生代算法 | 老年代算法 | 停顿特点 | 适用场景 |
|---|---|---|---|---|
| Serial | 复制 | 标记整理 | STW,单线程 | 客户端/单核/小堆 |
| Parallel | 复制 | 标记整理 | STW,多线程 | 后台批处理,吞吐优先 |
| CMS | (配合 ParNew) | 标记清除 | 短暂 STW + 并发 | 已废弃,迁移到 G1 |
| G1 | 复制 | 复制 | 可设定目标停顿 | 大堆(4GB+),通用服务 |
| ZGC | 并发,全堆统一 | <10ms,与堆大小无关 | 超大堆,延迟敏感服务 | |
九、GC 调优实践
关键 JVM 参数
# 堆大小
-Xms4g -Xmx4g # 初始和最大堆大小,建议设成相同值(避免动态扩容开销)
-Xmn1g # 新生代大小(一般为堆的 1/4 到 1/3)
# 选择收集器
-XX:+UseG1GC # 使用 G1(JDK 9+ 默认)
-XX:+UseZGC # 使用 ZGC(延迟敏感场景)
# G1 调优
-XX:MaxGCPauseMillis=200 # 期望停顿不超过 200ms
-XX:G1HeapRegionSize=16m # Region 大小(1-32m,2 的幂次)
# GC 日志(必须开,出问题时救命)
-Xlog:gc*:file=/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=20m
常见问题诊断
问题 1:频繁 Full GC
诊断步骤:
1. jstat -gcutil <pid> 1000 # 每秒看一次 GC 情况
2. 观察 OU(Old 使用率)是否持续增长
3. 如果 OU 线性增长 → 内存泄漏(对象无法被回收)
4. 如果 OU 周期性满 → 老年代容量不足,考虑扩大 -Xmx 或 -Xmn
问题 2:GC 停顿过长
诊断步骤:
1. 分析 GC 日志,找出停顿 > SLA 的 GC 事件
2. 查看是 Minor GC 还是 Full GC
3. Minor GC 慢 → 新生代太大,或存活对象太多(阈值设太高)
4. Full GC 慢 → 老年代碎片(CMS),或堆太大(G1 Region 太多)
问题 3:OOM(OutOfMemoryError)
常见原因:
java.lang.OutOfMemoryError: Java heap space
→ 堆内存不足,对象太多或内存泄漏
java.lang.OutOfMemoryError: Metaspace
→ 动态生成类太多(如 AOP、反射、动态代理),
增大 -XX:MaxMetaspaceSize
java.lang.OutOfMemoryError: GC overhead limit exceeded
→ GC 时间占总时间比例超过 98%,几乎不做实际工作
通常是内存严重不足的信号
十、关键点总结
- 堆分新生代和老年代,基于分代假说:大多数对象短命,少数长寿;不同代用不同算法
- Minor GC 用复制算法:Eden → Survivor,年龄到阈值晋升老年代;快速、频繁,影响小
- Full GC 触发 Stop-The-World:整个堆暂停,代价极大,要尽量避免;老年代满、晋升失败是主要触发原因
- 可达性分析从 GC Roots 出发判断存活,能正确处理循环引用(引用计数做不到)
- 三种基础算法:标记-清除(有碎片)、标记-复制(无碎片但费内存)、标记-整理(无碎片,但移动对象开销大)
- G1 是当前通用默认选择:Region 化设计,可设定停顿目标,无碎片,适合 4GB+ 的堆
- ZGC 是延迟敏感场景的终极方案:染色指针 + 读屏障实现并发整理,停顿 <10ms 且与堆大小无关
- GC 日志是调优的基础:生产环境必须开启 GC 日志,出问题时是最重要的诊断依据