Java 面试笔记III · 03 / 14

Lesson 26 · JVM 原理与调优

对象存活判定:引用计数 vs 可达性分析

中级·🔥 极高·#JVM·#GC

第 1 站

开场:这个对象还要不要?

面试官问:"JVM 怎么知道一个对象可以被回收?" 很多人答"没有引用指向它"——这个答案只对了一半。

垃圾回收的第一步不是"怎么回收",而是"怎么判断一个对象还活着"。如果判错了,要么把活对象删了(程序崩溃),要么把死对象留着(内存泄漏)。这是 GC 最底层的问题。

业界有两种主流方案:

方案核心思想代表语言致命弱点
引用计数给每个对象维护一个计数器Python / PHP / Swift循环引用
可达性分析从根节点出发做图遍历Java / C# / Go / JS需要 STW 或写屏障

Java 选择了可达性分析,Python 选择了引用计数(加上循环引用检测)。为什么它们做了不同的选择? 这就是本文要讲清楚的事。

面试加分项

不要只回答"没有引用就回收"。要说清楚"JVM 用的是可达性分析,不是简单的引用计数,因为引用计数无法处理循环引用"——一句话就把两个方案都点到了。

第 2 站

引用计数:最直觉的方案

引用计数的规则非常简单:

  • 每个对象有一个整型字段 refCount
  • 有新的引用指向它 → refCount++
  • 引用离开作用域或被置空 → refCount--
  • refCount == 0 → 立即回收
引用计数的伪代码
class RefCountedObject {
    int refCount = 0;

    void retain()  { refCount++; }
    void release() {
        refCount--;
        if (refCount == 0) {
            deallocate(this);   // 立即回收内存
        }
    }
}

优点

  • 实时性好:计数归零的那一刻就回收,不需要等 GC 周期
  • 实现简单:不需要暂停程序,不需要复杂的图遍历
  • 回收粒度细:每次只处理单个对象,停顿极短

致命缺陷:循环引用

对象 A refCount = 1 field: → B 对象 B refCount = 1 field: → A 外部引用已断开 ✕ 外部引用已断开 ✕ 互相引用 → refCount 永远不为 0 → 内存泄漏
图 1 循环引用:A 和 B 互相持有引用,即使外部引用全部断开,两者的 refCount 仍为 1,永远不会被回收

当 A 引用 B、B 又引用 A 时,即使外部没有任何变量再指向它们,两者的 refCount 都是 1,永远不会归零。这两个对象就成了"孤岛"——活着但永远无法到达

Python 怎么解决这个问题?

CPython 在引用计数之上加了一个循环垃圾收集器(cycle detector),定期扫描所有容器对象,找出不可达的循环引用组并打破它们。本质上是在引用计数之上补了一层"简化版可达性分析"。PHP 也用类似策略。

面试加分项

提到"Python 用引用计数 + 循环检测"比只说"Python 用引用计数"更准确。Swift 则用 ARC + weak/unowned 引用让开发者手动打破循环。

第 3 站

可达性分析:从根出发,能走到就是活的

Java、C#、Go、JavaScript 的 GC 都采用可达性分析(Reachability Analysis)。核心思路只有一句话:

从一组"GC Roots"出发,沿引用链做图遍历。能被遍历到的对象 → 存活;遍历不到的 → 可回收。

哪些东西可以作为 GC Roots?

  • 栈帧中的局部变量(每个线程的虚拟机栈中引用的对象)
  • 方法区中的静态变量static 字段引用的对象)
  • 方法区中的常量final static 引用的对象)
  • JNI(Native 方法)引用的对象
  • 同步监视器(synchronized 持有的对象)
GC Roots 栈变量 a static ref 栈变量 b Obj1 Obj2 Obj3 Obj4 Obj5 Obj6 Obj7 可达(存活) 不可达(可回收)
图 2 可达性分析:从 GC Roots 出发做图遍历。Obj6 和 Obj7 虽然互相引用,但没有任何 GC Root 能到达它们,因此被判定为可回收——循环引用不再是问题

注意图中 Obj6 和 Obj7 互相引用,但没有任何 GC Root 能遍历到它们。可达性分析天然免疫循环引用——只要从根走不到,不管你内部怎么互指,都会被回收。这就是 Java 选择可达性分析的核心原因。

可达性分析需要"暂停世界"(STW)吗?

是的,标记阶段必须保证引用关系不变,否则遍历过程中引用被修改会导致漏标或错标。这就是为什么 GC 的标记阶段通常需要 Stop-The-World。不过现代收集器(如 ZGC)通过染色指针 + 读屏障技术,把大部分标记工作做到了并发阶段,只在极少数关键点短暂暂停。

核心结论

可达性分析解决了引用计数的循环引用难题,代价是需要 STW 或写屏障来保证遍历期间引用关系的一致性。

第 4 站

finalize():一次"自我救赎"的机会

在可达性分析判定一个对象"不可达"之后,JVM 并不会立刻回收它。如果这个对象重写了 Object.finalize() 方法,它会获得一次"复活"的机会

逃逸流程

  1. 对象被判定不可达,进入 F-Queue(Finalizer 队列)
  2. Finalizer 线程(低优先级守护线程)执行对象的 finalize() 方法
  3. 如果 finalize() 中把 this 赋给了某个 GC Root 可达的变量 → 对象复活
  4. 如果没复活 → 第二次标记后真正回收
finalize() 复活演示——仅作理解原理用,生产代码不要这么写
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-resourcesAutoCloseable)或 JDK 9 引入的 Cleaner(基于 PhantomReference)。永远不要依赖 finalize() 来关闭文件或连接。

第 5 站

四种引用:强、软、弱、虚

Java 在可达性分析的基础上,还提供了四种不同强度的引用类型,让你可以精细控制对象的生命周期

引用类型回收时机典型用途
强引用普通赋值永不回收(只要可达)绝大多数变量
软引用SoftReference内存不足时才回收内存敏感型缓存
弱引用WeakReference下次 GC 就回收ThreadLocal、WeakHashMap
虚引用PhantomReference随时回收,get() 返回 null跟踪对象被回收的时机

软引用:内存敏感的缓存

SoftReference 实现图片缓存
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() 更好的替代品

PhantomReferenceget() 永远返回 null,它唯一的用途是配合 ReferenceQueue获知对象何时被回收,从而执行清理操作。JDK 9 的 Cleaner 底层就是基于虚引用实现的。

一句话记忆

强引用 = 打死不回收;软引用 = 没内存才回收;弱引用 = GC 就回收;虚引用 = 只用来收通知。

第 6 站

回收方法区:类也能被卸载

很多人以为 GC 只管堆,其实方法区(元空间 / Metaspace)也会被回收。回收的对象主要是两类:废弃的类不再使用的字符串常量

类卸载的三个条件

一个类要被卸载,必须同时满足以下三个条件:

  1. 该类的所有实例都已经被回收(堆中不存在该类的任何对象)
  2. 加载该类的 ClassLoader 已经被回收
  3. 该类的 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 限制。如果动态生成大量类(如反射代理),不设置上限可能导致本地内存持续增长。"——这说明你有生产环境的排障经验。

第 7 站

总结:一张表 + 实战启示

两种方案对比

维度引用计数可达性分析
循环引用无法处理(需额外机制)天然免疫
实时性极好(归零即回收)依赖 GC 周期
停顿几乎无停顿标记阶段需 STW 或屏障
空间开销每个对象多一个计数器需要维护 GC Root 集合 + 遍历栈
实现复杂度高(图遍历、并发标记、写屏障)
代表语言Python / PHP / SwiftJava / 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 替代
  • 四种引用(强/软/弱/虚)让你精细控制对象生命周期
  • 方法区也可以回收:类卸载需同时满足三个条件