Java 垃圾回收机制:从对象判死到分代回收,一篇讲透
面试官:“Java 如何判断一个对象可以被回收?”
你:“两种方式:引用计数法和可达性分析。主流 JVM 使用可达性分析,从 GC Roots 出发,不可达的对象就是垃圾。”
面试官:“那 GC Roots 包括哪些?引用计数法有什么缺陷?”
你:“……”
很多人能背出“可达性分析”,但一问到具体算法、分代回收原理、GC Roots 细节就卡壳了。本文从对象死亡判定到垃圾回收算法,结合 HotSpot 实现,彻底讲透 Java 垃圾回收机制。
一、垃圾回收(GC)是什么?
垃圾回收是指自动管理内存的一种机制:自动识别并回收不再使用的对象,释放内存空间。Java 程序员不需要手动free或delete,JVM 会帮我们完成这项工作。垃圾回收的核心问题有两个:
- 哪些内存需要回收?—— 对象死亡判定
- 如何回收?—— 垃圾回收算法
二、判断对象死亡的两种方式
1. 引用计数法(Reference Counting)
原理:每个对象维护一个引用计数器,每当有一个地方引用它,计数器 +1;引用失效时,计数器 -1。计数器为 0 的对象就是垃圾。
优点:简单高效,实时性好,可以立即回收垃圾。
缺点(致命缺陷):
- 循环引用:对象 A 引用 B,B 引用 A,除此之外无其他引用。两者计数器都不为 0,永远不会被回收,导致内存泄漏。
- 需要额外空间存储计数器,且加减操作频繁。
应用:Python、PHP 等语言早期使用,但都搭配了其他算法解决循环引用。JVM 没有采用引用计数法。
2. 可达性分析(Reachability Analysis)
原理:从一组称为GC Roots的根对象出发,通过引用链向下搜索,形成引用网络。在这个网络中的对象是“可达”的(存活),不在网络中的对象就是“不可达”的(可回收)。
优点:天然解决循环引用问题——A 和 B 互相引用,但如果它们没有任何 GC Roots 引用,就会被判定为不可达,一起回收。
GC Roots 包括:
- 虚拟机栈(栈帧中的局部变量表)中引用的对象
- 方法区中静态属性引用的对象(如
static字段) - 方法区中常量引用的对象(如
final常量) - 本地方法栈中 JNI 引用的对象(Native 方法)
- Java 虚拟机内部的引用(基本类型的 Class 对象、常驻异常对象等)
- 所有被同步锁(synchronized)持有的对象
- 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 回调等
注意:即使对象被可达性分析判定为不可达,也不一定会立即回收。它至少需要经过两次标记过程(第一次标记并筛选是否执行finalize(),第二次标记真正回收)。finalize()方法已被 JDK 9 标记为废弃,不推荐使用。
结论:HotSpot JVM 采用可达性分析作为对象存活判定算法。
三、常见垃圾回收算法
有了“哪些是垃圾”,接下来就是“如何回收垃圾”。垃圾回收算法是垃圾收集器实现的理论基础。
1. 标记-清除(Mark-Sweep)
过程:
- 标记:遍历所有 GC Roots,标记出所有存活对象。
- 清除:线性遍历堆内存,回收所有未被标记的对象的内存。
图示:
初始: [A][B][C][D][E] (A、C、E 存活) 标记后: [✓][×][✓][×][✓] (✓存活,×垃圾) 清除后: [A][ ][C][ ][E] (内存碎片)优点:简单,不需要移动对象。
缺点:
- 效率问题:标记和清除都需要遍历,对象越多越慢。
- 空间碎片:清除后产生大量不连续内存碎片,导致大对象无法分配,提前触发另一次 GC。
应用场景:CMS 收集器的“并发清除”阶段使用了标记-清除算法的变种。
2. 复制算法(Copying)
过程:将内存分为大小相等的两块(From 和 To),每次只使用其中一块。当这一块用满时,将存活对象复制到另一块,然后一次性清空当前块。
图示:
初始: From [A][B][C][D] To [空] 存活 A、C → 复制到 To From 清空: From [空] To [A][C] 交换角色: From [A][C] To [空]优点:
- 实现简单,运行高效(只需移动指针,无需处理碎片)。
- 无内存碎片,分配新对象时只需指针碰撞(bump-the-pointer)。
缺点:
- 内存利用率低,只能用一半内存。
- 对象存活率高时,复制开销大。
应用:新生代 GC(如 Serial、ParNew、Parallel Scavenge),因为新生代对象存活率低,适合复制算法。HotSpot 将新生代分为 Eden + Survivor(两块,通常是 8:1:1),而不是 1:1,提高了内存利用率。
3. 标记-整理(Mark-Compact)
过程:
- 标记:与标记-清除相同,标记存活对象。
- 整理:将所有存活对象向一端移动,然后直接清理边界以外的内存。
图示:
标记后: [A][×][C][×][E] (A、C、E 存活) 整理后: [A][C][E][ ][ ] (存活对象靠左,右边空闲连续)优点:无内存碎片,适合老年代(对象存活率高)。
缺点:移动对象需要更新所有引用地址,成本比标记-清除高。
应用:老年代 GC(如 Serial Old、Parallel Old)。
4. 分代回收(Generational Collection)
核心思想:根据对象存活周期的不同,将堆内存划分为不同区域,采用最适合的回收算法。
HotSpot 堆划分:
- 新生代(Young Generation):对象存活率低,使用复制算法。分为 Eden 区和两块 Survivor 区(From 和 To),默认比例 8:1:1。
- 老年代(Old Generation):对象存活率高,使用标记-清除或标记-整理算法(取决于收集器)。
为什么分代有效?
- 大部分对象朝生夕灭(如局部变量、临时对象)。
- 老年代对象生命周期长,回收频率低。
- 分代可以针对不同区域采用不同策略,提升整体效率。
对象晋升(Promotion):
- 新生代经过多次 GC 仍存活的对象,会晋升到老年代。
- 大对象(超过阈值)直接分配到老年代。
卡表(Card Table):用于记录老年代对新生代的引用,避免每次 Young GC 都扫描整个老年代。
四、垃圾回收算法对比
| 算法 | 内存利用率 | 碎片问题 | 移动对象 | 适用场景 |
|---|---|---|---|---|
| 标记-清除 | 100% | 有 | 否 | 老年代(CMS) |
| 复制 | 50%(或更高,如 90% 通过 Survivor) | 无 | 是 | 新生代 |
| 标记-整理 | 100% | 无 | 是 | 老年代 |
| 分代回收 | 高(组合使用) | 基本无 | 部分 | 综合 |
五、常见面试追问
Q1:引用计数法为什么不用于 JVM?
循环引用无法解决,例如 A 引用 B,B 引用 A,且外部没有引用,计数器永远不为 0,内存泄漏。
Q2:可达性分析中的 GC Roots 包括哪些?
见上文列表。尤其注意:被synchronized持有的对象、JNI 全局引用、活跃线程等也是 GC Roots。
Q3:标记-清除和标记-整理的区别?
标记-清除不移动对象,产生碎片;标记-整理移动对象,消除碎片,但成本更高。
Q4:为什么新生代使用复制算法?
新生代对象存活率低,每次回收只有少量对象存活,复制开销小,且无碎片。通过 Survivor 分区(8:1:1),内存浪费仅 10%,可接受。
Q5:直接调用System.gc()会发生什么?
会建议 JVM 执行 Full GC,但 JVM 不保证立即执行。频繁调用会严重影响性能,应避免。
Q6:finalize()方法的作用和问题?
对象被回收前,如果覆盖了finalize(),会被调用(仅一次)。但该方法执行时间不确定,且可能“复活”对象,已被 JDK 9 标记为废弃。推荐使用try-with-resources或Cleaner。
六、总结
| 判定方式 | 核心 | 优缺点 |
|---|---|---|
| 引用计数法 | 计数器 | 简单,但无法解决循环引用 |
| 可达性分析 | GC Roots + 引用链 | 无循环引用问题,主流方案 |
| 回收算法 | 核心 | 适用区域 |
|---|---|---|
| 标记-清除 | 标记 + 清除 | 老年代(CMS) |
| 复制 | 分半复制 | 新生代 |
| 标记-整理 | 标记 + 移动 | 老年代(Serial Old、Parallel Old) |
| 分代回收 | 组合策略 | 整个堆 |
一句话记住垃圾回收:可达分析判生死,分代回收用对法;新生复制老整理,标记清除防碎片。
垃圾回收算法是理解 JVM 调优和选择垃圾收集器的基础。掌握这些原理,才能看懂 GC 日志、优化停顿时间、排查内存问题。
希望这篇文章能帮你彻底掌握 Java 垃圾回收机制,欢迎继续讨论。