一个类持有 DirectByteBuffer 或 FileDescriptor,构造函数里申请了本地内存或打开文件句柄。如果调用方忘了 close(),这块资源什么时候归还给操作系统?很多旧代码的答案是重写 finalize(),指望 GC 在回收对象时顺手关掉它。这个答案在 JDK 18 之后已经站不住脚:JEP 421 把终结机制标记为废弃,并说明它会在未来版本默认关闭、再之后移除。
问题不在于 finalize() 写得对不对,而在于它接入的是一套无法承诺时间的机制。要理解替代方案,得先看清 GC 判定“对象已死”时,终结机制在判定流程里插入了哪一步。
终结机制插在可达性判定的哪一步
GC 判断一个对象能否回收,依据是可达性,而不是引用计数。从一组根对象出发(线程栈上的局部变量、静态字段、JNI 引用等)做遍历,能走到的对象存活,走不到的对象不可达。不可达是回收的前提。
终结机制改变了这个前提。JEP 421 描述的行为是:GC 会在回收对象内存之前,安排调用不可达对象的终结方法。也就是说,一个不可达对象如果所属类声明了 finalize(),它不会立刻被回收,而是先进入终结流程。
这套流程在 HotSpot 里由几个部件协作,需要区分规范保证与实现细节。规范层面只承诺“终结方法会在回收内存之前被安排调用”,不承诺何时调用、由哪个线程调用、以什么顺序调用。JEP 421 明确列出四条缺陷:延迟不可预测、行为不受约束、无法显式注册或取消、线程与顺序均未指定。
HotSpot 的实现细节是:GC 发现对象只被终结器引用时,把它挂到终结队列上,由一条专门的终结线程(FinalizerThread)取出并执行 finalize()。终结队列和这条线程是 HotSpot 的实现方式,不是 JVM 规范对所有虚拟机的强制要求。把“终结一定由 FinalizerThread 执行”当成跨 JVM 的保证会出错。
真正麻烦的是队列和线程之间的时间差。对象进入队列不等于终结方法马上执行。FinalizerThread 是单线程,队列里排在前面的终结方法如果阻塞,后面的全部等待。JEP 421 的措辞是:对象变得不可达,到它的终结方法被调用之间,可能过去任意长的时间;GC 甚至不保证任何终结方法一定会被调用。
对象复活如何破坏可达性判定
终结机制最反直觉的一点是,它让“不可达”变成可逆状态。
finalize() 的方法体可以执行任意代码。它可以把 this 赋给某个静态字段或集合,于是这个对象重新被根对象引用,从不可达变回可达。GC 必须接受这个结果,不能回收它。对象复活之后,它不会再次进入终结流程,因为终结方法对每个对象最多执行一次。一个已经复活、又再次变得不可达的对象,会直接进入回收阶段,不再有第二次兜底机会。
JEP 421 还指出一个更隐蔽的安全问题:如果类声明了终结方法,那么实例从构造函数开始执行的那一刻起,就已经有资格被终结。构造函数抛异常时,这个未完成初始化的实例不会被销毁,它仍然可以被终结,终结方法就能对一个半成品对象执行任意操作,包括把它复活后使用。序列化创建的对象有同样的问题。仅仅删掉自己的终结方法并不能堵住这个口子,因为子类可以声明终结方法,从而获得对非法构造对象的访问。
性能上,JEP 421 引用了 Hans Boehm 的一项观察:给一个类加上终结机制,会带来 7~11 倍的减速。这个数字来自该来源的测量,不能外推到所有场景,但它说明终结的代价不是常数级的。终结还要求 GC 在对象创建时、终结前后做额外工作,并拉长吞吐型收集器的停顿。
用 PhantomReference 把清理动作移出对象
替代思路是:不要让对象自己执行清理,而是让一个外部观察者知道对象已经不可达,再执行清理。java.lang.ref 包提供了这条路径。
PhantomReference 是一种引用强度最弱的引用。它和 WeakReference 的区别在于,弱引用在对象仍可被弱引用触达时可能被清除,而虚引用指向的对象在虚引用被清除之前,已经不可能再被任何方式取回。也就是说,虚引用让你知道对象即将消失,但不给你访问对象的途径。
ReferenceQueue 是配套的通知通道。注册时把虚引用和一个队列绑定,当 GC 判定对象只被虚引用触达时,把这个虚引用对象本身入队。注意入队的是引用对象,不是被引用的对象。清理线程从队列里取出引用,就知道对应的被引用对象已经不可达。
Cleaner 把这两者包起来。根据 JDK 21 的 Cleaner 文档,它管理一组对象引用和对应的清理动作,清理动作在 cleaner 收到对象已变为虚可达的通知后运行,通知依赖 PhantomReference 和 ReferenceQueue。注册返回一个 Cleaner.Cleanable。
Cleaner 文档里有两条硬约束,直接决定实现对不对:
- 清理动作不能引用被注册的对象。一旦引用,对象就无法变成虚可达,清理动作永远不会被自动调用。
- 清理动作由与 cleaner 关联的线程执行,动作里抛出的所有异常都被忽略,不影响 cleaner 和其他清理动作。
第二条意味着清理失败是静默的。如果清理动作里写日志、关句柄,异常不会冒泡到任何地方,你只能靠自己的日志或指标发现。
注册、入队与清理线程的完整生命周期
把贯穿场景定下来:一个 NativeBuffer 类,构造函数通过 JNI 申请一块本地内存,close() 负责释放。它同时实现 AutoCloseable,并注册 Cleaner 作为忘记 close() 时的兜底。
注册发生在构造函数里。NativeBuffer 持有一个 State 对象,State 实现 Runnable,里面保存释放本地内存所需的地址和长度。构造函数调用 cleaner.register(this, state),返回的 Cleanable 存进字段。关键点是 State 必须是静态嵌套类,不能是匿名内部类或非静态内部类,否则它会隐式持有外部 NativeBuffer 实例的引用,对象永远无法变成虚可达。
清理动作只碰 State 里的地址和长度,不碰 NativeBuffer 的任何字段。
public class NativeBuffer implements AutoCloseable {
private static final Cleaner CLEANER = Cleaner.create();
static class State implements Runnable {
private long address;
private long size;
State(long address, long size) {
this.address = address;
this.size = size;
}
@Override
public void run() {
if (address != 0) {
freeNative(address);
address = 0;
}
}
}
private final State state;
private final Cleaner.Cleanable cleanable;
public NativeBuffer(long size) {
long address = allocateNative(size);
this.state = new State(address, size);
this.cleanable = CLEANER.register(this, state);
}
@Override
public void close() {
cleanable.clean();
}
}
这段代码里 allocateNative 和 freeNative 是示意方法,实际项目里由 JNI 或 java.lang.foreign 提供。State.run() 里的 address != 0 判断配合 cleanable.clean() 的语义,保证释放最多发生一次:Cleaner 文档说明清理动作在对象变为虚可达时最多调用一次,除非已经被显式清理。
cleanable.clean() 是显式路径。调用方正常使用 try-with-resources 时,close() 触发它,清理动作立即在当前线程执行,不经过清理线程。忘记 close() 时,NativeBuffer 实例变得不可达,GC 判定它虚可达,对应的虚引用入队,cleaner 的线程取出后执行 State.run()。
下面的流程图把两条路径画在一起。
flowchart TD
A[NativeBuffer 构造] --> B[申请本地内存]
B --> C[创建 State 保存地址]
C --> D[cleaner.register 返回 Cleanable]
D --> E{调用方是否 close}
E -->|是| F[当前线程执行 clean]
F --> G[释放本地内存]
E -->|否| H[对象变为不可达]
H --> I[GC 判定虚可达]
I --> J[虚引用入 ReferenceQueue]
J --> K[cleaner 线程取出]
K --> L[执行 State.run]
L --> G
两条路径在“释放本地内存”处汇合,但执行线程不同:显式路径在调用 close() 的线程上,兜底路径在 cleaner 的守护线程上。这个差别决定了清理动作里能不能做阻塞操作。
清理线程的调度约束与失败模式
Cleaner.create() 会创建一条守护线程处理虚可达对象并调用清理动作,线程的上下文类加载器被设为系统类加载器。Cleaner.create(ThreadFactory) 允许你换一条线程,但线程工厂每次必须提供一个新线程,否则抛 IllegalThreadStateException。
调度上最需要记住的是:同一个 cleaner 的清理动作共用一条线程。Cleaner 文档明确说,清理动作应当准备好在与其他清理动作并发的情况下被调用,通常应当执行得很快且不阻塞;如果某个清理动作阻塞,它可能延迟注册到同一个 cleaner 的其他清理动作。
这带来几个具体失败模式:
清理动作阻塞。 某个 State.run() 里做了网络调用或等待锁,同一 cleaner 上所有待清理对象全部排队。表现是本地内存占用持续上涨,但堆内存看起来正常,因为对象本身已经被回收,只是清理没跟上。
清理动作抛异常。 异常被忽略,句柄没关。表现是文件描述符数量缓慢增长,日志里什么都没有。
清理动作捕获了被清理对象。 对象永远无法变成虚可达,清理动作永远不执行,等同于没有兜底。用匿名内部类或 lambda 引用外部实例字段时最容易踩。
共享 cleaner 被滥用。 如果整个库共用一个 Cleaner,某个慢清理动作会影响所有使用者。Cleaner 文档说新建 cleaner 还是共享已有 cleaner 取决于用例,库作者通常倾向共享,但要意识到共享意味着共用一条线程。
System.exit 期间的行为。 Cleaner 文档说明,cleaner 在 System.exit 期间的行为由实现决定,不保证清理动作是否被调用。把清理当作退出时的可靠收尾会落空。
与 try-with-resources 的边界
Cleaner 不是 try-with-resources 的替代品,两者的定位不同。
try-with-resources 是确定性的:资源在代码块结束时立即释放,释放发生在当前线程,异常会被抛出并可以处理。它要求调用方在语法上把资源放进 try 块,编译器会插入关闭逻辑。
Cleaner 是非确定性的:清理发生在对象虚可达之后,时间取决于 GC 何时运行、清理线程何时被调度,异常被吞掉。它不要求调用方做任何事,代价是时间不可控。
JEP 421 给出的迁移方向是把 try-with-resources 和 cleaner 一起用:try-with-resources 作为正常路径,cleaner 作为忘记关闭时的安全网。库作者应当让资源类实现 AutoCloseable,把清理动作抽到不持有外部实例的 State 里,注册 cleaner 兜底。
| 维度 | finalize | Cleaner | try-with-resources |
|---|---|---|---|
| 触发时机 | 不可达后由 GC 安排,时间无保证 | 虚可达后由清理线程执行,时间无保证 | 代码块结束时立即执行 |
| 执行线程 | 未指定,HotSpot 为 FinalizerThread | 与 cleaner 关联的线程 | 调用 close 的线程 |
| 能否取消 | 不能 | 可以,显式 clean 后不再自动执行 | 不适用 |
| 异常处理 | 被忽略 | 被忽略 | 可抛出并捕获 |
| 对象复活 | 可以,且破坏回收判定 | 无法通过清理动作复活 | 不适用 |
| 每实例开销 | 类只要有终结方法,所有实例都受影响 | 只对注册过的实例生效 | 无 |
| 适用位置 | 已废弃,不应使用 | 兜底安全网 | 正常释放路径 |
从表里能读出的决策规则是:正常路径一律用 try-with-resources;只有当你无法控制调用方、又必须保证资源最终被释放时,才加 Cleaner 兜底。Cleaner 的清理动作要短、不阻塞、不抛异常、不引用被清理对象。
可观测信号与诊断路径
Cleaner 的静默失败意味着默认情况下你什么也看不到。工程上需要主动加信号:
- 在清理动作里记录一条日志或递增一个计数器,区分显式清理和兜底清理。兜底清理的比例上升,说明调用方在泄漏。
- 监控进程的文件描述符数量和本地内存占用。堆内存正常但这两项持续上涨,指向清理线程跟不上或清理动作失败。
- 用 JFR 观察 GC 和引用处理相关事件,确认虚引用是否在按预期入队。
- 如果清理动作可能阻塞,给它加超时或移到独立线程池,避免拖住同一 cleaner 上的其他动作。
诊断顺序通常是:先确认对象是否真的变成虚可达(检查清理动作有没有引用外部实例),再确认清理线程是否被阻塞,最后确认清理动作本身有没有抛异常。三步都排除后,才考虑是不是 GC 压力导致清理延迟。
版本边界上,Cleaner 自 JDK 9 起提供,JDK 18 起 finalize 被标记为废弃待移除。JEP 421 说明终结机制当前仍默认启用,但可以关闭以便提前测试;未来版本会默认关闭,再之后移除。依赖终结机制的库和应用应当按 JEP 421 的建议迁移到 try-with-resources 和 cleaner。
需要提醒的是,Cleaner 同样不保证清理动作一定执行。JVM 退出、清理线程被阻塞、对象因引用问题无法变成虚可达,都会让兜底失效。它降低的是资源泄漏的概率,不是消除。把 Cleaner 当唯一释放路径,等于把 finalize 的不确定性换了个名字。