本节目标:说清「对象还算活着」由谁判定,以及强、软、弱、虚四种引用和
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 认为它可达。这一节要回答四个问题:
- GC 判定「可达」的起点到底有哪些?
WeakHashMap的 key 什么时候真的会被清掉?SoftReference在内存压力下能保住多少条目?- 缓存对象需要释放堆外资源时,
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 分代假设与回收算法:复制、标记与整理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。