Java Cleaner 实战:替代废弃的 finalize() 做堆外资源清理,以及强引用导致永不回收的坑

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 的兜底安全网,而它能不能兜住,取决于你有没有让清理动作"放手"那个对象。
相关推荐
LaughingZhu13 分钟前
Product Hunt 每日热榜 | 2026-09-02
java·ide·intellij-idea
旧梦952719 分钟前
Java EnumMap 详解:原理、用法与实战
java·开发语言
大勇前进24 分钟前
写了5年代码,我重新开始认真写单元测试
后端
兜里ヌ有糖24 分钟前
java Date时间格式工具类DateUtil
java
长大198829 分钟前
TypeScript 真的是 JavaScript 的"平替"吗?5 个真实项目踩坑记录
后端
泡海椒30 分钟前
响应自动序列化:JSON 响应一键转 Java 实体对象,JQuick-Curl 第三方接口调用不再手动解析
后端·github
大白8030 分钟前
"速度"与"质量"不是选择题——我们如何用 CI/CD 同时保住两者
后端
八苦35 分钟前
用 runtime-async 写一个支持 async/await 的轻量脚本引擎
后端
zww894911141 分钟前
家政多商户系统开发:从架构设计到落地实践
java·eclipse