Java Cleaner 实战:替代废弃的 finalize() 做堆外资源清理,以及强引用导致永不回收的坑
你可能在老代码里见过 finalize(),想着"对象被回收前自动关一下资源多方便"。但 finalize() 从 Java 9 起就被 @Deprecated,Java 18 更是标了 forRemoval。原因很实在:它执行时机不确定、拖慢 GC、异常被吞、甚至能让对象"复活"。Java 9 给的正牌替代是 java.lang.ref.Cleaner。这篇讲清楚怎么用它做兜底清理,以及那个能让清理永远不触发的隐蔽坑。
finalize 到底错在哪
先看为什么不能再用它:
java
public class OldStyle {
private long nativeHandle = openNative();
@Override
protected void finalize() {
closeNative(nativeHandle); // 看着方便,坑一堆
}
}
问题清单:执行时机完全由 GC 决定,可能几分钟后才跑、也可能永远不跑;它在专门的 finalizer 线程串行执行,一个慢 finalize 会拖垮所有对象的回收;抛了异常直接被吞掉;最离谱的是 finalize 里能把 this 重新赋给某个静态字段让对象"复活",逃过回收。所以官方直接判它死刑。
Cleaner 的正确用法
Cleaner 的思路:注册一个"当对象变得不可达时要跑的清理动作",由 Cleaner 自己的后台线程去执行。核心 API 是 Cleaner.create() 和 register(obj, cleanupAction)。
标准写法长这样(这是官方推荐的模板):
java
import java.lang.ref.Cleaner;
public class NativeResource implements AutoCloseable {
// 整个应用共享一个 Cleaner 实例即可
private static final Cleaner CLEANER = Cleaner.create();
// 关键:清理逻辑放进一个静态内部类,不能是匿名/非静态内部类
private static class State implements Runnable {
private long handle;
State(long handle) { this.handle = handle; }
@Override
public void run() {
// 真正的清理动作,幂等
if (handle != 0) {
closeNative(handle);
handle = 0;
}
}
}
private final State state;
private final Cleaner.Cleanable cleanable;
public NativeResource() {
this.state = new State(openNative());
// 注册:当 this 不可达时,自动调用 state.run()
this.cleanable = CLEANER.register(this, state);
}
@Override
public void close() {
// 显式关闭:主动触发清理,并从 Cleaner 注销
cleanable.clean();
}
private static native long openNative();
private static native void closeNative(long handle);
}
用起来:
java
try (NativeResource res = new NativeResource()) {
// 用资源...
} // close() 被调用,cleanable.clean() 立即清理
// 就算有人忘了 try-with-resources,对象被 GC 时 Cleaner 也会兜底清理
两条路都通:正常路径靠 try-with-resources 显式 close() 立即 清理;万一有人忘了关,Cleaner 在对象不可达后兜底清理。这就是 Cleaner 的定位------不是主力,是安全网。
最大的坑:清理动作千万别引用被清理的对象
这是新手 90% 会踩的坑,也是为什么上面模板一定要用静态内部类 State,而不是把清理逻辑写成引用外部对象的 lambda。
Cleaner 靠"对象变得不可达"来触发清理。如果你注册的清理动作(第二个参数)强引用了第一个参数那个对象 ,那这个对象就永远可达、永远不会被回收、清理永远不触发------你精心写的兜底成了摆设。
错误示范:
java
public class Buggy implements AutoCloseable {
private static final Cleaner CLEANER = Cleaner.create();
private long handle = openNative();
private final Cleaner.Cleanable cleanable;
public Buggy() {
// 错!lambda 捕获了 this(为了读 this.handle)
// Cleaner 内部强引用这个 lambda → 强引用 this → this 永不可回收
cleanable = CLEANER.register(this, () -> closeNative(this.handle));
}
// ...
}
这里 lambda 里出现了 this.handle,编译器为了让它能读到 handle,让 lambda 捕获了整个 this。Cleaner 为了将来能跑这个 lambda,得一直强引用它,于是 this 永远活着,register 承诺的兜底形同虚设。
正确做法就是模板里的样子:把需要的状态(handle)拷进一个独立的、不持有外部对象引用 的 State 对象,清理动作只碰 State,绝不碰外层 this。判断标准很简单:你的 cleanup Runnable 里不能出现 外层类.this 或任何指向被清理对象的字段引用。
另外三个注意点
1. 清理动作必须幂等。 显式 close() 和 GC 兜底可能都想清理,run() 里要用标志位(上面 handle = 0)防止重复释放。
2. 别在清理里做重活或阻塞。 Cleaner 线程是共享的,一个慢清理会拖累其他对象的清理,这点和 finalize 的教训一样。
3. Cleaner 实例要复用。 Cleaner.create() 会起一个后台线程,别每个对象 new 一个,整个应用一个静态 Cleaner 就够;需要隔离时(比如库不想抢用户线程)才单独建。
什么时候真该用它
Cleaner 只适合堆外/本地资源的兜底清理 :JNI 拿到的 native handle、堆外内存(ByteBuffer.allocateDirect)、文件描述符这类 GC 管不到的东西。纯 Java 对象(比如一个 HashMap)不需要它------GC 自己会回收。而且它永远是第二道防线 ,第一道永远是 try-with-resources + AutoCloseable 的显式关闭。别指望 Cleaner 及时,它只保证"最终会清,如果对象真被回收的话"。
小结
finalize()已废弃(Java 9 起,18 标记移除),因执行时机不定、拖慢 GC、吞异常、能复活对象等硬伤被淘汰。- 正牌替代是
java.lang.ref.Cleaner:显式close()立即清理,忘关时 GC 兜底清理,定位是"安全网"不是主力。 - 头号坑 :清理动作绝不能强引用被清理的对象,否则对象永远可达、清理永不触发------所以清理状态要放进独立的静态
State,别用捕获this的 lambda。 - 清理动作要幂等、要轻量;Cleaner 实例全局复用一个。
- 一句话记忆:Cleaner 是 try-with-resources 的兜底安全网,而它能不能兜住,取决于你有没有让清理动作"放手"那个对象。