Lesson 26 · JVM 原理与调优
对象存活判定:引用计数 vs 可达性分析
开场:这个对象还要不要?
面试官问:"JVM 怎么知道一个对象可以被回收?" 很多人答"没有引用指向它"——这个答案只对了一半。
垃圾回收的第一步不是"怎么回收",而是"怎么判断一个对象还活着"。如果判错了,要么把活对象删了(程序崩溃),要么把死对象留着(内存泄漏)。这是 GC 最底层的问题。
业界有两种主流方案:
| 方案 | 核心思想 | 代表语言 | 致命弱点 |
|---|---|---|---|
| 引用计数 | 给每个对象维护一个计数器 | Python / PHP / Swift | 循环引用 |
| 可达性分析 | 从根节点出发做图遍历 | Java / C# / Go / JS | 需要 STW 或写屏障 |
Java 选择了可达性分析,Python 选择了引用计数(加上循环引用检测)。为什么它们做了不同的选择? 这就是本文要讲清楚的事。
不要只回答"没有引用就回收"。要说清楚"JVM 用的是可达性分析,不是简单的引用计数,因为引用计数无法处理循环引用"——一句话就把两个方案都点到了。
引用计数:最直觉的方案
引用计数的规则非常简单:
- 每个对象有一个整型字段
refCount - 有新的引用指向它 →
refCount++ - 引用离开作用域或被置空 →
refCount-- refCount == 0→ 立即回收
class RefCountedObject { int refCount = 0; void retain() { refCount++; } void release() { refCount--; if (refCount == 0) { deallocate(this); // 立即回收内存 } } }
优点
- 实时性好:计数归零的那一刻就回收,不需要等 GC 周期
- 实现简单:不需要暂停程序,不需要复杂的图遍历
- 回收粒度细:每次只处理单个对象,停顿极短
致命缺陷:循环引用
当 A 引用 B、B 又引用 A 时,即使外部没有任何变量再指向它们,两者的 refCount 都是 1,永远不会归零。这两个对象就成了"孤岛"——活着但永远无法到达。
Python 怎么解决这个问题?
CPython 在引用计数之上加了一个循环垃圾收集器(cycle detector),定期扫描所有容器对象,找出不可达的循环引用组并打破它们。本质上是在引用计数之上补了一层"简化版可达性分析"。PHP 也用类似策略。
提到"Python 用引用计数 + 循环检测"比只说"Python 用引用计数"更准确。Swift 则用 ARC + weak/unowned 引用让开发者手动打破循环。
可达性分析:从根出发,能走到就是活的
Java、C#、Go、JavaScript 的 GC 都采用可达性分析(Reachability Analysis)。核心思路只有一句话:
哪些东西可以作为 GC Roots?
- 栈帧中的局部变量(每个线程的虚拟机栈中引用的对象)
- 方法区中的静态变量(
static字段引用的对象) - 方法区中的常量(
final static引用的对象) - JNI(Native 方法)引用的对象
- 同步监视器(synchronized 持有的对象)
注意图中 Obj6 和 Obj7 互相引用,但没有任何 GC Root 能遍历到它们。可达性分析天然免疫循环引用——只要从根走不到,不管你内部怎么互指,都会被回收。这就是 Java 选择可达性分析的核心原因。
可达性分析需要"暂停世界"(STW)吗?
是的,标记阶段必须保证引用关系不变,否则遍历过程中引用被修改会导致漏标或错标。这就是为什么 GC 的标记阶段通常需要 Stop-The-World。不过现代收集器(如 ZGC)通过染色指针 + 读屏障技术,把大部分标记工作做到了并发阶段,只在极少数关键点短暂暂停。
可达性分析解决了引用计数的循环引用难题,代价是需要 STW 或写屏障来保证遍历期间引用关系的一致性。
finalize():一次"自我救赎"的机会
在可达性分析判定一个对象"不可达"之后,JVM 并不会立刻回收它。如果这个对象重写了 Object.finalize() 方法,它会获得一次"复活"的机会。
逃逸流程
- 对象被判定不可达,进入 F-Queue(Finalizer 队列)
- Finalizer 线程(低优先级守护线程)执行对象的
finalize()方法 - 如果
finalize()中把this赋给了某个 GC Root 可达的变量 → 对象复活 - 如果没复活 → 第二次标记后真正回收
public class EscapeDemo { public static EscapeDemo SAVE_HOOK = null; protected void finalize() throws Throwable { super.finalize(); System.out.println("finalize() 被执行,尝试自救"); SAVE_HOOK = this; // 把 this 挂到一个静态变量上 → 重新可达 } public static void main(String[] args) throws Exception { SAVE_HOOK = new EscapeDemo(); SAVE_HOOK = null; // 断开引用 System.gc(); // 触发 GC Thread.sleep(1000); // 等 Finalizer 线程执行 if (SAVE_HOOK != null) { System.out.println("对象复活了!"); // 会打印 } } }
为什么 finalize() 被废弃?
JDK 9 将 Object.finalize() 标记为 @Deprecated,JDK 18+ 的 JEP 421 进一步讨论移除。原因:
| 问题 | 说明 |
|---|---|
| 不可预测 | 你不知道 finalize() 何时执行,甚至不确定它一定会执行 |
| 性能差 | 有 finalize() 的对象回收速度比普通对象慢 数十倍(需要进 F-Queue、等守护线程) |
| 只给一次机会 | 同一个对象的 finalize() 最多被调用一次,第二次不可达时直接回收 |
| 安全隐患 | finalize() 中可以复活对象,导致本应释放的资源被意外保留 |
| 异常被吞 | finalize() 中抛出的异常会被 Finalizer 线程直接忽略,不会打印堆栈 |
需要释放资源时,使用 try-with-resources(AutoCloseable)或 JDK 9 引入的 Cleaner(基于 PhantomReference)。永远不要依赖 finalize() 来关闭文件或连接。
四种引用:强、软、弱、虚
Java 在可达性分析的基础上,还提供了四种不同强度的引用类型,让你可以精细控制对象的生命周期:
| 引用类型 | 类 | 回收时机 | 典型用途 |
|---|---|---|---|
| 强引用 | 普通赋值 | 永不回收(只要可达) | 绝大多数变量 |
| 软引用 | SoftReference | 内存不足时才回收 | 内存敏感型缓存 |
| 弱引用 | WeakReference | 下次 GC 就回收 | ThreadLocal、WeakHashMap |
| 虚引用 | PhantomReference | 随时回收,get() 返回 null | 跟踪对象被回收的时机 |
软引用:内存敏感的缓存
Map<String, SoftReference<Bitmap>> cache = new HashMap<>(); // 存入缓存 cache.put("avatar", new SoftReference<>(bitmap)); // 读取缓存 SoftReference<Bitmap> ref = cache.get("avatar"); Bitmap bmp = (ref != null) ? ref.get() : null; if (bmp == null) { bmp = loadFromDisk("avatar"); // 缓存被回收了,重新加载 }
JVM 保证:在抛出 OutOfMemoryError 之前,所有软引用对象都会被回收。所以它非常适合做"有就用,没有也能活"的缓存。
弱引用:ThreadLocal 的坑与解
ThreadLocalMap 的 key 就是 WeakReference<ThreadLocal>。当 ThreadLocal 实例不再有强引用时,下次 GC 就会回收 key,但 value 仍然被线程持有。如果线程长期存活(如线程池),value 就泄漏了——这就是经典的 ThreadLocal 内存泄漏。解法是每次用完手动 remove()。
虚引用:比 finalize() 更好的替代品
PhantomReference 的 get() 永远返回 null,它唯一的用途是配合 ReferenceQueue 来获知对象何时被回收,从而执行清理操作。JDK 9 的 Cleaner 底层就是基于虚引用实现的。
强引用 = 打死不回收;软引用 = 没内存才回收;弱引用 = GC 就回收;虚引用 = 只用来收通知。
回收方法区:类也能被卸载
很多人以为 GC 只管堆,其实方法区(元空间 / Metaspace)也会被回收。回收的对象主要是两类:废弃的类和不再使用的字符串常量。
类卸载的三个条件
一个类要被卸载,必须同时满足以下三个条件:
- 该类的所有实例都已经被回收(堆中不存在该类的任何对象)
- 加载该类的 ClassLoader 已经被回收
- 该类的
java.lang.Class对象不再被任何地方引用(无法通过反射访问)
为什么类卸载这么苛刻?
因为 Java 的反射机制允许在运行时动态获取类的信息。只要 Class 对象还被引用,就随时可能通过 Class.forName() 或 classObj.newInstance() 创建新实例。JVM 必须确保"真的不可能再用到"才敢卸载。
在实际场景中,类卸载主要发生在:
- 大量使用反射生成动态代理类(如 CGLIB、ByteBuddy)后,旧的代理类可以被卸载
- OSGi / 热部署:模块卸载时,对应的 ClassLoader 被回收,类随之卸载
- JSP 编译:每次修改 JSP 都会生成新的类,旧的需要被卸载
字符串常量池回收
JDK 7+ 字符串常量池从永久代移到了堆中(JDK 8 彻底移除永久代)。常量池中的字符串如果不再被引用,也可以被 GC 回收。不过实际开发中很少需要关注这一点——除非你在大量使用 String.intern()。
提到"Metaspace 默认没有上限,可以通过 -XX:MaxMetaspaceSize 限制。如果动态生成大量类(如反射代理),不设置上限可能导致本地内存持续增长。"——这说明你有生产环境的排障经验。
总结:一张表 + 实战启示
两种方案对比
| 维度 | 引用计数 | 可达性分析 |
|---|---|---|
| 循环引用 | 无法处理(需额外机制) | 天然免疫 |
| 实时性 | 极好(归零即回收) | 依赖 GC 周期 |
| 停顿 | 几乎无停顿 | 标记阶段需 STW 或屏障 |
| 空间开销 | 每个对象多一个计数器 | 需要维护 GC Root 集合 + 遍历栈 |
| 实现复杂度 | 低 | 高(图遍历、并发标记、写屏障) |
| 代表语言 | Python / PHP / Swift | Java / C# / Go / JavaScript |
面试回答模板
// 1. 先说结论 "JVM 用可达性分析判定对象是否存活,不是引用计数。" // 2. 解释为什么 "引用计数无法处理循环引用——两个对象互相持有引用时, 即使外部没有引用指向它们,计数也不会归零。 可达性分析从 GC Roots 出发做图遍历, 只要从根走不到,不管对象之间怎么互指,都会被回收。" // 3. 补充 GC Roots "GC Roots 包括栈帧局部变量、静态变量、常量引用、JNI 引用等。" // 4. 延伸到四种引用(如果面试官追问) "Java 还提供了软/弱/虚引用来精细控制生命周期, 比如 SoftReference 做缓存、WeakReference 在 ThreadLocal 中的应用。"
实战避坑清单
| 场景 | 陷阱 | 正确做法 |
|---|---|---|
| 释放资源 | 依赖 finalize() 关闭流/连接 | 用 try-with-resources 或 Cleaner |
| ThreadLocal | 用完不调 remove(),value 泄漏 | finally 块中调用 remove() |
| 缓存 | 用 HashMap 做缓存,只进不出 | 用 SoftReference 或 Caffeine |
| 动态类生成 | 反射/CGLIB 生成大量类不卸载 | 设置 MaxMetaspaceSize,复用代理类 |
- 引用计数简单但有循环引用问题;可达性分析天然解决循环引用但需要 STW
- GC Roots 是可达性分析的起点:栈变量、静态变量、常量、JNI 引用
- finalize() 已被废弃,用 AutoCloseable 或 Cleaner 替代
- 四种引用(强/软/弱/虚)让你精细控制对象生命周期
- 方法区也可以回收:类卸载需同时满足三个条件