Appearance
第36课:垃圾回收
🎯 学习目标
- 理解可达性分析与 GC Roots(如何判断对象可回收)
- 掌握三种 GC 算法(标记-清除/复制/标记-整理)
- 理解分代收集(新生代/老年代)与对象晋升
- 了解各代垃圾收集器(Serial/Parallel/CMS/G1/ZGC)
- 知道 GC 调优参数与常见场景
📖 一、概念讲解:如何判断对象可回收
1. 引用计数 vs 可达性分析
- 引用计数(早期):对象被引用 +1,解除 -1,归 0 回收。致命缺陷:循环引用(A 引 B,B 引 A,永不归 0)。Java 不用。
- 可达性分析(Java 用):从 GC Roots 出发,沿引用链遍历,可达的对象存活,不可达的可回收。天然解决循环引用。
2. GC Roots(根对象)
可达性分析的起点,包括:
- 虚拟机栈中的局部变量引用(方法内 new 的对象)
- 方法区的类静态变量引用
- 方法区的常量引用
- 本地方法栈 JNI 引用
- 活跃线程、同步锁持有的对象
从这些根出发能到达的对象都存活,到达不了的回收。
java
void method() {
Object o = new Object(); // o 是 GC Root(局部变量),new 的对象可达
o = null; // 断开引用,new 的对象不可达,下次 GC 回收
}3. 四种引用(影响回收时机)
- 强引用(
Object o = new Object()):只要在就不回收,OOM 也不回收。 - 软引用(SoftReference):内存不足时才回收(适合缓存)。
- 弱引用(WeakReference):下次 GC 就回收(WeakHashMap key)。
- 虚引用(PhantomReference):不影响对象生命周期,仅跟踪回收(资源清理)。
📖 二、GC 算法
1. 标记-清除(Mark-Sweep)
先标记可达对象,清除其余。问题:产生内存碎片(空闲不连续),大对象可能分配不下。
2. 复制(Copying)
内存分两块,GC 时把存活对象复制到另一块,原块整体清空。优点:无碎片、分配快。缺点:可用内存减半。新生代用(存活少,复制开销小)。
3. 标记-整理(Mark-Compact)
标记后,存活对象向一端移动整理,清除边界外。优点:无碎片。缺点:移动开销大。老年代用(存活多,不想复制开销)。
📖 三、分代收集
JVM 把堆分代,不同代用不同算法(对象"朝生夕死"特性):
堆
├── 新生代(约 1/3)
│ ├── Eden(80%)
│ ├── Survivor 0(10%)
│ └── Survivor 1(10%)
└── 老年代(约 2/3)对象生命周期
- 新对象在 Eden 分配。
- Eden 满 → Minor GC:Eden + 一个 Survivor 存活对象复制到另一 Survivor,原区清空。复制算法。
- 存活对象在 Survivor 间来回,每次 GC 年龄+1。
- 年龄达阈值(默认 15)→ 晋升老年代。
- 大对象直接进老年代(避免新生代复制)。
- 老年代满 → Major GC / Full GC(标记-清除/整理)。
Minor GC:新生代 GC,频繁但快(存活少)。 Full GC:整个堆+方法区,慢,应尽量减少。
📖 四、垃圾收集器
| 收集器 | 代 | 特点 | 适用 |
|---|---|---|---|
| Serial | 新/老 | 单线程,STW | 客户端/小应用 |
| ParNew | 新 | Serial 多线程版 | 配 CMS |
| Parallel Scavenge | 新 | 吞吐量优先 | 计算密集 |
| Parallel Old | 老 | 吞吐量优先 | 配 Parallel Scavenge |
| CMS | 老 | 低延迟,并发标记清除 | 对延迟敏感(JDK 9 弃用) |
| G1 | 全堆 | Region 化,可预测停顿 | 大堆、低延迟(JDK 9+ 默认) |
| ZGC | 全堆 | 着色指针,亚毫秒停顿 | 超大堆、极低延迟(JDK 11+) |
| Shenandoah | 全堆 | 并发整理,低延迟 | 超大堆(OpenJDK) |
STW(Stop-The-World):GC 时暂停所有应用线程。收集器演进目标:减少 STW 时间。
- CMS:并发标记/清除,老年代低延迟,但有碎片。
- G1:堆分 Region,优先回收价值高的 Region,停顿可预测。
- ZGC:着色指针 + 读屏障,堆 TB 级停顿仍 <10ms。
⚠️ 五、常见陷阱
陷阱1:误以为 System.gc() 立即回收
System.gc() 只是建议,JVM 可忽略(-XX:+DisableExplicitGC)。别依赖。
陷阱2:finalize 延迟回收
finalize() 在 GC 前调用,对象可"自我拯救"(重新被引用)。但 finalize 不保证执行时机/是否执行,JDK 9 弃用。别用 finalize 做资源清理,用 try-with-resources。
陷阱3:Full GC 频繁
老年代/元空间不足、大对象、内存泄漏 → Full GC 频繁,应用卡顿。监控 Full GC 频率/耗时。
陷阱4:内存碎片
CMS 标记-清除有碎片,可能并发失败退化为 Serial Old(长 STW)。
陷阱5:对象过早晋升
Survivor 太小 → 对象年龄增长快 → 过早进老年代 → 老年代膨胀 Full GC。调 -XX:SurvivorRatio。
🆚 六、Java vs C / 调优
| 特性 | C | Java GC |
|---|---|---|
| 内存回收 | 手动 free | 自动 GC |
| 内存泄漏 | 忘 free | 引用未断(可达未释放) |
| 性能可控 | 完全手动 | GC 停顿(STW) |
GC 调优要点:
- 监控:-XX:+PrintGCDetails、jstat -gc、GC 日志分析。
- 常见参数:
- 堆:-Xms -Xmx(建议相等)
- 新生代:-Xmn
- 元空间:-XX:MetaspaceSize -XX:MaxMetaspaceSize
- 收集器:-XX:+UseG1GC
- GC 日志:-Xlog:gc*(JDK 9+)
- 调优目标:减少 Full GC、降低 STW、合理堆大小。
- 工具:jstat、jmap、GCViewer、GCEasy(在线分析)。
对 C 程序员:GC 把 C 的手动 free 自动化,代价是 GC 停顿(STW)不可完全消除。理解分代(朝生夕死优化)和收集器选择(吞吐 vs 延迟)是调优基础。
💡 七、最佳实践
- 优先用 G1(JDK 9+ 默认),大堆低延迟用 ZGC。
- -Xms = -Xmx,避免堆动态调整。
- 监控 Full GC,频繁/耗时长需排查(内存泄漏/堆小)。
- 避免 System.gc(),别依赖手动触发。
- 不用 finalize,用 try-with-resources。
- 大对象慎用(直接进老年代),考虑分块或对象池。
- 缓存用软/弱引用,避免强引用持有不释放。
📝 练习预告
完成 练习/Ex36_GC.java 中的 6 道题:
- System.gc 观察(建议性)
- 软引用(内存不足回收)
- 弱引用(GC 即回收)
- WeakHashMap 演示
- 对象晋升观察(-verbose:gc 思考)
- 综合:模拟缓存(软引用 vs 强引用对比)
完成后对比 答案/Sol36.java,查看逐行讲解与多解法。
📖 八、GC Roots 与可达性分析
Java 判断对象是否可回收,主要使用可达性分析。
text
从 GC Roots 出发,沿引用链能到达的对象是存活对象。
无法从 GC Roots 到达的对象可以被回收。常见 GC Roots:
text
线程栈中的局部变量
静态字段引用的对象
JNI 引用
正在运行方法中的参数
同步锁持有的对象这解释了为什么静态集合容易造成内存泄漏:
java
private static final List<Object> CACHE = new ArrayList<>();只要静态字段还引用集合,集合中的对象就可达,GC 不会回收。
📖 九、常见收集器选择
| 收集器 | 特点 | 场景 |
|---|---|---|
| Serial | 单线程,简单 | 小程序、测试 |
| Parallel | 吞吐优先 | 批处理 |
| G1 | 平衡吞吐和延迟 | 大多数服务端应用 |
| ZGC | 超低停顿 | 大堆、低延迟应用 |
| Shenandoah | 低停顿 | 部分 JDK 发行版 |
现代 Spring Boot 服务通常从 G1 开始,不要一上来就盲目切换收集器。
📖 十、GC 日志阅读
JDK 11+:
bash
-Xlog:gc*:file=logs/gc.log:time,uptime,level,tags:filecount=10,filesize=100M重点看:
text
GC 类型:Young、Mixed、Full。
GC 前后堆占用。
暂停时间。
触发原因。
Old 区是否持续上涨。
Full GC 是否频繁。示例:
text
Pause Young 256M->80M(1024M) 12ms表示 GC 前使用 256M,GC 后 80M,总堆 1024M,暂停 12ms。
🧪 十一、实战排查:内存持续上涨
排查流程:
text
观察 GC 后内存是否回落。
使用 jstat 看 GC 频率。
使用 jmap -histo 查看对象数量。
导出 heap dump。
用 MAT 看 Dominator Tree。
找到 GC Roots 引用链。
判断是缓存、队列、ThreadLocal 还是静态集合。不要看到内存高就先加堆。加堆可能只是延迟 OOM。
📌 十二、生产建议
text
开启 GC 日志。
开启 OOM heap dump。
监控 Full GC 次数和耗时。
监控堆使用趋势,而不是单点数值。
缓存必须有容量或 TTL。
ThreadLocal 用完 remove。
不要主动调用 System.gc。🔍 十三、自测问题
text
GC Roots 包括哪些对象?
Java 内存泄漏和 C 内存泄漏有什么不同?
为什么静态集合可能导致泄漏?
Young GC 和 Full GC 有什么区别?
G1 和 ZGC 的目标有什么不同?
GC 后内存不下降说明什么?
heap dump 应该看对象数量还是引用链?
为什么 System.gc 不可靠?🧭 十四、GC 调优流程
GC 调优不要从参数开始,而要从证据开始。
推荐流程:
text
1. 明确问题:吞吐低、延迟高、Full GC 频繁、OOM,还是内存基线持续上涨。
2. 收集数据:GC 日志、jstat、监控曲线、heap dump、线程 dump。
3. 判断类型:是分配过快、存活对象过多、老年代不足,还是泄漏。
4. 先改代码:减少无效对象、修复缓存、关闭资源、清理 ThreadLocal。
5. 再调参数:堆大小、收集器、停顿目标、新生代比例。
6. 单变量验证:一次只改一个关键点,观察压测和生产指标。
7. 固化配置:把 GC 日志、OOM dump、告警阈值纳入部署模板。常用观测指标:
text
Young GC 次数和平均耗时。
Full GC 次数和平均耗时。
GC 后老年代占用。
对象分配速率。
晋升速率。
应用吞吐量。
P95/P99 响应时间。调优目标要量化,例如:
text
Full GC 每天不超过 1 次。
Young GC P99 暂停低于 50ms。
接口 P99 响应时间低于 200ms。
GC 时间占比低于 5%。🧪 十五、案例:缓存导致老年代上涨
问题现象:
text
服务运行几小时后响应变慢。
GC 日志显示 Old 区 GC 后仍持续上涨。
最终出现 Full GC 频繁,甚至 OOM。可疑代码:
java
private static final Map<String, UserProfile> CACHE = new HashMap<>();
public UserProfile getProfile(String userId) {
return CACHE.computeIfAbsent(userId, id -> queryProfile(id));
}问题原因:
text
static Map 是 GC Root 可达路径的一部分。
缓存没有容量上限。
用户越多,Map 越大。
对象始终被强引用持有,GC 无法回收。改进方向:
java
Cache<String, UserProfile> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(30))
.build();排查重点不是“为什么 GC 不回收”,而是“谁还引用着这些对象”。
🛠 十六、GC 日志信号
看到这些信号时要提高警惕:
text
Full GC 频率越来越高。
每次 GC 后 Old 区占用没有明显下降。
Young GC 后大量对象晋升 Old 区。
Humongous Allocation 频繁出现。
Metadata GC Threshold 频繁出现。
To-space exhausted 或 evacuation failed。
GC pause 时间接近接口超时时间。对应排查方向:
| 信号 | 可能原因 | 下一步 |
|---|---|---|
| Old 区持续上涨 | 泄漏、缓存无界 | heap dump + MAT |
| Young GC 很频繁 | 分配速率过高 | 减少临时对象 |
| 晋升很多 | Survivor 太小或对象生命周期偏长 | 看年龄分布 |
| 元空间触发 GC | 动态类太多、ClassLoader 泄漏 | 查类加载数量 |
| 大对象频繁 | 大数组、大字符串、批量查询 | 分页、流式处理 |
✅ 十七、掌握标准
学完本课后,应能做到:
text
能解释可达性分析和 GC Roots。
能说明 Java 内存泄漏为什么是“仍然可达”。
能区分 Young GC、Mixed GC、Full GC。
能解释复制、标记清除、标记整理的优缺点。
能根据场景选择 G1、Parallel、ZGC 的起点。
能看懂 GC 日志中的前后内存变化和暂停时间。
能用 heap dump 查找 GC Roots 引用链。
能把缓存、ThreadLocal、静态集合列为泄漏高危点。如果只会背垃圾收集器名称,但不会根据日志判断问题,调优时很容易变成猜参数。
🎓 下一步
- 第37课:类加载机制 — 双亲委派、自定义类加载器