Skip to content

第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)

对象生命周期

  1. 新对象在 Eden 分配。
  2. Eden 满 → Minor GC:Eden + 一个 Survivor 存活对象复制到另一 Survivor,原区清空。复制算法。
  3. 存活对象在 Survivor 间来回,每次 GC 年龄+1。
  4. 年龄达阈值(默认 15)→ 晋升老年代。
  5. 大对象直接进老年代(避免新生代复制)。
  6. 老年代满 → Major GC / Full GC(标记-清除/整理)。

Minor GC:新生代 GC,频繁但快(存活少)。 Full GC:整个堆+方法区,慢,应尽量减少。


📖 四、垃圾收集器

收集器特点适用
Serial新/老单线程,STW客户端/小应用
ParNewSerial 多线程版配 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 / 调优

特性CJava 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 延迟)是调优基础。


💡 七、最佳实践

  1. 优先用 G1(JDK 9+ 默认),大堆低延迟用 ZGC。
  2. -Xms = -Xmx,避免堆动态调整。
  3. 监控 Full GC,频繁/耗时长需排查(内存泄漏/堆小)。
  4. 避免 System.gc(),别依赖手动触发。
  5. 不用 finalize,用 try-with-resources。
  6. 大对象慎用(直接进老年代),考虑分块或对象池。
  7. 缓存用软/弱引用,避免强引用持有不释放。

📝 练习预告

完成 练习/Ex36_GC.java 中的 6 道题:

  1. System.gc 观察(建议性)
  2. 软引用(内存不足回收)
  3. 弱引用(GC 即回收)
  4. WeakHashMap 演示
  5. 对象晋升观察(-verbose:gc 思考)
  6. 综合:模拟缓存(软引用 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课:类加载机制 — 双亲委派、自定义类加载器