首页 / Java 学习笔记 / 26

JAVA · Vol.III · DAY 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 站

三色标记:并发标记与"漏标"难题

串行标记没什么可担心的:先 STW,再遍历,引用关系全程冻结。但并发标记(GC 线程和业务线程同时跑)有一个真问题——GC 线程遍历的同时,业务线程正在修改引用。为了精确描述这个问题,业界引入了三色标记模型。这是 GC 面试里问得最深的问题之一,必须讲透。

白色:GC 线程还没访问过
灰色:对象自身已访问,但它的引用对象(出边)还没遍历完
黑色:对象自身和它的全部出边都遍历完了(GC 认为"处理完毕")

串行标记就是个简单状态机:根是灰色 → 取出白对象压灰栈(白→灰)→ 取出灰对象遍历出边(灰→黑,发现的白出边转灰入栈)。全程 STW,所以正确。

白色 灰色 黑色 白→灰:压入灰栈 灰→黑:出边处理完 并发标记中的漏标场景 A(黑) B(白) C(灰) C 引用 A,A 已处理完 业务线程:A.newRef = B B 仍是白色 → 被误回收
图 3 三色状态机(左)与并发标记漏标场景(右)

右图就是对象漏标——并发标记必须解决的灾难。注意它需要同时满足两个条件才会发生:

  1. 被修改的是黑色对象的引用关系(灰色对象会被重新扫描,不受影响)
  2. 该对象新增了对白色对象的引用(删除旧引用不产生问题)

反过来有一类情况不需要解决:标记期间业务线程释放了对某白色对象的引用,之后该对象不再被任何根可达——它本来就是垃圾,这轮收和下轮收没区别。这种"标记期间新产生的垃圾"叫浮动垃圾,代价只是本轮收不掉,可以接受。

解法一:增量更新(CMS 用)

业务线程修改黑色对象的引用时,写屏障记录"这个黑色对象被改过"(加脏位)。并发标记结束后,GC 对所有被改过的黑色对象做一遍 STW 重新扫描(remark),把新增引用指向的白色对象标灰。重扫保证了不漏标,代价是:并发期间引用改得越多,remark 越久,而且这一段必须 STW——CMS 的 remark 阶段在生产里实测过不可忽略。

解法二:SATB 原始快照(G1 / ZGC 用)

Snapshot At The Begining,对"标记开始时刻的引用关系"做快照。实现:写屏障把被覆盖的旧引用压入引用变更栈,标记过程中 GC 线程并发消费这个栈——旧引用指向的白对象照样标灰继续遍历。思路是保守的:并发期间把"旧引用关系"视为始终有效。旧引用指向的对象就算实际已该死,也等下轮收(浮动垃圾多一些);绝不会误杀。

维度增量更新(CMS)SATB(G1 / ZGC)
记录什么黑色对象的新引用被覆盖的旧引用(压引用变更栈)
何时处理标记结束后 STW 重扫标记过程中并发消费栈
STW 开销remark 必须 STW,随修改量变长无额外重扫 STW
浮动垃圾较少较多(保守策略的代价)
额外内存仅脏位引用变更栈占内存
代表收集器CMSG1、ZGC

G1 为什么不用增量更新?它 SATB 多产生浮动垃圾的代价很明显。

增量更新的 remark 要"扫完所有被修改黑色对象的新增引用",工作量正比于并发期修改量,难以并行,且必须 STW——并发度越高越吃亏。SATB 的引用变更栈可以多个 GC 线程并行消费,和 G1 / ZGC 的并行化路线完全匹配,代价(多收不了点浮动垃圾)下一轮就回来了。所以新收集器统一转向 SATB。

高频追问:"SATB 保守"到底保守在哪?

两点:① 旧引用指向的对象即使该被回收,也照常遍历——多干了活;② 黑色对象新增的引用指向白对象时,那个白对象可能本轮被收——只要从快照看它确实不可达就合理。一句话:SATB "多留",绝不"误杀"。

一句话核心

三色标记的核心是一对破坏条件:黑色对象被修改 + 新增了对白对象的引用。增量更新重扫新引用,SATB 压旧引用进栈——CMS 选前者,G1 / ZGC 选后者。

第 5 站

写屏障:并发 GC 一致性的地基

上一站的每个机制——重扫、引用变更栈——都要回答同一个问题:"引用被修改的瞬间,改了谁"。这个信息正是写屏障(Write Barrier)提供的。它是 JVM 面试的高频词,而且特别容易和 JMM 的内存屏障混。

易混淆:写屏障 ≠ 内存屏障

写屏障是 JVM(JIT 编译器)在引用赋值点插入的一小段代码,给 GC 记录引用变化,服务"垃圾收集的正确性"。内存屏障(load/store barrier)是 CPU 层面的指令序列,给线程模型防指令重排、管可见性,服务"并发编程的正确性"。一个给 GC 用,一个给 JMM 用,面试千万别串台。

以引用赋值 a.b = c 为例:没有写屏障时,JIT 生成的就是一条普通 store;有了写屏障,赋值点会多一段调用:

伪代码:带写屏障的代码生成(JIT 自动插入,开发者无感)
// 你写的
a.b = c;

// JIT 实际生成的(简化)
a.b = c;                     // ① 真正的 store
write_barrier(a, "b", c);   // ② 写屏障:告知 GC "a.b 这个引用变了,新值是 c"

// write_barrier 内部按收集器分派:
// Serial/Parallel → 把赋值引用所在卡表卡标脏
// G1 → 标脏卡 + 若 c 在其他 Region,把 c 记入源 Region 的 RSet
// G1/ZGC 并发周期 → 额外把旧引用(a.b 的原值)压入引用变更栈(SATB)

所以写屏障实际是三件事的分发器

  1. 脏卡(Card Table):堆被划分成 512 字节一张的卡,写屏障把赋值引用所在卡标脏。Minor GC 不必重扫整个老年代,只看脏卡——这是 Minor GC 快的根源,呼应「JVM 堆内存」讲过的"老年代怎么引用年轻代"。
  2. RSet(Remembered Set,G1):Region 之间互相引用时,把目标对象记在源 Region 的 RSet 里,G1 才能精确地只回收目标 Region。
  3. 引用变更栈(SATB,G1 / ZGC):压入被覆盖的旧引用,由并发标记线程消费(见上一站)。
没有写屏障,并发收集只有两个选择:全堆重扫(太慢)或漏标(错误)
写屏障是"修改瞬间被打点"的唯一低成本方式——并发 GC 一致性的地基
一句话核心

写屏障 = 引用赋值瞬间插入的额外代码,三职:标脏卡(Minor GC)、记 RSet(G1)、压引用变更栈(SATB)。它不是 JMM 的内存屏障——一个给 GC,一个给线程模型。

第 6 站

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() 来关闭文件或连接。

第 7 站

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

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 之前,所有软引用对象都会被回收。所以它非常适合做"有就用,没有也能活"的缓存。

注意这是"尽力"语义:JVM 只是保证 OOM 前"会"回收软引用,不保证"内存一紧张立刻回收"。实践中可以用 -XX:SoftRefLRUPolicyMSPerMB(默认 500)调节——含义是"每 1MB 空闲堆,软引用对象多存活 500 毫秒"。空闲堆越大,软引用活得越久,所以纯软引用实现的缓存经常出现"想挤掉挤不掉"。想要精确过期时间,用 Caffeine / Guava Cache,别依赖软引用。

弱引用:ThreadLocal 的坑与解

ThreadLocalMap 的 key 就是 WeakReference<ThreadLocal>。当 ThreadLocal 实例不再有强引用时,下次 GC 就会回收 key,但 value 仍然被线程持有。如果线程长期存活(如线程池),value 就泄漏了——这就是经典的 ThreadLocal 内存泄漏。解法是每次用完手动 remove()

另一个常见场景是 WeakHashMap:key 是弱引用,key 对象被回收后这条 entry 就成了"陈旧项";map 在执行写操作(put/remove/clear)时会顺带执行 expungeStaleEntries(),把 key 已失效的 entry 清理掉。注意它只防"key 侧"泄漏——value 如果还被外部强引用,照样活着。

ThreadLocal 的 key 为什么是弱引用?换成强引用不是更安全吗?

恰恰相反:key 若是强引用,业务代码用完后,线程池里长期存活的线程的 Entry 仍会一直持有 ThreadLocal 实例,它永远无法回收。key 做成弱引用,业务一放手 key 就能被回收,ThreadLocal 实例才有"死"的机会。但 key 回收后 value 仍被 Entry 挂着,value 就漏了——所以必须手动 remove()。这是 JVM 的"半防御"设计:key 的回收它管,value 的清理留给你。(完整分析见并发卷的 ThreadLocal 专题)

虚引用:比 finalize() 更好的替代品

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

一个重要的实战用途是释放堆外资源DirectByteBuffer 的内存分配在堆外(直接内存),不直接参与堆的 GC。JVM 在 buffer 对象上挂一个 Cleaner(基于虚引用),buffer 对象被回收时 Cleaner 运行,去释放那块堆外内存——这就是"对象回收 → 释放本地资源"的链路,也是 -XX:MaxDirectMemorySize 生效的背景。

Reference 家族的统一结构

四种引用内部结构完全一致:引用对象 → referent(被引用对象)→ ReferenceQueue。引用对象的 referent 字段真正指向目标;目标对象被回收时,GC 把引用对象本身放入构造时传入的 ReferenceQueue。所以从队列里取到的是"引用对象",不是业务对象本身——通知的时机是"引用所指向的对象已回收"。四种引用的区别只在于 GC 在什么条件下清空 referent,其余机制共用一套代码。

一句话记忆

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

第 8 站

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

很多人以为 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()

一个容易忽略的细节:堆里的字符串表(string table)本质是一张哈希表,桶数量由 -XX:StringTableSize 控制(默认 60011)。大量 intern 字符串发生哈希冲突时,查表成本会上升;GC 日志里若提示字符串表占用过高,可以调大该参数,或者从代码上减少 intern 的使用。

面试加分项

提到"Metaspace 默认没有上限,可以通过 -XX:MaxMetaspaceSize 限制。如果动态生成大量类(如反射代理),不设置上限可能导致本地内存持续增长。"——这说明你有生产环境的排障经验。

第 9 站

总结:一张表 + 实战启示

两种方案对比

维度引用计数可达性分析
循环引用无法处理(需额外机制)天然免疫
实时性极好(归零即回收)依赖 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 替代
  • 四种引用(强/软/弱/虚)让你精细控制对象生命周期
  • 方法区也可以回收:类卸载需同时满足三个条件

Comments · 评论