Skip to content

05.1 - 对象存活判定算法

定位: GC 的第一道工序——判断哪些对象还"活着",哪些可以回收 面试高频度: ⭐⭐⭐⭐⭐ 考查方式: 手写可达性分析流程、列举 GC Roots、四种引用类型使用场景

一、这是什么?为什么需要它?

GC 回收堆空间前必须回答一个问题:哪些内存可以回收?

如果你无法区分"正在使用的对象"和"不再需要的对象",你就无法安全地回收任何内存。两种判定思路:

  1. 引用计数: 每个对象上记录被引用的次数,计数归零时回收
  2. 可达性分析: 从一组已知的"根"出发遍历对象图,未到达的就是垃圾

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 常量)类约定的一部分,不能随便回收
4JNI 引用native 方法栈中的 Global/Weak 引用本地代码正在使用的对象
5同步锁synchronized 持有的 Monitor 对象锁对象被回收 = 锁丢失 = 并发崩溃
6JVM 内部引用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&lt;T&gt;内存不够时才回收(Full GC 前)内存敏感缓存(图片缓存)new SoftReference<>(bitmap)
弱引用WeakReference&lt;T&gt;每次 GC 都回收(无论内存是否充足)规范映射(WeakHashMap)、ThreadLocalnew WeakReference<>(obj)
虚引用PhantomReference&lt;T&gt;永远拿不到对象引用,仅通知机制DirectByteBuffer 回收通知new PhantomReference<>(obj, queue)

关键问答:ThreadLocal 为什么用 WeakReference?

java
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. 回收或保留              │
     │     存活 → 移动/标记        │
     │     死亡 → 回收空间         │
     └─────────────────────────────┘
                                         最终结果:
                │                        存活对象 → 保留
                ▼                        死亡对象 → 回收

四、实战验证

bash
# 验证一: 软引用在内存紧张时被回收
# 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 软引用已被回收
    }
}
bash
# 验证二: 弱引用每次 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索引**

Knowledge4J — Java 知识库