2055 字
约 6 分钟
15
S04-A23 · `finalize` 与 `Cleaner`:为什么"清理资源"不该等 GC

一个团队用 finalize() 关闭文件句柄,理由是"忘记 close 时能兜住"。上线后"句柄耗尽"依然发生——因为 finalize 的执行依赖 GC,而"堆很空"时 GC 可能几十分钟不跑。

finalize 已废弃(Java 9 标 @Deprecated,18 起"终弃"),而 Cleaner 是"官方替代"。但真正的答案是:"不要依赖任何"回收时的清理""——因为"回收时机"由 GC 决定,这个"两套时钟"是理解本题的关键:内存的时钟是"分配速率",而资源的时钟是"并发数"——两者不同步,于是"内存宽裕"时"资源已尽"。
而"资源耗尽"由"多少并发句柄"决定。

1. 三种"清理时机"的差别

机制 时机 可靠性
try-with-resources 作用域结束(确定) 高
Cleaner GC 之后(不确定) 中
finalize GC 之后(不确定 + 慢) 低

第二项与第三项的"不确定"是本质:**GC 只保证"内存不够时回收",不保证"及时"**第二项的"补充"是"GC 甚至可能"永不执行"":低分配速率 + 大堆的服务,可能几十分钟才 GC 一次——于是"清理"也跟着几十分钟一次。
——于是"资源"等不到"内存压力"。

2. "等 GC"为什么会耗尽资源

场景 结果
句柄/连接(有限) 耗尽(先于内存)
直接内存/堆外 堆外 OOM
锁/临时文件 泄漏到重启

第一项的"量化"很直观:默认 ulimit -n 常是 1024,而"堆"是 2–8G——1024 个句柄在"每请求开一个文件"的服务上,几十秒就能耗尽。
第一项是关键:"句柄数"通常只有几千,而"堆"有几个 G——于是"内存还没紧张,句柄已用光"。这也解释了"靠 finalize 兜底"的失败。

"兜底是否真的工作"必须"显式验证",否则它只是"看起来有":

// 验证 Cleaner:强制 GC + 等一会儿,再看"清理计数器"
AtomicInteger cleaned = new AtomicInteger();
// 在清理动作里 cleaned.incrementAndGet()
System.gc(); Thread.sleep(200);      // 生产中不要这样做,测试里可以
assertEquals(1, cleaned.get());      // 若为 0 → 清理闭包很可能持有 this
// 判据:这个断言能让"永不清理"的 bug 在 CI 里暴露

3. Cleaner 的正确用法

要点 说明
注册"清理动作"到 Cleaner 而不是重写 finalize
保留"显式 close" 清理只是兜底
用 PhantomReference 语义 不要在清理里访问对象

第二项是"设计原则":Cleaner 是"最后一道保险",而不是"主要手段"第二项的"代价"是"两条路径都要测":"正常 close"与"兜底清理"要走不同用例——而"兜底"那条(强制 GC 后检查)几乎没人写。
——于是"正常路径 close,异常路径 cleaner"。

// 一个"显式 close + Cleaner 兜底"的资源
final class NativeBuf implements AutoCloseable {
    private static final Cleaner CLEANER = Cleaner.create();
    private final Cleaner.Cleanable cleanable;
    private final long addr;

    NativeBuf(long size) {
        this.addr = alloc(size);
        this.cleanable = CLEANER.register(this, () -> free(addr));   // 只捕获 addr
    }
    @Override public void close() { cleanable.clean(); }             // 显式释放
}
// 关键:清理动作"不持有 this",否则对象永远不会被判定为"不可达"

4. "清理动作里访问 this"的三种后果

后果 机制
对象永不回收 清理闭包持有 this
清理延迟到"下一次 GC" 同上
内存泄漏(假象) 看起来"已经不用了"

这个坑的"隐蔽"在于"它不报错":代码看起来完全正常,清理只是"从不发生"——于是表现为"内存缓慢上涨",而没有任何异常。
这是 Cleaner 最著名的坑:清理闭包必须"不引用被清理对象"——否则"引用链"让对象"永远可达"。

"try-with-resources"能覆盖"多个资源",而"关闭顺序"是"逆序"(后开的先关):

// 两个资源的正确写法(关闭顺序:writer → stream)
try (var out = Files.newOutputStream(p);
     var writer = new BufferedWriter(new OutputStreamWriter(out))) {
    writer.write(data);
}   // 自动 writer.close() 再 out.close()
// 反例:手动 close 时"先关 out" → writer 再 flush 会抛"流已关闭"

5. "三种资源的正确清理"

资源 正确做法
文件/连接 try-with-resources
堆外内存 显式 close + Cleaner 兜底
锁 try/finally(不要等 GC)

第二项是"必须兜底"的唯一场景:堆外内存不会"因为忘记释放而报错",它会"悄悄涨到 OOM"。第二项还有个"变体":DirectByteBuffer 的释放也挂着 Cleaner——于是"堆外内存"同样"等 GC 才释放",而"堆不紧张"时它就不释放。

"句柄泄漏"有现成的观测手段,不必"等到耗尽":

// 三处看"句柄去哪了"
ls /proc/<pid>/fd | wc -l                 # 句柄总数
ls -l /proc/<pid>/fd | grep -c socket     # socket 数
jcmd <pid> VM.native_memory summary       # 需开 NMT,看 malloc/文件映射
// 判据:句柄总数"随请求数单调涨" → 泄漏;"平稳" → 正常复用

6. 排错:五种"清理相关问题"

现象 成因 处理方向
句柄耗尽 依赖 finalize 改 try-with-resources
堆外缓慢上涨 未 close 显式 close + Cleaner
清理"从不执行" 闭包持有 this 只捕获必要数据
清理时抛异常 访问已释放状态 清理只做"释放"
端口/临时文件残留 未清理 finally

"锁"也要算"资源",而它的清理永远不该等 GC:

// 锁的两种清理(都必须是"确定时机")
lock.lock();
try { work(); } finally { lock.unlock(); }     // 正解:finally

// 反例:靠"对象回收时释放锁" → 线程可能"永久持锁",直到进程重启
// 判据:这个资源"被占着会不会阻塞别人"——会,就必须 finally

7. 边界:什么情况不必显式清理

场景 为什么 做法
纯内存对象 GC 就是清理 不必
短生命周期进程(脚本) 退出即释放 不必
已有框架管理(连接池/Netty) 框架负责 跟随
只读打开且立即关闭 作用域明确 try-with-resources

最后一条与"编码风格"有关的边界:"资源获取与 try 相邻"这条纪律,比"任何兜底机制"都有效——因为"相邻"让"忘记 close"在评审时肉眼可见。
判断口径一句话:"忘记清理"会不会"在内存耗尽之前就出问题"。会(句柄、端口、堆外、锁),必须"显式清理 + 兜底";不会(纯内存),交给 GC 就好。

最后一个"历史"视角:finalize 被废弃的核心原因是"它把'清理'与'回收'绑在一起"——而这两件事本来该分开:回收是"内存管理",清理是"资源管理"。Cleaner 只是"把它从对象方法变成外部注册",本质上仍是"等回收"。真正的解法是"显式关闭 + 兜底",而"兜底"只该在"显式失败时"出现——所以评估一个资源用法时,先问"正常路径有没有 close",再问"兜底有没有"。

把这两问写在评审清单里,比记住任何 API 都管用。

而且它在评审里只需两行字。

8. 自测

  1. 三种清理时机的差别是什么?为什么 Cleaner 的"不确定"是本质?
  2. 为什么"等 GC"会先耗尽句柄?"句柄数"与"堆大小"的关系是什么?
  3. Cleaner 的正确用法是什么?为什么它是"最后一道保险"?
  4. "清理动作里访问 this"会造成什么?为什么?
  5. 哪一类资源"必须兜底"?为什么"忘记释放"不会立即报错?
  6. 判断"是否需要显式清理"的那个问题是什么?
S04-A23 · `finalize` 与 `Cleaner`:为什么"清理资源"不该等 GC
https://www.clxhxhhr.top/posts/6646/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。