GC 判定算法

候选人小林在面试美团 P6 时,面试官问道:

"怎么判断一个对象是否应该被回收?"

小林说:"用引用计数法..."面试官追问:"引用计数法有什么问题?"

小林说:"有循环引用的问题..."面试官继续追问:"那 JVM 用的是什么算法?"

小林答不上来...

一、核心问题:GC 判定算法 🔴

1.1 问题拆解

第一层:判定方法(怎么判断?)
  "JVM 判断对象存活的算法是什么?"
  考察点:引用计数法 vs 根可达算法

第二层:根可达算法(怎么做?)
  "什么是 GC Roots?哪些可以作为 GC Roots?"
  考察点:线程相关根、类相关根、栈相关根

第三层:引用强度(怎么分类?)
  "强引用、软引用、弱引用、虚引用的区别是什么?"
  考察点:垃圾回收策略

1.2 ❌ 错误示范

候选人原话 A:"JVM 用的是引用计数法。"

问题诊断:JVM 用的是根可达算法(Reachability Analysis),不是引用计数法。引用计数法虽然简单,但无法处理循环引用。

候选人原话 B:"对象没有被引用就会被回收。"

问题诊断:对象即使没有被直接引用,如果通过引用链与 GC Roots 相连,仍然是可达的,不会被回收。

1.3 标准回答

P5 级别:两种判定算法

引用计数法(Reference Counting)

每个对象有一个引用计数器。当有新的引用指向对象时,计数器 +1;引用失效时,计数器 -1。计数器为 0 时,对象死亡。

优点:判定简单,回收及时 缺点:无法处理循环引用(A 引用 B,B 引用 A,但两者都不再被外部引用)

Python 使用引用计数 + GC:Python 虽然用引用计数回收大多数对象,但用额外的 GC 处理循环引用。

根可达算法(Reachability Analysis)

从一组根(GC Roots)出发,沿着引用链向下搜索。所有能够到达的对象是可达的(Reachable),存活;不能到达的对象是不可达的(Unreachable),可以回收。

graph TD
    R[GC Roots] --> A[Object A]
    R --> B[Object B]
    A --> C[Object C]
    A --> D[Object D]
    B --> D
    D -.-> E[Object E<br/>不可达]

    style E fill:#ff6b6b
    style R fill:#51cf66

JVM 使用根可达算法,因为它能正确处理循环引用。

P6 级别:GC Roots 的选择

可以作为 GC Roots 的对象

类型示例说明
栈帧中的引用局部变量表中的对象引用、方法参数当前执行方法的局部变量
JNI 引用JNI 栈中的全局引用和局部引用native 代码持有的 Java 对象引用
活跃线程Thread 对象、ThreadLocalMap正在运行的线程
类静态属性static 字段引用的对象静态变量持有的对象
常量引用字符串常量池中的字符串运行时常量池中的引用
监视器对象持有 synchronized 锁的对象ObjectMonitor
JVMTI 强引用JVMTI 代码持有的对象引用调试工具持有的引用

方法区作为 GC Roots 的特殊性

在 JDK 7 及之前,方法区的常量池中的字符串常量、静态引用可以作为 GC Roots。JDK 8 之后,字符串常量池移到了堆中。

P7 级别:引用的五种强度

JDK 1.2 引入的引用分类

引用类型回收时机典型用途
强引用(Strong Reference)永不回收(除非无引用)普通对象引用:Object obj = new Object()
软引用(Soft Reference)内存不足时回收(第一次 GC 不回收)缓存:SoftReference<T>
弱引用(Weak Reference)下一次 GC 时回收规范化映射:WeakHashMap<K,V>
虚引用(Phantom Reference)不影响回收,回收时收到通知对象生命周期跟踪:PhantomReference<T>
Final Reference对象 finalization 时回收对象 finalizer 的执行

虚引用的特殊用途

// 虚引用用于堆外内存的释放
// DirectByteBuffer 的 cleaner
DirectByteBuffer buffer = new DirectByteBuffer(capacity);
// buffer 对象被 GC 后,实际的堆外内存通过 Cleaner 释放

PhantomReference<Object> ref = new PhantomReference<>(obj, referenceQueue);
// ref.get() 永远返回 null
// 当 obj 被 GC 时,ref 被加入 referenceQueue
// 线程从 queue 中取出 ref,释放关联资源

【面试官心理】 这道题我能问到 P7 级别,是因为 GC 判定算法涉及了垃圾回收的理论基础和引用分类的高级应用。能说清虚引用用途的候选人说明他理解了引用分类的工程价值。

1.4 追问升级

追问:方法区需要 GC 吗?什么情况下方法区会回收?

方法区也需要 GC,但不是所有内容都值得回收:

  • 类卸载:当类的 ClassLoader 不再被引用,且该类没有实例时,可以卸载类(元数据回收)
  • 常量池回收:运行时常量池中的常量(与字符串常量池不同)

二、生产避坑 🟡

2.1 内存泄漏的 GC 表现

// 内存泄漏:static Map 不断添加
static Map<String, Object> cache = new HashMap<>();

public void addToCache(String key, Object value) {
    cache.put(key, value);  // 永远不会被移除
}

GC 表现:Minor GC 后存活对象数量持续增长,最终进入老年代,导致频繁 Full GC。

2.2 软引用缓存

// 软引用缓存:内存不足时回收
SoftReference<byte[]> cache = new SoftReference<>(new byte[1024 * 1024]);
// 内存不足时,byte[] 数组被回收
// 但 SoftReference 对象本身是强引用,需额外处理

三、四种引用强度对比 🟢

引用类型get() 返回值回收条件典型场景
强引用对象永不(除非无引用)普通变量
软引用对象或 null内存不足时缓存
弱引用对象或 null下次 GC缓存、规范化
虚引用永远 null不影响回收堆外内存释放
💡

面试加分点:能说出"JDK 9 引入了 java.lang.ref.Cleaner,替代了传统的 finalize() 方法,用于在对象被 GC 后执行清理任务(如释放堆外内存)",说明他关注了 JDK 9 的改进。

⚠️

面试陷阱:被问到"循环引用的对象会被 JVM 回收吗",很多人会说"不会"。准确答案是:会回收。JVM 使用根可达算法,循环引用的对象如果无法从 GC Roots 到达,就会被回收。