《Java 虚拟机原理与性能调优》5.1 可达性分析与引用类型:从 GC Root 到 Cleaner

libraryd 的 LoanCache 清了引用还占着 1.2 GB。本节用 GC Root 实验、弱/软/虚引用与 Cleaner 的真实输出,量出 -Xmx64m 下软引用只剩 16/4096,并解释 WeakHashMap 为什么留不住条目。

本节目标:说清「对象还算活着」由谁判定,以及强、软、弱、虚四种引用和 Cleaner 在 libraryd 的缓存里各自该出现在哪。
适用版本:JDK 25(Temurin 25.0.5)。Cleaner 自 JDK 9 提供;finalize 自 JDK 18 起标记为 deprecated for removal(JEP 421)。

承接的问题

libraryd 的借阅路径每次都要读 Book,热路径上重复查库代价不低。第四卷之前加过一个 LoanCache,思路很朴素:把 Loan 按 ISBN 放进 WeakHashMap,让 GC 在内存紧张时自己清。

上线一周后,RuntimeProbe 的快照显示堆稳定在 4 GB 上限附近,GC.heap_info 里老年代占 3.2 GB,而 jcmd GC.class_histogram 排第一的是 com.plume.library.domain.Loan 和它的 byte[] 回执附件。现象很清楚:

  • 缓存条目数远小于「预期应该被回收」的数量;
  • 每次 Full GC 之后堆只降一点点,下一次又涨回来;
  • 把 LoanCache.clear() 调一次,堆立刻降 1.1 GB。

所以问题不是「对象泄漏到了 GC 看不见的地方」,而是我们以为它不可达,JVM 认为它可达。这一节要回答四个问题:

  1. GC 判定「可达」的起点到底有哪些?
  2. WeakHashMap 的 key 什么时候真的会被清掉?
  3. SoftReference 在内存压力下能保住多少条目?
  4. 缓存对象需要释放堆外资源时,Cleaner 的时序是什么?

GC Root:可达性的起点

HotSpot 用的是可达性分析,不是引用计数。它从一组称为 GC Root 的对象出发,沿引用链遍历,遍历不到的对象才算垃圾。常见 Root 有五类:

Root 来源典型例子libraryd 里的对应物
虚拟机栈帧的局部变量表方法参数、局部变量LoanService.borrow 循环里的 Loan 变量
方法区(元空间)的静态字段static final 常量池、单例FinePolicy.DEFAULT、连接池单例
JNI 引用本地方法持有的全局/局部引用JDBC 驱动传给 native 的句柄
活动线程每个 Thread 对象本身虚拟线程的载体线程、JFR 采集线程
同步监视器被 synchronized 持有的对象旧版 DashboardService 的计数器锁

前两类占了绝大多数「以为泄漏」的现场。下面这个实验把「局部变量是 Root」和「静态字段是 Root」分开测。RootLab 每次分配 8 MB,用 Runtime 读已用堆,-Xms256m -Xmx256m 固定堆避免扩容干扰:

public class RootLab {

    static byte[] STATIC_HOLD;

    public static void main(String[] args) throws Exception {
        System.out.println("start            used=" + usedMb() + "M");
        byte[] local = new byte[8 * 1024 * 1024];
        System.out.println("local allocated  used=" + usedMb() + "M");
        System.gc();
        Thread.sleep(200);
        System.out.println("gc with local    used=" + usedMb() + "M");
        local = null;
        System.gc();
        Thread.sleep(200);
        System.out.println("gc after null    used=" + usedMb() + "M");
        STATIC_HOLD = new byte[8 * 1024 * 1024];
        System.gc();
        Thread.sleep(200);
        System.out.println("gc with static   used=" + usedMb() + "M");
        STATIC_HOLD = null;
        System.gc();
        Thread.sleep(200);
        System.out.println("gc after clear   used=" + usedMb() + "M");
    }

    static long usedMb() {
        Runtime runtime = Runtime.getRuntime();
        return (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024);
    }
}

本机输出(Apple M1 Pro,10 核,32 GB,macOS 26.3,Temurin 25.0.5+8,单次运行):

start            used=2M
local allocated  used=11M
gc with local    used=10M
gc after null    used=1M
static allocated used=10M
gc with static   used=10M
gc after clear   used=1M

同一份代码用 -Xint 关掉 JIT,输出完全一致,说明这不是「编译器把变量优化没了」的偶然现象。两个结论:

  • local 还在局部变量表里时,它就是 Root,System.gc() 不会回收那 8 MB;
  • 把 static 字段置空后,8 MB 立刻变成 1 MB 的基线。

这条规则直接解释了 LoanCache 的第一类问题:只要业务代码里还有一个 Map<String, Loan> 在方法返回值之外被引用,缓存里的 Loan 就全部可达,WeakHashMap 里的弱引用根本没有机会被清。

四种引用:强度、队列与时机

强引用是最普通的那种,Object o = new Object() 就是强引用,只要可达链不断,对象不会被回收。

另外三种引用都把「是否算可达」这件事交给 GC 判断,区别在清理的迫切程度和是否收到通知:

引用get()回收时机配合队列
SoftReference返回对象或 null内存不足时,GC 在 OOM 前清可选
WeakReference返回对象或 null下一次 GC,只要没有强引用可选
PhantomReference永远返回 null对象已 finalize、即将回收必须

弱引用与虚引用的实验:

Book book = new Book("9780134685991", 256);
WeakReference<Book> weak = new WeakReference<>(book);
System.out.println("weak before: " + weak.get());
book = null;
System.gc();
Thread.sleep(300);
System.out.println("weak after gc: " + weak.get());

ReferenceQueue<Book> queue = new ReferenceQueue<>();
Book other = new Book("9780134685992", 128);
PhantomReference<Book> phantom = new PhantomReference<>(other, queue);
System.out.println("phantom get before: " + phantom.get());
other = null;
System.gc();
Thread.sleep(300);
System.out.println("phantom enqueued: " + (queue.poll() == phantom));
System.out.println("phantom get after: " + phantom.get());

本机输出:

weak before: Book[9780134685991]
weak after gc: null
phantom get before: null
phantom enqueued: true
phantom get after: null

注意 phantom get before 也是 null。虚引用的语义就是「拿不到对象」,它只提供一个「对象已经死了」的通知,让你在对象内存被真正复用前做清理。这也是它比 finalize 可靠的地方:finalize 可能让对象「复活」,而虚引用不会。

System.gc() 在这里起了作用,但它只是建议。JDK 25 默认不忽略它,加了 -XX:+DisableExplicitGC 就会忽略。同一个实验加上这个参数:

本机输出(java -XX:+DisableExplicitGC -cp out RefLab weak):

weak before: Book[9780134685991]
weak after gc: Book[9780134685991]
phantom get before: null

弱引用没有被清,说明那次 System.gc() 被完全跳过。线上代码不要依赖 System.gc(),也不要依赖「调用它就能触发一次完整回收」;它是否执行、执行成什么形态,都由收集器和参数决定。

软引用在内存压力下能保住多少

软引用的常见用途是「内存充足时当缓存,紧张时自动让路」。听起来正好适合 LoanCache,但它的行为不可精确控制。用 -Xmx64m 连续分配 4096 个 1 MB 的块,每个块只被一个 SoftReference 持有:

List<SoftReference<byte[]>> refs = new ArrayList<>();
for (int i = 0; i < 4096; i++) {
    byte[] block = new byte[1024 * 1024];
    refs.add(new SoftReference<>(block));
    block = null;
}
int survived = 0;
for (SoftReference<byte[]> ref : refs) {
    if (ref.get() != null) {
        survived++;
    }
}
System.out.println("soft survived: " + survived + "/" + refs.size());

本机输出(java -Xmx64m -cp out RefLab soft):

soft survived: 16/4096

4096 个条目只剩 16 个。这说明两件事:

  • 软引用确实会在 OOM 前被清,程序没有崩;
  • 清多少、留哪些,完全由 GC 的回收策略和当时的堆状态决定,不是 LRU,也不是按访问频率。

换成更接近缓存形状的实验:4000 个 Loan,每个带 128 KB 附件,用 Map<String, SoftReference<Loan>> 保存,-Xmx64m:

本机输出:

soft entries=4000 live=297
sample hit=false

4000 个 key 都还在 map 里,只有 297 个 value 还活着。entries 的数量不能反映缓存命中率,因为 map 本身持有的是 SoftReference 对象,不是 Loan。更要紧的是 sample hit=false:第一个放进去的条目已经被清掉了。软引用缓存没有「热点保护」,冷热数据被一视同仁地清。

Cleaner:清理动作不等于对象回收

Cleaner 是 JDK 9 引入的替代方案,适合管理堆外资源(直接内存、文件句柄、native 句柄)。libraryd 的导出模块会把结果写到临时文件,句柄就适合用 Cleaner 兜底。

关键约束只有一条:注册给 Cleaner 的清理动作不能持有被清理对象本身。否则清理动作 → 对象 → 引用链一直在,对象永远不回收。

static final Cleaner CLEANER = Cleaner.create();

static final class Resource implements AutoCloseable {
    private final Cleaner.Cleanable cleanable;
    private final String name;
    Resource(String name) {
        this.name = name;
        this.cleanable = CLEANER.register(this, new Handle(name));
    }
    @Override public void close() {
        System.out.println("explicit close " + name);
        cleanable.clean();
    }
}

Handle 只持有名字字符串,不持有 Resource。注册 5 个对象再丢掉引用:

本机输出:

registered 5, dropping references
clean r4 #1 thread=Cleaner-0
clean r3 #2 thread=Cleaner-0
clean r2 #3 thread=Cleaner-0
clean r1 #4 thread=Cleaner-0
clean r0 #5 thread=Cleaner-0
--- explicit close path ---
explicit close explicit
clean explicit #6 thread=main
explicit close explicit
done

三个可复现的事实:

  • 清理顺序是注册的逆序(r4 到 r0)。这是 Cleaner 内部链表头插的实现细节,不要把它当契约,清理顺序不该被业务依赖。
  • 清理线程是专门的 Cleaner-0,不是你创建的任何线程。
  • close() 调用两次,第二次不再执行清理动作。Cleanable.clean() 幂等,这正是 Cleaner 比 finalize 好用的地方:显式关闭和 GC 兜底可以走同一条路径。

清理动作在对象不可达之后才执行,所以它拿不到对象状态。需要清理的数据必须在注册时通过 Handle 的字段传进去(比如文件路径、native 指针),不能指望在 run() 里读 this.resource。

在 libraryd 里落地:LoanCache 的三次修正

第一版就是出问题的版本:

// 反例:value 反向引用 key,弱引用永远不会被清
public final class LoanCache {
    private final Map<String, Loan> entries = new WeakHashMap<>();
    public void put(Loan loan) { entries.put(loan.isbn(), loan); }
}

Loan 是 record,isbn() 返回的正是 map 的 key 对象。value 被 map 强引用,value 又强引用 key,于是 key 始终可达,弱引用形同虚设。用两个用例可以量出差别:

本机输出(java -Xmx64m -cp out CacheLab):

A payload cache after gc: 0
B loan cache after gc: 1

A 是 WeakHashMap<String, byte[]>,value 不引用 key,GC 后条目清零;B 是 WeakHashMap<String, Loan>,Loan.isbn 就是 key,GC 后条目还在。这不是实现 bug,是 WeakHashMap 的定义:它只弱引用 key,不负责切断 value 到 key 的路径。

第二版改用 SoftReference,解决了强引用问题,但引入了上一节的两个毛病:命中率不可控、没有容量上限。4000 个条目只剩 297 个,第一个放进去的已经没了。

第三版是现在 libraryd 用的形状:显式容量上限 + LRU 淘汰 + 软引用兜底。

package com.plume.library.app;

import com.plume.library.domain.Loan;

import java.lang.ref.SoftReference;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;

public final class LoanCache {

    private static final int MAX_ENTRIES = 10_000;

    private final Map<String, SoftReference<Loan>> entries =
            new LinkedHashMap<>(1024, 0.75f, true) {
                @Override
                protected boolean removeEldestEntry(
                        Map.Entry<String, SoftReference<Loan>> eldest) {
                    return size() > MAX_ENTRIES;
                }
            };

    public Optional<Loan> get(String isbn) {
        SoftReference<Loan> ref = entries.get(isbn);
        return Optional.ofNullable(ref == null ? null : ref.get());
    }

    public void put(Loan loan) {
        entries.put(loan.isbn(), new SoftReference<>(loan));
    }

    public int size() {
        return entries.size();
    }
}

LinkedHashMap 的第三个构造参数 accessOrder=true 让每次 get 把条目移到链表尾部,removeEldestEntry 在超过 1 万条时淘汰头部,这就是 LRU。外层的 SoftReference 只作为内存压力下的第二道闸:即使容量没满,GC 也可以提前清掉一部分 value。

这套组合的语义要写清楚:

  • size() 是条目数,不是有效缓存数;get 返回 Optional.empty() 时才代表 miss。
  • LRU 只保护最近访问的 1 万条,软引用不保证这 1 万条都活着。
  • 缓存命中率要用 get 的返回值为准统计,不能用 size()。

LoanCache 只缓存可重建的读模型,不缓存事务中的可变状态。借阅上限、库存这类判断仍然走数据库,因为缓存里的值随时可能被 GC 清掉,不能作为业务判断的依据。

常见错误与边界

错误一:把 WeakHashMap 当通用缓存。 只要 value 里有 key 的引用(record 的字段、toString 缓存的字符串、日志上下文),弱引用就不会生效。上文本机实验里 B 用例的 size() 在 GC 后仍是 1。要弱引用生效,value 必须是「不回头引用 key」的独立对象。

错误二:用 SoftReference 做无上限缓存。 无上限的软引用缓存会在内存压力下被大面积清空,命中率骤降,而 map 本身还在增长。本机 -Xmx64m 下 4000 条只剩 297 条,这个比例不能当作容量规划依据。容量上限必须显式给。

错误三:Cleaner 的清理动作持有外部对象。

// 反例:lambda 捕获了 resource,清理动作反过来让对象一直可达
CLEANER.register(resource, () -> resource.close());

这个 lambda 捕获了 resource,而 Cleaner 的链表从 Root 可达,于是 resource 永远不可达不了,清理永远不会触发。正确写法是把需要的数据抽成独立字段,或者用静态嵌套类并在构造时只传必要参数。

错误四:以为 System.gc() 一定回收。 本机加 -XX:+DisableExplicitGC 后弱引用没有被清。反过来,即使不加这个参数,System.gc() 在 G1 下通常触发一次 Full GC,停顿可能远大于日常 Young GC;线上不要用它做「手动清理」。

边界一:finalize 不能用。 JDK 25 里 Object.finalize() 已被标记为 deprecated for removal,--finalization=disabled 可以整体关闭终结机制(本机 java --finalization=disabled -version 正常启动)。终结器线程的执行时机不确定,还可能让对象复活,不适合任何资源释放场景。

边界二:引用类型不改变「什么时候停顿」。 弱引用、虚引用只是让对象更早进入可回收集合,回收动作仍然发生在某次 GC 里。想降低停顿,要换收集器或调堆,不是换引用类型。

小结

  • 可达性从 GC Root 出发,局部变量表、静态字段、JNI 引用、活动线程、同步监视器都是 Root;本机实验中局部变量在栈帧内时 8 MB 不会被回收。
  • 强引用保活,软引用在 OOM 前被清,弱引用在下一次 GC 被清,虚引用只提供入队通知且 get() 永远返回 null。
  • System.gc() 只是建议;本机加 -XX:+DisableExplicitGC 后弱引用没有被清。
  • 软引用缓存没有热点保护:-Xmx64m 下 4096 个 1 MB 块只剩 16 个,4000 条 Loan 缓存只剩 297 条。
  • WeakHashMap 的 key 会被 value 反向引用救活;本机 B 用例 GC 后条目数仍为 1。
  • Cleaner 的清理动作不能持有被清理对象,清理线程独立、顺序为注册逆序、clean() 幂等;finalize 已废弃,不应使用。
  • libraryd 的 LoanCache 最终采用「容量上限 + LRU + 软引用兜底」,size() 只表示条目数,命中率按 get 统计。

引用类型决定「对象何时可以被回收」,但回收动作本身怎么执行、停顿多长,由分代结构和回收算法决定。下一节从 Eden、Survivor 和晋升年龄开始,把一次 Young GC 拆开看。

阅读导航:上一节:4.3 反优化与 OSR:代码什么时候退回解释器 · 下一节:5.2 分代假设与回收算法:复制、标记与整理 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发