Java 9清理器是否应该优先于终结器?

24
在Java中,重写finalize方法有些名声不好,尽管我不明白为什么。像FileInputStream这样的类在Java 8和Java 10中都使用它来确保调用close方法。然而,Java 9引入了java.lang.ref.Cleaner,它使用幻象引用机制而非GC终结。起初,我认为这只是一种向第三方类添加终止操作的方式。然而,在javadoc中给出的示例显示了一个可以轻松用终止器重写的用例。
我应该用Cleaner重写我所有的finalize方法吗?(当然我没有很多,只是一些专门用于CUDA互操作的OS资源的类。)
据我所知,Cleaner(通过幻象引用)避免了一些finalizer的危险。特别是,您无法访问已清理对象及其任何字段,因此无法恢复它或其任何字段。
但是,那是我能看到的唯一优点。Cleaner也是非平凡的。事实上,它和终结器都使用ReferenceQueue!(您是否喜欢阅读JDK的简便程度?)它比终结器更快吗?它会避免等待两个GC吗?如果有许多对象排队清理,它会避免堆耗尽吗?(对我来说,所有这些问题的答案似乎都是否定的。)

最后,实际上没有任何保证能够阻止您在清理操作中引用目标对象。请仔细阅读长API注释!如果您最终确实引用了该对象,则整个机制将悄无声息地中断,与始终试图挣扎前行的终结不同。最后,虽然终结线程由JVM管理,但创建和持有Cleaner线程是您自己的责任。


8
可能是这样的。Java 9已经弃用了finalize()。Javadoc明确表示,当一个对象变得不可达时,“Cleaner”和“PhantomReference”提供了更灵活、更高效的释放资源的方式。请注意,Javadoc中使用了“显式地”一词。 - Elliott Frisch
1
在Java 9中,与Java 8相比,我们必须使用PhantomReference进行后期清理。因此,如果我们在队列中找到PhantomReference实例,则表示引用对象的内存已被释放。在Java 8中,我们必须显式执行clean方法以释放内存。 - gstackoverflow
4个回答

25
您不应该将所有的finalize()方法替换为Cleanerfinalize()方法的弃用和(一个publicCleaner的引入发生在同一个Java版本中,这只是表明了关于该主题的一般性工作已经完成,而不是要求一个替代另一个。

该Java版本的其他相关工作包括删除一个规则,即PhantomReference不会自动清除(是的,在Java 9之前,使用PhantomReference而不是finalize()仍需要两个GC循环才能回收对象),以及引入Reference.reachabilityFence(…)

第一种替代finalize()的方法是根本不要有垃圾回收依赖操作。当您说您没有使用许多时,这很好,但我在野外看到完全过时的finalize()方法。问题在于,finalize()看起来像是普通的protected方法,并且finalize()是某种类型的析构函数的顽固神话仍然在一些互联网页面上传播。将其标记为已弃用可以向开发人员发出信号,表明这不是情况,而不会破坏兼容性。使用需要显式注册的机制有助于理解这不是正常程序流程。当它看起来比覆盖单个方法更复杂时,它也不会受到伤害。

如果您的类封装了非堆资源,则文档中指出:

持有非堆资源实例的类应提供一种方法以启用对这些资源的显式释放,并在适当时还应实现AutoCloseable

(这是首选解决方案)

CleanerPhantomReference在对象变得不可达时提供了更灵活、更高效的释放资源的方式。

因此,当您确实需要与垃圾收集器交互时,即使这个简短的文档注释没有提到PhantomReference作为Cleaner的隐藏后端,它也命名了两个替代方案;直接使用PhantomReferenceCleaner的一个替代方案,可能更加复杂,但也可以在同一个使用资源的线程中清理,从而提供更多的时间和线程控制,包括处理清理期间抛出的异常的可能性,比默默地吞噬它们更好地解决问题。与使用WeakHashMap相比(其具有避免线程安全构造费用的此类清理),它还可以处理异常。

但是,甚至Cleaner可以解决你意识不到的更多问题。
一个重要问题是注册时间。
  • 非平凡finalize()方法的类对象在执行Object()构造函数时被注册。此时,该对象尚未初始化。如果您的初始化由于异常而终止,则finalize()方法仍将被调用。可能会想通过对象的数据来解决这个问题,例如将initialized标志设置为true,但是您只能针对自己的实例数据这样做,对于其子类的数据不能这么说,因为当您的构造函数返回时,它们仍未初始化。

    注册清理程序需要一个完全构造的Runnable,其中包含所有清理所需的数据,没有对正在构建的对象的引用。即使资源分配没有发生在构造函数中(考虑一个未绑定的Socket实例或者一个未与显示器原子连接的Frame),也可以推迟注册。

  • 可以覆盖finalize()方法,却没有调用超类方法或在特殊情况下未能这样做。通过声明它为final来防止方法被覆盖,不允许子类有这样的清理操作。相反,每个类都可以注册清理器,而不会干扰其他清理器。

当然,您可以通过封装对象来解决此类问题,但是为每个类设计一个finalize()方法导致了错误的方向。

  • 正如您已经发现的那样,有一个clean()方法,允许立即执行清理操作并删除清理程序。因此,在提供显式关闭方法或甚至实现AutoClosable时,这是首选的清理方式,及时处理资源并摆脱基于垃圾收集器的所有问题。

    请注意,这与上面提到的点协调一致。一个对象可以有多个清理器,例如由层次结构中的不同类注册。每个清理程序都可以单独触发,具有关于访问权限的内在解决方案,只有注册清理器的人才能拿到相关的Cleanable以便调用clean()方法。


话虽如此,通常忽略管理垃圾收集器的资源时最糟糕的事情不是清理动作可能稍后运行或根本不运行。最糟糕的事情是它运行得太早。例如,参见Java 8中对已强引用对象调用finalize()。或者一个真正好的例子:JDK-8145304,Executors.newSingleThreadExecutor().submit(runnable)抛出RejectedExecutionException,其中一个finalizer关闭了仍在使用的执行器服务。

当然,仅使用CleanerPhantomReference并不能解决这个问题。但是移除finalizers并在真正需要时实现替代机制,就是一个仔细思考这个主题的机会,也许可以在必要的地方插入reachabilityFences。最糟糕的情况是,你可能拥有一个看起来易于使用的方法,但实际上这个主题非常复杂,99%的使用方式都可能在将来某一天出现问题。

此外,虽然替代方案更加复杂,但你自己说过,它们很少被需要。这种复杂性应该只影响到代码库的一小部分。而且,为什么java.lang.Object,作为所有类的基类,还要包含一个解决Java编程中罕见情况的方法呢?


谢谢您的出色回答!但是,恕我直言,我认为我们基本上达成了一致意见:1)Cleaner/PhantomReference只是finalize的优化版本(而且存在一个引理,即“优化”并不自动意味着“更好”)。2)废弃finalize是对n00bs的一种焦虑和绝望之举。它实际上并没有消失,也不比Cleaner/PhantomReference更不正确。 - Aleksandr Dubinsky
不要误会,我在学习Netty后才发现了高效、灵活的替代终结方案的实用性。Netty在本地内存之上有一个引用计数手动内存管理系统,并且它使用弱引用(奇怪的是,不是虚引用)来进行可选和抽样式的内存泄漏检测策略。使用finalize会太慢了。尽管如此,这是一个罕见的情况,避免不必要的优化是一个好建议。 - Aleksandr Dubinsky
9
“finalize()”方法本不应存在,承认某些东西是个坏主意永远不晚。关于“弱引用”与“虚引用”,在我真正需要这样的东西时我也使用了“弱引用”。在Java 9之前,“虚引用”不会自动清除,需要在第二次回收中收集引用对象,这使其效率较低。此外,唯一的区别在于在将虚引用加入队列之前必须对对象进行终结,因此如果不使用终结器,则不再有区别。 - Holger
唉,或许这些都是不好的想法。相反,应该存在一种内置的、经过优化的、专用于调试资源泄漏的机制,类似于Netty的ResourceLeakDetector,并期望不存在泄漏。那样的话,我们根本就不需要finalizers了。或者更好的是,我们可以有内置的自动引用计数。那才会真正有帮助。 - Aleksandr Dubinsky
“Cleaner” 似乎是 finalize 提供的安全网的一个相当差劣的替代品,需要为每种需要这样的安全网的对象类型提供一个线程。至少,在 Java 应用程序开始拥有数十个垃圾线程来清理单个类的实例之前,标准实现可以提供一个共享的“Cleaner”。 - john16384
1
@john16384 一个单独的“Cleaner”可以用于不同类型的对象。框架可以自由定义它们共享的“Cleaner”,这与“ExecutorService”没有区别。但是,使完全不相关的资源的清理操作相互依赖(长时间运行的清理操作可能会延迟其他清理操作),会带来“finalize()”的问题。但往往,错误始于认为特定应用程序需要这样的“安全网”。 - Holger

4
评论中所指出的那样,Java 9+已经将Object.finalize标记为已弃用,因此使用Cleaner实现方法更为合理。此外,从发布说明中可以看到:

java.lang.Object.finalize方法已被弃用。终结机制本质上存在问题,可能会导致性能问题、死锁和挂起。当对象变得不可达时,java.lang.ref.Cleanerjava.lang.ref.PhantomReference提供了更加灵活和高效的资源释放方式。

详情请参见Bug数据库 - JDK-8165641


1
我只是要复制并粘贴我的评论给Elliot:嗯,我不反对最终化可能存在所有列出的问题,但我不明白为什么Cleaner会“更灵活和高效”。性能问题?当然。死锁/挂起?何不试试。错误导致资源泄漏?当然。可取消性?我没有看到任何Cleanable.cancel。顺序保证?根本没有。时间保证?别想了。说真的,他们想卖给我们什么? - Aleksandr Dubinsky
2
不太熟悉语气,但是其中一个原因是将其从链接文档中弃用,供读者阅读。http://mindprod.com/jgloss/finalize.html - Naman
3
Cleanable.clean()注销可清理对象并调用清理操作。 这应该在你的资源类的 close() 方法中调用。这样,只有在忘记调用 close() 时才需要进行垃圾回收。但是,当正确关闭时,无论下一次 GC 何时发生,都会及时清理和注销。 - Holger
@Holger,我注意到了这一点,并在我的答案中加以说明。clean() 调用 Reference.clear(),防止引用进入 ReferenceQueue 并删除对其的引用,因此可以在第一次 GC 时将其回收。Finalizer 可以提供相同的功能,因为它也继承了 Reference,但是没有办法访问它。 - Aleksandr Dubinsky
4
finalize() 方法是使用特殊的 Reference 对象实现的这一事实是一个实现细节。但更糟糕的是,缺乏对注册的控制,而无法选择退出。当执行 Object() 构造函数时,具有非平凡 finalize() 方法的类的对象会在分配资源之前注册,在对象甚至未初始化的情况下进行注册。这就是所谓的 finalizer 攻击,通过其可以获取未初始化或无效对象的控制权,即使构造函数抛出异常也一样。 - Holger
有人能解释一下如何使用PhantomReference和PhantomReference + Cleaner吗? - gstackoverflow

-2

不要使用finalize。

试图使用Cleaner来恢复资源泄漏几乎会遇到与finalize相同的挑战,正如Holger所提到的那样,其中最糟糕的问题是过早的终结(这不仅是finalize的问题,而且是每种软/弱/幻象引用的问题)。即使您尽力正确地实现了终结(再次强调,我指的是任何使用软/弱/幻象引用的系统),您也无法保证资源泄漏不会导致资源耗尽。不可避免的事实是,GC不知道您的资源。

相反,您应该假设资源将通过AutoCloseabletry-with-resources、引用计数等正确关闭,找到并修复错误,而不是希望绕过它们,并且只将终结(以其任何形式)用作调试工具,就像assert一样

必须修复资源泄漏-而不是绕过它们。

Finalization 应该只被用作一个断言机制,来(尝试)通知您存在一个 bug。为此,我建议看一下基于 Netty 的 almson-refcount。它提供了一个基于弱引用的高效资源泄漏检测器,以及一个比通常的 AutoCloseable 更灵活的可选引用计数功能。它的泄漏检测器之所以出色,是因为它提供了不同级别的跟踪(带有不同量的开销),并且您可以使用它来捕获泄漏对象分配和使用的堆栈跟踪。


泄漏是另一回事。您可能会有内存泄漏,但没有人声称垃圾收集器应该修复这些问题。内存像任何其他资源一样,最好尽快释放。对于内存资源,有一个标准机制。这种机制也应该适用于其他类型的资源。有时您希望尽快释放此类资源,但在许多应用程序中已经看到了“最终”仍然可以正常工作的情况,例如不关闭套接字、文件等。 - john16384
@john16384 你有没有读到关于过早终止的部分?另外,你真的不关闭你的文件吗?你的评论太混乱了,我很震惊你称自己为“Java专家”。 - Aleksandr Dubinsky
我已经清楚地说过,使用 finalize 方法关闭文件和套接字的应用程序似乎运行良好。这并不是“没有关闭它们”,而是并非立即关闭它们。就此而言,我并没有表示支持它(在高负载服务器上显然是不明智的)。只是这样的应用程序确实似乎运行良好,正如@john16384的评论所述。你对他非常无礼和傲慢,却未能引用任何证据来反驳他的说法。你现在仍未提供证据。我要对此进行批评。证据在哪里? - barneypitt
引入和修复。如果在内部锁中引入了一个错误,您是否建议使用“同步”本质上是危险的,永远不应该使用?“似乎工作正常”<=>“工作正常”。读取偶尔的属性文件并且没有明确关闭它们的桌面应用程序是可以接受的。当应用程序耗尽内存时,终结并不会立即发生。垃圾回收是分代的。像套接字/文件读取器这样的资源往往具有短暂的生命周期,并且永远不会离开伊甸园空间。无论内存使用情况如何,伊甸园空间对象都会被频繁收集。 - barneypitt
@barneypitt你的意思是提前终止现在已经修复了吗?能分享一下来源吗?不同的GC工作方式不同。ZGC现在还没有代际,也不会主动运行(除非有一个opt-in标记)。当然,finalize的合同并不承诺任何事情。即使保持单个配置文件打开,也可能会导致各种问题,具体取决于平台,从阻止文件系统卸载到防止文件被修改(Windows)。 - Aleksandr Dubinsky
显示剩余3条评论

-5

Java 9的Cleaner与传统的finalization(在OpenJDK中实现)非常相似,几乎所有关于finalization的好坏都可以适用于Cleaner。两者都依赖于垃圾收集器将Reference对象放置在ReferenceQueue上,并使用单独的线程运行清理方法。

三个主要区别是,Cleaner使用PhantomReference而不是本质上是WeakReference的内容(幽灵引用不允许您访问对象,这确保它无法被访问,即成为僵尸),每个Cleaner使用可自定义的ThreadFactory的单独线程,并允许手动清除(即取消)PhantomReferences并且永远不会入队。

当大量使用Cleaner/finalization时,这提供了性能优势。(不幸的是,我没有基准测试来说明有多大的优势。)但是,大量使用finalization并不正常。

对于通常会使用finalize的正常事情——即最后一招清理本地资源的机制,使用小而终结状态必要最小的对象实现,提供AutoCloseable且每秒不分配数百万个,除了使用差异之外,两种方法之间没有实际区别(在某些方面,finalize更简单,而在其他方面Cleaner可以帮助避免错误)。Cleaner不提供任何额外的保证或行为(例如保证在进程退出之前运行清理程序-这基本上是不可能保证的)。

然而,finalize已弃用。就这样,我想是这个原因。有点儿没营养。或许JDK开发人员在思考:“为什么JDK要提供一个本机机制,可以轻松地作为库实现”“n00bs。无处不在的n00bs。n00bs,别再使用finalize了,我们非常讨厌你们。”这是一个很好的观点-然而,我无法想象finalize会真正消失。

一个好的文章,谈到了finalization并概述了如何使用替代-finalization,可以在这里找到:如何处理Java Finalization的内存保留问题。它大致描述了Cleaner的工作原理。
可能会使用Cleaner或PhantomReference而不是finalize的代码示例是Netty的引用计数手动管理直接(非堆)内存。在那里,会分配许多可完成对象,并且Netty采用的替代-finalization机制是有意义的。但是,Netty更进一步,除非泄漏检测器设置为最高灵敏度,否则不会为每个引用计数对象创建Reference。在通常的操作中,它要么根本不使用finalization(因为如果有资源泄漏,您最终肯定会发现它),要么使用抽样(将清除代码附加到分配的对象的一小部分)。
Netty的ResourceLeakDetectorCleaner更酷。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接