跳到主要内容

JEP 421:弃用最终确定删除

概括

不赞成在未来版本中删除最终确定。目前默认情况下最终确定仍处于启用状态,但可以禁用以方便早期测试。在未来的版本中,它将默认禁用,并且在以后的版本中它将被删除。依赖最终确定的库和应用程序的维护者应考虑迁移到其他资源管理技术,例如try-with-resources 语句清理程序

目标

  • 帮助开发人员了解最终确定的危险。
  • 为开发人员在未来版本的 Java 中删除终结做好准备。
  • 提供简单的工具来帮助检测对最终确定的依赖。

动机

资源泄漏

Java 程序享受自动内存管理,其中 JVM 的垃圾收集器 (GC) 在不再需要对象时回收该对象使用的内存。但是,某些对象表示操作系统提供的资源,例如打开的文件描述符或本机内存块。对于这样的对象,仅仅回收对象的内存是不够的;程序还必须将底层资源释放回操作系统,通常是通过调用对象的close方法。如果程序在 GC 回收对象之前未能执行此操作,则释放资源所需的信息就会丢失。操作系统仍认为该资源正在使用,但该资源已_泄漏_。

资源泄漏非常常见。考虑以下将数据从一个文件复制到另一个文件的代码。在 Java 的早期版本中,开发人员通常使用该try-finally构造来确保即使在复制时发生异常也能释放资源:

FileInputStream  input  = null;
FileOutputStream output = null;
try {
input = new FileInputStream(file1);
output = new FileOutputStream(file2);
... copy bytes from input to output ...
output.close(); output = null;
input.close(); input = null;
} finally {
if (output != null) output.close();
if (input != null) input.close();
}

这段代码是错误的:如果复制抛出异常,并且块output.close()中的语句finally抛出异常,那么输入流将被泄漏。处理所有可能的执行路径中的异常是费力且难以正确处理的。 (此处的修复涉及嵌套try-finally构造,并留给读者作为练习。)即使未处理的异常只是偶尔发生,泄漏的资源也会随着时间的推移而积累。

最终确定——及其缺陷

Java 1.0 中引入的 Finalization 旨在帮助避免资源泄漏。类可以声明一个_终结器_(方法protected void finalize()),其主体释放所有底层资源。 GC 会在回收对象内存之前安排调用不可访问对象的终结器;反过来,该finalize方法可以执行一些操作,例如调用对象的close方法。

乍一看,这似乎是防止资源泄漏的有效安全网:如果包含仍然打开的资源的对象变得无法访问(input上面的对象),那么 GC 将调度终结器,这将关闭该资源。实际上,终结利用了垃圾收集的能力来管理非内存资源(Barry Hayes,收集器接口中的终结,内存管理国际研讨会,1992)。

不幸的是,最终确定有几个关键的、根本性的缺陷:

  • 不可预测的延迟——从对象变得不可访问到调用其终结器之间可能会经过任意长的时间。事实上,GC 不保证任何终结器都会被调用。

  • 不受约束的行为——终结器代码可以执行任何操作。特别是,它可以保存对正在终结的对象的引用,从而_复活_该对象并使其再次可访问。

  • 始终启用— 最终化没有明确的注册机制。具有终结器的类可以终结该类的每个实例,无论是否需要。无法取消对象的终结,即使该对象不再需要它。

  • 未指定的线程——终结器以任意顺序在未指定的线程上运行。线程和顺序都无法控制。

这些缺陷在二十多年前就已被广泛认识到。小心 Java 终结的建议早在 1998 年就出现了(Bill Venners,《对象终结和清理:如何为正确的对象清理设计类》 _,JavaWorld,1998 年 5 月),并在 Joshua Bloch 的 2001 年著作《Effective Java》_中得到突出强调(第 6 项:“避免终结器”)。自 2008 年以来, SEI CERT Oracle Java 编码标准建议不要使用终结器

现实世界的后果

最终确定的缺陷结合起来会导致安全性、性能、可靠性和可维护性方面的重大现实问题。

  • 安全漏洞——如果一个类有终结器,那么一旦其构造函数开始执行,该类的新实例就有资格被终结。如果构造函数抛出异常,则新实例不会被销毁,即使它可能没有完全初始化。新实例仍然有资格进行终结,并且其终结器可以对对象执行任意操作,包括复活它以供以后使用。恶意代码可以使用此技术生成格式不正确的对象,从而导致意外错误,或者将原本正确的代码混淆为行为不当。 (同样的漏洞也适用于反序列化创建的对象。)

    简单地从类中省略终结器并不能防止此问题。子类可以声明终结器,从而访问无效构造或反序列化的对象。缓解此问题需要额外的步骤,如果最终确定不是平台的一部分,则不需要这些步骤。Oracle Java SE 安全编码指南中描述了此问题和缓解该问题的技术(请参阅 4-5,“限制类和方法的可扩展性”和 7-3,“防御非最终类的部分初始化实例” )。

  • 性能——终结器的存在就会带来性能损失:GC 必须在创建对象时以及终结对象之前和之后执行额外的工作。例如,Hans Boehm 描述了在向类添加终结时,速度降低了 7-11 倍。最终确定还会导致面向吞吐量的收集器的暂停时间增加,以及低延迟收集器的数据结构开销增加。

    为了安全起见,某些类提供了显式的方法来释放资源,例如close以及终结器。如果用户忘记调用close,则终结器可以释放资源。但是,由于终结器不支持取消,因此始终会付出性能损失,即使对于已释放资源的不必要的终结器也是如此。

  • 执行不可靠——使用终结器的应用程序出现间歇性和难以诊断的故障的风险更高。终结器计划由 GC 运行,但 GC 通常仅在需要满足内存分配请求时才运行。如果可用内存充足,则 GC 可能很少运行,从而导致终止时出现任意延迟。当许多承载资源的对象累积在堆上等待最终确定时,结果可能会导致资源短缺,从而意外地破坏应用程序。此外,终结在未指定数量的线程上运行,因此应用程序线程分配资源的速度可能比终结程序线程释放资源的速度更快,从而再次导致资源短缺。

  • 困难的编程模型——终结器很难正确实现。通常,类的终结器必须调用其超类的终结器,因为不这样做可能会导致资源泄漏。开发人员有责任记住调用super.finalize()和处理任何异常。 Java 编译器不会自动将此调用插入到终结器中,这与它自动将调用插入super(...)到构造函数中的方式相反。

    确保您自己的代码中终结器的正确性不足以防止出现问题。其他组件可以对您的代码进行子类化并覆盖您的终结器。错误的终结器实现可能会有效地破坏以前正确的代码。

    终结器在应用程序未知的一个或多个系统线程上执行。因此,终结器在单线程应用程序中的存在本质上使其成为多线程的。这可能会导致死锁和其他线程问题。

    最终,终结器增加了应用程序架构中的耦合。当应用程序中的多个组件具有终结器时,一个组件对象的终结可能会延迟或以其他方式干扰另一组件对象的终结,特别是因为终结器线程在组件之间共享。此类干扰可能会导致特定类型的可终结对象在堆上累积,从而导致一个组件的资源短缺(如上所述),并最终对其他组件造成有害影响。

替代技术

考虑到与最终确定相关的问题,开发人员应该使用替代技术来避免资源泄漏,即try“with-resources”和“cleaners”。

  • Try-with-resources — Java 7 引入了try-with-resources 语句,try-finally作为对上面所示结构的改进。该语句允许以这样的方式使用资源:close无论是否发生异常,都保证调用其方法。前面的例子可以重写如下:

    try (FileInputStream input = new FileInputStream(file1);
    FileOutputStream output = new FileOutputStream(file2)) {
    ... copy bytes from input to output ...
    }

    try-with-resources 正确处理所有异常情况,避免需要最终确定的安全网。在单个词法范围内打开和关闭的任何资源都应转换为与try-with-resources 一起使用。如果具有终结器的类的实例可以在try-with-resources 语句中独占使用,那么终结器可能是不必要的,并且可以删除。

  • _Cleaners——一些资源的生命周期太长,无法与try-with-resources很好地配合,因此Java 9引入了cleaner API来帮助释放它们。清理器 API 允许程序为对象注册_清理操作,该操作在对象变得不可访问后运行一段时间。清理操作避免了终结器的许多缺点:

    • 没有对象复活——清理操作无法访问对象,因此对象复活是不可能的。

    • 按需启用——构造函数可以在对象完全初始化后为新对象注册清理操作。这意味着清理操作永远不会处理未初始化或部分初始化的对象。此外,程序可以取消对象的清理操作,以便 GC 不再需要安排该操作。

    • 无干扰——开发人员可以控制哪些线程运行清洁操作,因此可以防止清洁操作之间的干扰。此外,错误或恶意的子类不能干扰其超类设置的清理操作。

    然而,与终结器一样,清理操作是由 GC 安排的,因此它们可能会遭受无限的延迟。因此,在需要及时释放资源的情况下不应该使用更干净的API。此外,清理器不应该用来替换终结器,终结器仅充当安全网,以防止未捕获的异常或丢失的close()方法调用;在这种情况下,请try在将终结器转换为清理器之前调查使用 -with-resources 。

    尽管有延迟,清理程序对于高级开发人员来说仍然很有价值,其中一种场景是实现不允许显式close()方法的 API。考虑BigInteger在其底层实现中使用本机内存的类版本。close()向类添加方法BigInteger将从根本上改变其编程模型并排除某些优化。由于用户代码不能closea BigInteger,因此实现者必须依赖 GC 来安排清理操作以释放本机内存。实现者可以平衡开发人员的利益(他们受益于更简单的 API)和运行时开销。 (正在孵化的外部函数和内存 API ( JEP 419 ) 提供了一种更好的方式来访问本机内存,支持使用清理器来释放本机内存并避免资源泄漏。)

概括

最终确定存在严重缺陷,几十年来已得到广泛认可。它在 Java 平台中的存在给整个生态系统带来了负担,因为它使所有库和应用程序代码面临安全性、可靠性和性能风险。它还给 JDK 带来持续的维护和开发成本,特别是 GC 实现。为了推动 Java 平台向前发展,我们将弃用最终确定以进行删除。

描述

我们建议:

  • 添加一个命令行选项来禁用终结,这样 GC 就不会调度任何终结器来运行,并且
  • 弃用标准 Java API 中的所有终结器和终结相关方法。

final请注意,终结与修饰符和finally构造块都不同try-finally。不建议对final或进行任何更改try-finally

禁用终结的命令行选项

默认情况下,最终化在 JDK 18 中保持启用状态。新的命令行选项--finalization=disabled可禁用最终化。启动的 JVM--finalization=disabled不会运行任何终结器——甚至是 JDK 本身中声明的终结器。

您可以使用该选项来帮助确定您的应用程序是否依赖于最终化,并测试删除最终化后它的行为方式。例如,您可以首先运行_不带_该选项的应用程序负载测试,以便启用最终确定,并记录如下指标:

  • Java 堆和/或本机内存的内存配置文件,
  • 统计数据来自BufferPoolMXBeanUnixOperatingSystemMXBean::getOpenFileDescriptorCount, 和
  • jdk.FinalizerStatistics来自JDK Flight Recorder (JFR)的事件。此事件提供有关运行时终结器使用的数据,如JDK 18 发行说明中所述。 JFR 可以按如下方式使用:
    java -XX:StartFlightRecording:filename=recording.jfr ...
    jfr print --events FinalizerStatistics recording.jfr

_然后,您可以使用_该选项重新运行负载测试,以便禁用最终确定。报告的指标显着下降,或者出现错误或崩溃,表明需要调查应用程序依赖于最终确定的位置。运行之间基本相似的结果将在一定程度上保证应用程序不会受到最终删除最终确定的影响。

禁用终结可能会产生不可预测的后果,因此您应该仅在测试时使用该选项,而不是在生产环境中使用。

如果禁用终止,JFR 将不会发出任何jdk.FinalizerStatistics事件。此外,jcmd GC.finalizer_info将报告终结已禁用(而不是报告待终结的对象数量)。

为了完整性,--finalization=enabled支持。

弃用标准 Java API 中的终结器

我们将最终在java.basejava.desktop模块中弃用这些方法,并用 注释它们@Deprecated(forRemoval=true)

  • java.lang.Object.finalize()
  • java.lang.Enum.finalize()
  • java.awt.Graphics.finalize()
  • java.awt.PrintJob.finalize()
  • java.util.concurrent.ThreadPoolExecutor.finalize()
  • javax.imageio.spi.ServiceRegistry.finalize()
  • javax.imageio.stream.FileCacheImageInputStream.finalize()
  • javax.imageio.stream.FileImageInputStream.finalize()
  • javax.imageio.stream.FileImageOutputStream.finalize()
  • javax.imageio.stream.ImageInputStreamImpl.finalize()
  • javax.imageio.stream.MemoryCacheImageInputStream.finalize()

(中的其他三个终结器java.awt.**已被最终弃用,并独立于该 JEP从 Java 18 中删除。)

此外,我们将:

  • 最终弃用java.lang.Runtime.runFinalization()java.lang.System.runFinalization().如果没有最终确定,这些方法就没有任何作用。

  • 通过用 注释来弃用模块接口getObjectPendingFinalizationCount()中的方法。java.lang.management.MemoryMXBean``java.management``@Deprecated(forRemoval=false)

    该方法不是终结机制的一部分,而是查询该机制的操作。我们将弃用此方法,因为开发人员应避免使用它:一旦删除终结,就不会出现挂起的对象,并且该方法将始终返回零。我们不会最终弃用该方法,因为我们不打算删除它。MemoryMXBean是一个接口,因此删除方法可能会对各种独立实现产生不利影响。

未来的工作

我们预计在取消最终确定之前会有一个漫长的过渡期。这将为开发人员提供时间来评估他们的系统是否依赖于最终确定并根据需要迁移他们的代码。我们还设想了其他几个步骤:

  • JDK 本身大量使用了终结器。有些已经被删除或转换为使用CleanerJDK-8253568跟踪剩余终结器的删除。

  • Netty、Log4j、Guava 和 Apache Commons 等知名库都使用终结器。我们将与他们的维护者合作,确保这些库可以安全地从最终确定中迁移出来。

  • 我们将发布文档来帮助开发人员从最终确定的多个方面进行迁移,例如:

    • 如何在库和应用程序代码中查找终结器(例如,使用jdeprscan),
    • 如何确定系统是否依赖最终确定,以及
    • 如何使用终结器将代码转换为使用try-with-resources 或清理器。
  • JDK 的未来版本可能包括以下部分或全部更改:

    • 当使用终结器时在运行时发出警告,
    • 默认情况下禁用终结,并需要--finalization=enabled重新启用它的选项,
    • 稍后,删除终结机制,同时保留不起作用的 API,并且,
    • 最后,在迁移大部分代码后,删除上述最终不推荐使用的方法,包括Object::finalize.

我们不打算重新审视WeakReference和历史上所扮演的独特角色PhantomReference。相反,我们希望在删除终结时更新Java 语言规范,因为终结器与 Java 内存模型交互。