JAVA · Vol.III · DAY 26 · JVM 原理与调优
对象存活判定:引用计数 vs 可达性分析
开场:这个对象还要不要?
面试官问:"JVM 怎么知道一个对象可以被回收?" 很多人答"没有引用指向它"——这个答案只对了一半。
垃圾回收的第一步不是"怎么回收",而是"怎么判断一个对象还活着"。如果判错了,要么把活对象删了(程序崩溃),要么把死对象留着(内存泄漏)。这是 GC 最底层的问题。
业界有两种主流方案:
| 方案 | 核心思想 | 代表语言 | 致命弱点 |
|---|---|---|---|
| 引用计数 | 给每个对象维护一个计数器 | Python / PHP / Swift | 循环引用 |
| 可达性分析 | 从根节点出发做图遍历 | Java / C# / Go / JS | 需要 STW 或写屏障 |
Java 选择了可达性分析,Python 选择了引用计数(加上循环引用检测)。为什么它们做了不同的选择? 这就是本文要讲清楚的事。
不要只回答"没有引用就回收"。要说清楚"JVM 用的是可达性分析,不是简单的引用计数,因为引用计数无法处理循环引用"——一句话就把两个方案都点到了。
引用计数:最直觉的方案
引用计数的规则非常简单:
- 每个对象有一个整型字段
refCount - 有新的引用指向它 →
refCount++ - 引用离开作用域或被置空 →
refCount-- refCount == 0→ 立即回收
class RefCountedObject { int refCount = 0; void retain() { refCount++; } void release() { refCount--; if (refCount == 0) { deallocate(this); // 立即回收内存 } } }
优点
- 实时性好:计数归零的那一刻就回收,不需要等 GC 周期
- 实现简单:不需要暂停程序,不需要复杂的图遍历
- 回收粒度细:每次只处理单个对象,停顿极短
致命缺陷:循环引用
当 A 引用 B、B 又引用 A 时,即使外部没有任何变量再指向它们,两者的 refCount 都是 1,永远不会归零。这两个对象就成了"孤岛"——活着但永远无法到达。
Python 怎么解决这个问题?
CPython 在引用计数之上加了一个循环垃圾收集器(cycle detector),定期扫描所有容器对象,找出不可达的循环引用组并打破它们。本质上是在引用计数之上补了一层"简化版可达性分析"。PHP 也用类似策略。
提到"Python 用引用计数 + 循环检测"比只说"Python 用引用计数"更准确。Swift 则用 ARC + weak/unowned 引用让开发者手动打破循环。
可达性分析:从根出发,能走到就是活的
Java、C#、Go、JavaScript 的 GC 都采用可达性分析(Reachability Analysis)。核心思路只有一句话:
哪些东西可以作为 GC Roots?
- 栈帧中的局部变量(每个线程的虚拟机栈中引用的对象)
- 方法区中的静态变量(
static字段引用的对象) - 方法区中的常量(
final static引用的对象) - JNI(Native 方法)引用的对象
- 同步监视器(synchronized 持有的对象)
注意图中 Obj6 和 Obj7 互相引用,但没有任何 GC Root 能遍历到它们。可达性分析天然免疫循环引用——只要从根走不到,不管你内部怎么互指,都会被回收。这就是 Java 选择可达性分析的核心原因。
可达性分析需要"暂停世界"(STW)吗?
是的,标记阶段必须保证引用关系不变,否则遍历过程中引用被修改会导致漏标或错标。这就是为什么 GC 的标记阶段通常需要 Stop-The-World。不过现代收集器(如 ZGC)通过染色指针 + 读屏障技术,把大部分标记工作做到了并发阶段,只在极少数关键点短暂暂停。
可达性分析解决了引用计数的循环引用难题,代价是需要 STW 或写屏障来保证遍历期间引用关系的一致性。
三色标记:并发标记与"漏标"难题
串行标记没什么可担心的:先 STW,再遍历,引用关系全程冻结。但并发标记(GC 线程和业务线程同时跑)有一个真问题——GC 线程遍历的同时,业务线程正在修改引用。为了精确描述这个问题,业界引入了三色标记模型。这是 GC 面试里问得最深的问题之一,必须讲透。
灰色:对象自身已访问,但它的引用对象(出边)还没遍历完
黑色:对象自身和它的全部出边都遍历完了(GC 认为"处理完毕")
串行标记就是个简单状态机:根是灰色 → 取出白对象压灰栈(白→灰)→ 取出灰对象遍历出边(灰→黑,发现的白出边转灰入栈)。全程 STW,所以正确。
右图就是对象漏标——并发标记必须解决的灾难。注意它需要同时满足两个条件才会发生:
- 被修改的是黑色对象的引用关系(灰色对象会被重新扫描,不受影响)
- 该对象新增了对白色对象的引用(删除旧引用不产生问题)
反过来有一类情况不需要解决:标记期间业务线程释放了对某白色对象的引用,之后该对象不再被任何根可达——它本来就是垃圾,这轮收和下轮收没区别。这种"标记期间新产生的垃圾"叫浮动垃圾,代价只是本轮收不掉,可以接受。
解法一:增量更新(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 |
| 浮动垃圾 | 较少 | 较多(保守策略的代价) |
| 额外内存 | 仅脏位 | 引用变更栈占内存 |
| 代表收集器 | CMS | G1、ZGC |
G1 为什么不用增量更新?它 SATB 多产生浮动垃圾的代价很明显。
增量更新的 remark 要"扫完所有被修改黑色对象的新增引用",工作量正比于并发期修改量,难以并行,且必须 STW——并发度越高越吃亏。SATB 的引用变更栈可以多个 GC 线程并行消费,和 G1 / ZGC 的并行化路线完全匹配,代价(多收不了点浮动垃圾)下一轮就回来了。所以新收集器统一转向 SATB。
两点:① 旧引用指向的对象即使该被回收,也照常遍历——多干了活;② 黑色对象新增的引用指向白对象时,那个白对象可能本轮被收——只要从快照看它确实不可达就合理。一句话:SATB "多留",绝不"误杀"。
三色标记的核心是一对破坏条件:黑色对象被修改 + 新增了对白对象的引用。增量更新重扫新引用,SATB 压旧引用进栈——CMS 选前者,G1 / ZGC 选后者。
写屏障:并发 GC 一致性的地基
上一站的每个机制——重扫、引用变更栈——都要回答同一个问题:"引用被修改的瞬间,改了谁"。这个信息正是写屏障(Write Barrier)提供的。它是 JVM 面试的高频词,而且特别容易和 JMM 的内存屏障混。
写屏障是 JVM(JIT 编译器)在引用赋值点插入的一小段代码,给 GC 记录引用变化,服务"垃圾收集的正确性"。内存屏障(load/store barrier)是 CPU 层面的指令序列,给线程模型防指令重排、管可见性,服务"并发编程的正确性"。一个给 GC 用,一个给 JMM 用,面试千万别串台。
以引用赋值 a.b = c 为例:没有写屏障时,JIT 生成的就是一条普通 store;有了写屏障,赋值点会多一段调用:
// 你写的 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)
所以写屏障实际是三件事的分发器:
- 脏卡(Card Table):堆被划分成 512 字节一张的卡,写屏障把赋值引用所在卡标脏。Minor GC 不必重扫整个老年代,只看脏卡——这是 Minor GC 快的根源,呼应「JVM 堆内存」讲过的"老年代怎么引用年轻代"。
- RSet(Remembered Set,G1):Region 之间互相引用时,把目标对象记在源 Region 的 RSet 里,G1 才能精确地只回收目标 Region。
- 引用变更栈(SATB,G1 / ZGC):压入被覆盖的旧引用,由并发标记线程消费(见上一站)。
写屏障是"修改瞬间被打点"的唯一低成本方式——并发 GC 一致性的地基
写屏障 = 引用赋值瞬间插入的额外代码,三职:标脏卡(Minor GC)、记 RSet(G1)、压引用变更栈(SATB)。它不是 JMM 的内存屏障——一个给 GC,一个给线程模型。
finalize():一次"自我救赎"的机会
在可达性分析判定一个对象"不可达"之后,JVM 并不会立刻回收它。如果这个对象重写了 Object.finalize() 方法,它会获得一次"复活"的机会。
逃逸流程
- 对象被判定不可达,进入 F-Queue(Finalizer 队列)
- Finalizer 线程(低优先级守护线程)执行对象的
finalize()方法 - 如果
finalize()中把this赋给了某个 GC Root 可达的变量 → 对象复活 - 如果没复活 → 第二次标记后真正回收
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-resources(AutoCloseable)或 JDK 9 引入的 Cleaner(基于 PhantomReference)。永远不要依赖 finalize() 来关闭文件或连接。
四种引用:强、软、弱、虚
Java 在可达性分析的基础上,还提供了四种不同强度的引用类型,让你可以精细控制对象的生命周期:
| 引用类型 | 类 | 回收时机 | 典型用途 |
|---|---|---|---|
| 强引用 | 普通赋值 | 永不回收(只要可达) | 绝大多数变量 |
| 软引用 | SoftReference | 内存不足时才回收 | 内存敏感型缓存 |
| 弱引用 | WeakReference | 下次 GC 就回收 | ThreadLocal、WeakHashMap |
| 虚引用 | PhantomReference | 随时回收,get() 返回 null | 跟踪对象被回收的时机 |
软引用:内存敏感的缓存
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() 更好的替代品
PhantomReference 的 get() 永远返回 null,它唯一的用途是配合 ReferenceQueue 来获知对象何时被回收,从而执行清理操作。JDK 9 的 Cleaner 底层就是基于虚引用实现的。
一个重要的实战用途是释放堆外资源:DirectByteBuffer 的内存分配在堆外(直接内存),不直接参与堆的 GC。JVM 在 buffer 对象上挂一个 Cleaner(基于虚引用),buffer 对象被回收时 Cleaner 运行,去释放那块堆外内存——这就是"对象回收 → 释放本地资源"的链路,也是 -XX:MaxDirectMemorySize 生效的背景。
Reference 家族的统一结构
四种引用内部结构完全一致:引用对象 → referent(被引用对象)→ ReferenceQueue。引用对象的 referent 字段真正指向目标;目标对象被回收时,GC 把引用对象本身放入构造时传入的 ReferenceQueue。所以从队列里取到的是"引用对象",不是业务对象本身——通知的时机是"引用所指向的对象已回收"。四种引用的区别只在于 GC 在什么条件下清空 referent,其余机制共用一套代码。
强引用 = 打死不回收;软引用 = 没内存才回收;弱引用 = GC 就回收;虚引用 = 只用来收通知。
回收方法区:类也能被卸载
很多人以为 GC 只管堆,其实方法区(元空间 / Metaspace)也会被回收。回收的对象主要是两类:废弃的类和不再使用的字符串常量。
类卸载的三个条件
一个类要被卸载,必须同时满足以下三个条件:
- 该类的所有实例都已经被回收(堆中不存在该类的任何对象)
- 加载该类的 ClassLoader 已经被回收
- 该类的
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 限制。如果动态生成大量类(如反射代理),不设置上限可能导致本地内存持续增长。"——这说明你有生产环境的排障经验。
总结:一张表 + 实战启示
两种方案对比
| 维度 | 引用计数 | 可达性分析 |
|---|---|---|
| 循环引用 | 无法处理(需额外机制) | 天然免疫 |
| 实时性 | 极好(归零即回收) | 依赖 GC 周期 |
| 停顿 | 几乎无停顿 | 标记阶段需 STW 或屏障 |
| 空间开销 | 每个对象多一个计数器 | 需要维护 GC Root 集合 + 遍历栈 |
| 实现复杂度 | 低 | 高(图遍历、并发标记、写屏障) |
| 代表语言 | Python / PHP / Swift | Java / 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 · 评论