05.1 - 对象存活判定算法
定位: GC 的第一道工序——判断哪些对象还"活着",哪些可以回收 面试高频度: ⭐⭐⭐⭐⭐ 考查方式: 手写可达性分析流程、列举 GC Roots、四种引用类型使用场景
一、这是什么?为什么需要它?
GC 回收堆空间前必须回答一个问题:哪些内存可以回收?
如果你无法区分"正在使用的对象"和"不再需要的对象",你就无法安全地回收任何内存。两种判定思路:
- 引用计数: 每个对象上记录被引用的次数,计数归零时回收
- 可达性分析: 从一组已知的"根"出发遍历对象图,未到达的就是垃圾
JVM 选择的是后者。为什么? 这要从引用计数无法解决的循环引用说起。
二、原理拆解
2.1 引用计数(Reference Counting)——为什么不选它?
原理
每个对象维护一个 计数器。被引用时 +1,引用解除时 -1。计数器为 0 → 对象可以回收。
对象 A 被 obj1 引用 → A.count = 1
对象 B 被 obj2 引用 → B.count = 1
A 引用了 B → B.count = 2
B.count 减到 0 → B 被回收致命缺陷:循环引用(Circular Reference)
问题场景:
┌──────────────────────────────────────────────────────────────┐
│ │
│ GC Roots (栈帧) │
│ │ │
│ ▼ │
│ ┌──────┐ 引用 ┌──────┐ │
│ │ A │ ──────────→ │ B │ ← 循环引用问题 │
│ │ │ │ │ A 和 B 都被 Roots 间接引用 │
│ │ │ ←────────── │ │ 但遍历不到! │
│ └──────┘ 引用 └──────┘ │
│ │
│ 如果把 lastRefToA = null, lastRefToB = null: │
│ │
│ ┌──────┐ 引用 ┌──────┐ │
│ │ A │ ──────────→ │ B │ A.count = 1 (B 引用 A) │
│ │count=1│ │count=1│ B.count = 1 (A 引用 B) │
│ └──────┘ 引用 └──────┘ 永远达不到 0 → 内存泄漏! │
└──────────────────────────────────────────────────────────────┘没有人引用 A 和 B 了,但计数各自为 1 → 永远不会回收。Python 的 CPython 使用引用计数为主,但必须额外维护一个循环检测器来定期扫描和收集循环垃圾——这是额外的复杂度与性能开销。
JVM 的选择:与其修修补补,不如直接换一个不存在循环引用问题的算法。JVM 从一开始就选择了可达性分析。
2.2 可达性分析(Reachability Analysis)——JVM 的选择
核心思想
从一组 GC Roots 出发,执行有向图遍历。能从 Roots 到达的 → 存活;不能到达的 → 死亡可回收。
可达性分析示意:
┌────────────────────────────────────────────────────────────────┐
│ GC Roots │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ R1 │ │ R2 │ │ R3 │ │
│ └──┬──┘ └──┬──┘ └─────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌────────┐ ┌──────────┐ │
│ │ A │ │ B │ │
│ └───┬───┘ └────┬─────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ C │ │ D │ │ E │ ← 不可达 → 死亡可回收 │
│ └────────┘ └───┬────┘ └────────┘ │
│ │ │
│ ▼ │
│ ┌────────┐ ┌────────┐ │
│ │ F │ │ G │ ← 也不可达 │
│ └────────┘ └────────┘ │
│ │
│ 存活: A, B, C, D, F 死亡: E, G │
│ (D 存活因为 B→D 可达; F 存活因为 D→F 可达) │
└────────────────────────────────────────────────────────────────┘关键优势:循环引用不构成问题。即使 A↔B 互相引用,如果从任何一个 Root 都无法到达它们,两者都是垃圾。
缺陷:STW 问题
遍历对象图时,对象引用关系必须静止——如果一边遍历一边被应用修改(新的引用产生/旧引用消失),标记结果就不可靠。解决办法:Stop-The-World(暂停所有应用线程,只运行 GC 线程)。
这个"暂停"就是 GC 停顿的来源,也是每一代 GC 努力减少的核心指标。
2.3 GC Roots 详解
JVM 选择一组肯定不会被回收的引用作为遍历起点。面试时至少要说清楚前 4 类:
| # | GC Root 类型 | 具体内容 | 为什么它是 Root |
|---|---|---|---|
| 1 | 虚拟机栈引用 | 栈帧中局部变量表/操作数栈引用的对象 | 正在执行的方法依赖这些对象,回收会导致 NPE |
| 2 | 静态属性引用 | 类的 static 字段引用的对象 | 类的全局状态,回收会破坏类生命周期语义 |
| 3 | 常量引用 | 运行时常量池中的引用(String、final 常量) | 类约定的一部分,不能随便回收 |
| 4 | JNI 引用 | native 方法栈中的 Global/Weak 引用 | 本地代码正在使用的对象 |
| 5 | 同步锁 | synchronized 持有的 Monitor 对象 | 锁对象被回收 = 锁丢失 = 并发崩溃 |
| 6 | JVM 内部引用 | Class 对象、ClassLoader、常驻异常对象、系统类加载器 | JVM 运行的基础设施 |
GC Roots 在 JVM 运行时的分布:
┌─────────────────────────────────────────────────────────────────┐
│ 线程 1 栈 线程 2 栈 │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ 帧: getUser() │ │ 帧: processOrder() │ │
│ │ userService ───────│─→ Root │ order ─────────────│─→ Root │
│ └─────────────────────┘ └─────────────────────┘ │
│ │
│ 方法区 (Metaspace) 堆 (Heap) │
│ ┌────────────────────┐ ┌──────────────────────────┐ │
│ │ Class 对象 ────────│─→ Root │ 存活对象 ← Roots 到达 │ │
│ │ static 字段 ───────│─→ Root │ │ │
│ │ 常量池引用 ────────│─→ Root │ 死亡对象 ← Roots 未到达 │ │
│ └────────────────────┘ └──────────────────────────┘ │
│ │
│ 本地方法栈 同步锁 │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ JNI GlobalRef ─────│─→ Root │ synced(obj) ──────│─→ Root │
│ └────────────────────┘ └────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘2.4 四种引用类型(JDK 1.2+)
为什么需要 4 种引用?让开发者控制对象的 GC 行为——有些对象在内存充足时保留,不足时允许回收。
引用强度谱:
强不可达 ←───────────────────→ 弱可达
Strong Soft Weak Phantom
│ │ │ │
永不回收 内存不足时 每次 GC 永远拿不到
才回收 都回收 对象引用
强度: Strong > Soft > Weak > Phantom| 类型 | 类名 | GC 行为 | 典型用途 | 代码示例 |
|---|---|---|---|---|
| 强引用 | (普通赋值) | 只要可达,绝不回收 | 日常对象引用 | Object o = new Object() |
| 软引用 | SoftReference<T> | 内存不够时才回收(Full GC 前) | 内存敏感缓存(图片缓存) | new SoftReference<>(bitmap) |
| 弱引用 | WeakReference<T> | 每次 GC 都回收(无论内存是否充足) | 规范映射(WeakHashMap)、ThreadLocal | new WeakReference<>(obj) |
| 虚引用 | PhantomReference<T> | 永远拿不到对象引用,仅通知机制 | DirectByteBuffer 回收通知 | new PhantomReference<>(obj, queue) |
关键问答:ThreadLocal 为什么用 WeakReference?
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k); // key 是弱引用
value = v;
}
}
}如果不使用弱引用:ThreadLocalMap 的生命周期和线程一样长(线程池中的线程不会被销毁)。如果业务代码不再引用某个 ThreadLocal 对象(threadLocal = null),但 ThreadLocalMap.Entry 还持有强引用 → ThreadLocal 永远无法回收 → 内存泄漏。
使用弱引用后:ThreadLocal 对象只有 Entry 的 key 弱引用着它。业务代码放弃引用后,下一次 GC 就能回收 ThreadLocal。Entry.value 还需要手动清理(调用 remove())——这就是 ThreadLocal 内存泄漏的最终源头。弱引用解决了 key 的泄漏,但 value 的泄漏需要开发者配合。
2.5 finalize()——被废弃的"救赎"机制
为什么需要它?——历史原因
JDK 1.0 引入 finalize(),初衷是为开发者在对象被回收前提供一个"清理机会"(类比 C++ 析构函数)。但实际上几乎从未正常工作过。
为什么 JDK 9 标记废弃?
finalize() 的三大问题:
① 不可靠: finalize() 不保证被执行
System.gc() 和 Runtime.runFinalization() 只是"建议",不是命令
JVM 可能永远不调用 finalize() 直接就退出了
② 不可预测: finalize() 的执行线程优先级低
对象已经"死亡",但 finalize() 迟迟不被执行 → 对象占着内存不释放
在 finalize() 中执行耗时操作 → GC 停顿时间不可控
③ 性能致命: 对象需要至少 2 次 GC cycle 才能回收
第一次 GC: 发现对象不可达 → 放入 F-Queue → 执行 finalize()
第二次 GC: 确认对象仍不可达 → 回收内存
↓
如果一个对象覆盖了 finalize(),它永远不会被"快速回收"对象复活问题
这是 finalize() 最反直觉的特性:
finalize() 中的"复活":
GCRoots → null (对象不可达了)
│
▼
第一次 GC 发现死亡
│
▼
放入 F-Queue, 执行 finalize()
│
▼
if (finalize() 将 this 赋值给静态变量) {
→ 对象重新被 GC Roots 引用 → 复活!
→ 但 finalize() 只会被 JVM 调用一次
}
│
▼
对象再次不可达时 → 第二次 GC → 直接回收(不再执行 finalize())替代方案:java.lang.ref.Cleaner(JDK 9+)或 AutoCloseable + try-with-resources。清理逻辑应该由开发者显式控制,而不是依赖 GC 来触发。
三、图解全景
对象存活判定的完整流程:
对象分配在堆上
│
▼
应用线程继续运行
│
▼ (堆空间不足 → GC 触发)
┌─────────────────────────────┐
│ 1. 枚举 GC Roots │ ← STW 开始
│ (栈帧、静态变量、JNI等) │
└──────────┬──────────────────┘
▼
┌─────────────────────────────┐
│ 2. 可达性分析 (三色标记) │ ← 遍历对象图
│ 白: 未访问 │
│ 灰: 已访问,引用未扫描 │
│ 黑: 已访问,引用已扫描 │
└──────────┬──────────────────┘
▼
┌─────────────────────────────┐
│ 3. 判定引用类型 │
│ 强引用: 必须存活 │
│ 软引用: 看内存情况 │
│ 弱引用: 直接回收 │
│ 虚引用: 加入引用队列 │
└──────────┬──────────────────┘
▼
┌─────────────────────────────┐
│ 4. 处理 finalize() │ ← (已废弃,仅遗留系统涉及)
│ 有 finalize: 入 F-Queue │
│ 无 finalize: 直接标记 │
└──────────┬──────────────────┘
▼
┌─────────────────────────────┐
│ 5. 回收或保留 │
│ 存活 → 移动/标记 │
│ 死亡 → 回收空间 │
└─────────────────────────────┘
最终结果:
│ 存活对象 → 保留
▼ 死亡对象 → 回收四、实战验证
# 验证一: 软引用在内存紧张时被回收
# VM 参数: -Xms10m -Xmx10m -XX:+PrintGCDetails
import java.lang.ref.SoftReference;
public class SoftRefDemo {
public static void main(String[] args) {
SoftReference<byte[]> ref = new SoftReference<>(new byte[4 * 1024 * 1024]);
System.out.println("第一次: " + ref.get()); // 还能拿到
try { byte[][] holder = new byte[10][]; for (int i = 0; ; i++) holder[i] = new byte[1024 * 1024]; }
catch (OutOfMemoryError e) { /* 强制触发内存紧张 */ }
System.out.println("OOM后: " + ref.get()); // null — 软引用已被回收
}
}# 验证二: 弱引用每次 GC 都被回收
# VM 参数: -XX:+PrintGCDetails
import java.lang.ref.WeakReference;
public class WeakRefDemo {
public static void main(String[] args) {
WeakReference<Object> ref = new WeakReference<>(new Object());
System.out.println("GC前: " + ref.get()); // 有值
System.gc();
System.out.println("GC后: " + ref.get()); // null
}
}五、面试视角
| 追问 | 答案要点 |
|---|---|
| 引用计数有什么问题? | 无法处理循环引用。A↔B 互相引用但都不被 Roots 引用时,计数器永不归零。Python 需要额外 Cycle Detector 解决 |
| GC Roots 有哪些?(关键面试题) | 6 类:栈帧引用、静态属性、常量池引用、JNI 引用、同步锁对象、JVM 内部引用。最少答前 4 类 |
| 四种引用类型的使用场景? | 强引用=日常;软引用=缓存(内存不足时自动释放);弱引用=规范映射/ThreadLocal key;虚引用=DirectByteBuffer 回收通知 |
| WeakReference 为什么适合 ThreadLocal 的 key? | 防止 ThreadLocal 对象被业务代码放弃后仍被 Entry 强引用导致内存泄漏。弱引用使 ThreadLocal 可被下一次 GC 回收。但 value 仍需手动 remove() |
| finalize() 为什么被废弃? | 不可靠(不保证执行)、不可预测(GC 线程优先级低,时机不确定)、性能差(至少 2 次 GC 才能回收,可导致对象"复活") |
| 可达性分析为什么需要 STW? | 遍历对象图时若引用关系不断变化(应用线程新建/删除引用),标记结果不一致。必须暂停应用线程以冻结对象图。不同 GC 在"停多久"上做优化 |
| 对象头的 age 字段和引用类型有关系吗? | 不直接相关。age 在 MarkWord 的 4 bit 中,记录对象经历过 Minor GC 的次数。引用类型是 Reference 子类的语义行为,决定 GC 如何处理该引用 |
📚 相关链接
- **Java堆** — 判定存活后,对象在堆的代际间移动
- **对象内存布局(MarkWord与Klass与实例数据)** — MarkWord 中的 GC age 位
- **GC基本算法(标记清除与复制与标记整理)** — 判定存活后,用哪种算法回收?
- **OOM排查方法论** — 引用类型使用不当导致的 OOM
- ← 返回 **GC索引**