在Java编程的长期实践中,finalize()方法一直是一个备受争议的话题。近日,一则技术社区的热议重新将聚光灯投向这个被很多开发者视为“遗老”的机制:在finalizer中创建新线程,到底安全吗? 这个问题看似冷门,却触及了JVM内存管理、线程模型与对象生命周期交织的核心地带。

1. finalizer:一个被遗忘的角落

Java中,finalize()方法由垃圾回收器(GC)在对象被回收前调用,允许开发者进行最后的资源清理。然而,从JDK 9开始,finalize()已被标记为deprecated,官方推荐使用CleanerPhantomReference。尽管如此,大量遗留代码和某些特殊场景(如JNI资源释放)仍然依赖这一机制。

问题的焦点在于:finalizer由专门的“Finalizer线程”串行执行。JVM会维护一个等待被终结的对象队列,Finalizer线程从队列中取出对象,调用其finalize()方法。如果在这个方法中创建新线程,会发生什么?

2. 潜在风险:死锁、饥饿与状态混乱

多位资深JVM工程师在社区讨论中指出了三个主要风险点:

  • 死锁风险:Finalizer线程本身是一个守护线程,优先级较低。如果在finalize()中创建一个新线程并等待其完成(比如使用join()),而新线程又需要获取Finalizer线程持有的锁(比如某个共享清理资源),就可能形成无限等待。“最终会耗竭Finalizer线程池,导致整个JVM的终结器队列阻塞,进而引起内存泄漏。”一位来自阿里JVM团队的工程师在博文中警告。

  • 线程生命周期混乱finalize()执行的时机非常不确定——可能在对象变得“不可达”后的任意时刻,甚至在JVM已经准备退出时。此时创建的新线程可能无法正常启动或完成,导致资源清理不完全。尤其在高并发应用中,多个对象同时进入终结状态,创建的大量“幽灵线程”会加剧系统混乱。

  • 状态不一致:当finalize()被调用时,对象可能已经处于部分清理状态(比如字段已被置空)。如果新线程试图访问这些字段,极有可能触发NullPointerException或其他难以调试的运行时异常。更坏的情况是,线程启动后对象本身又被GC回收,造成悬空引用。

3. 真实案例:Spring Data Redis的教训

早在2015年,Spring Data Redis的一个issue(DATAREDIS-353)就曾暴露过类似问题。该库的JedisConnectionfinalize()中尝试关闭Redis连接,而关闭操作内部会创建新线程进行异步清理。在特定压力测试下,JVM出现僵死——所有线程都在等待Finalizer线程完成,而Finalizer线程又在等待新创建的清理线程结束,形成闭环死锁。

最终,该问题的解决方案是彻底弃用finalize(),改用显式的close()方法配合try-with-resources。这一案例成为Java社区中“不要在finalizer里搞异步操作”的标志性教材。

4. 专家共识:能不用,尽量别用

目前,绝大多数Java专家对“在finalizer中创建线程”持否定态度。Oracle官方文档明确指出:Finalizer线程的行为是单线程、串行化的,任何阻塞操作(包括线程创建)都不应该发生。推荐的做法包括:

  • 改用Cleaner:JDK 9引入的Cleaner允许注册一个Runnable任务,该任务由GC线程在对象被回收前执行,但不纳入Finalizer线程的串行队列,且支持异步回调。即使如此,Cleaner中的任务也不应执行长时间操作或创建线程。
  • 采用显式资源管理:通过实现AutoCloseable接口并在try-with-resources中释放资源,是唯一受官方推荐的安全方式。
  • 必要时使用PhantomReference:配合引用队列手动处理资源回收,可以完全绕开锁竞争问题。

5. 特殊场景:如果不得不做?

少数情况下,比如正在开发一个底层JNI库,必须确保原生内存释放,而API限制又无法暴露显式关闭方法时,确实有人试图在finalizer中开线程“曲线救国”。对此,社区给出的勉强可行的方案是:创建非守护线程并不等待其结束——即启动后立即脱离控制,让新线程独立完成清理。但这会引入资源泄漏风险,且无法保证新线程在JVM退出前完成。

“这就像在沉船旁放走一艘救生艇——你无法控制它是否真的会被利用。”著名Java性能专家、Stack Overflow大神之一的“@IvanMamontov”这样比喻。

结语

从技术演进角度看,finalize()已经是一个“过去式”的特性。在它的生命周期中,任何一个试图塞入复杂操作(尤其是线程创建)的尝试,都像在定时炸弹上跳舞。现代的Java生态提供了更优雅、更安全的替代方案。如果你仍在维护遗留代码并考虑在finalizer中创建线程,请三思——更建议直接重构

毕竟,安全从来不是一个可以用“侥幸”检验的问题。