Skip to content

JEP 421: Deprecate Finalization for Removal | 弃用终结器以进行移除

摘要

在未来的版本中,计划废弃并最终移除终结化(finalization)机制。目前,终结化仍默认启用,但可以禁用以便于早期测试。在未来的某个版本中,终结化将默认禁用,并在之后的版本中完全移除。依赖终结化的库和应用程序的维护者应考虑迁移到其他资源管理技术,如 try-with-resources 语句Cleaner

目标

  • 帮助开发者了解终结化的危险性。
  • 让开发者为 Java 未来版本中终结化的移除做好准备。
  • 提供简单的工具来帮助检测对终结化的依赖。

动机

资源泄露

Java 程序享受自动内存管理,即 JVM 的垃圾收集器(GC)会在对象不再需要时回收其占用的内存。然而,一些对象代表操作系统提供的资源,如打开的文件描述符或原生内存块。对于这类对象,仅仅回收对象的内存是不够的;程序还必须将底层资源释放回操作系统,这通常通过调用对象的 close 方法来实现。如果程序在 GC 回收对象之前没有做到这一点,那么释放资源所需的信息就会丢失。操作系统仍然认为这些资源正在使用中,从而导致 资源泄露

资源泄露可能非常普遍。考虑以下将数据从一个文件复制到另一个文件的代码。在 Java 的早期版本中,开发者通常使用 try-finally 结构来确保即使在复制过程中发生异常时也能释放资源:

java
FileInputStream  input  = null;
FileOutputStream output = null;
try {
    input  = new FileInputStream(file1);
    output = new FileOutputStream(file2);
    ... 复制字节从输入到输出 ...
    output.close();  output = null;
    input.close();   input  = null;
} finally {
    if (output != null) output.close();
    if (input  != null) input.close();
}

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

终结化及其缺陷

终结化(Finalization)在 Java 1.0 中引入,旨在帮助避免资源泄露。一个类可以声明一个 终结器——即 protected void finalize() 方法——其主体用于释放任何底层资源。垃圾收集器(GC)将安排调用无法访问对象的终结器,在其回收对象内存之前;相应地,finalize 方法可以执行诸如调用对象的 close 方法等操作。

乍一看,这似乎是一个防止资源泄露的有效安全网:如果一个包含仍打开的资源的对象变得无法访问(如上面的 input 对象),则 GC 将安排调用终结器,从而关闭资源。实际上,终结化利用了垃圾收集的力量来管理非内存资源(Barry Hayes, Collector Interface 中的终结化,国际内存管理工作坊,1992 年)。

不幸的是,终结化存在几个关键且根本性的缺陷:

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

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

  • 始终启用 —— 终结化没有显式的注册机制。具有终结器的类会为其每个实例启用终结化,无论是否需要。一旦启用,对象的终结化就不能被取消,即使对于该对象来说已经不再必要。

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

这些缺陷在二十多年前就已被广泛认识到。早在 1998 年,就出现了关于谨慎使用 Java 终结化的建议(Bill Venners, 对象终结化和清理:如何为适当的对象清理设计类,JavaWorld,1998 年 5 月),并在 Joshua Bloch 2001 年的著作《Effective Java》(第 6 条:“避免终结器”)中显著提及。自 2008 年以来,《SEI CERT Oracle Java 编码标准》 一直 建议不要使用终结器

现实世界的后果

终结化的缺陷结合在一起,在安全、性能、可靠性和可维护性方面引发了重大的现实问题。

  • 安全漏洞 —— 如果一个类有终结器,那么该类的新实例一旦其构造函数开始执行就符合终结条件。如果构造函数抛出异常,新实例不会被销毁,尽管它可能尚未完全初始化。新实例仍然符合终结条件,其终结器可以对对象执行任意操作,包括将其复活以供以后使用。恶意代码可以利用这种技术生成格式不正确的对象,导致意外错误,或者使原本正确的代码行为异常。(同样的漏洞也适用于通过反序列化创建的对象。)

    仅仅从类中省略终结器并不能防止这个问题。子类可以声明终结器,从而获得对无效构造或反序列化对象的访问权限。要缓解这个问题,需要采取额外的步骤,而这些步骤在终结化不是平台一部分的情况下是不必要的。这个问题以及缓解它的技术在 Oracle Java SE 安全编码指南 中有描述(参见 4-5,“限制类和方法的可扩展性”和 7-3,“防范非 final 类部分初始化实例”)。

  • 性能 —— 仅仅因为存在终结器,就会对性能造成惩罚:GC 在对象创建时,以及在终结之前和之后都必须做额外的工作。例如,Hans Boehm 描述了 向类添加终结器会导致 7-11 倍的性能下降。终结化还会导致吞吐量导向收集器的暂停时间增加,以及低延迟收集器的数据结构开销增加。

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

  • 不可靠的执行 —— 使用终结器的应用程序面临更高的间歇性和难以诊断的故障风险。终结器由 GC 调度运行,但 GC 通常仅在必要时为满足内存分配请求而运行。如果空闲内存充足,GC 可能会不频繁运行,从而导致终结化出现任意延迟。当许多承载资源的对象在堆上累积等待终结时,结果可能是资源短缺,导致应用程序不可预测地崩溃。此外,终结化在未知数量的线程上运行,因此应用程序线程可能会比终结器线程更快地分配资源,从而导致资源短缺。

  • 复杂的编程模型 —— 终结器出人意料地难以正确实现。通常,一个类的终结器必须调用其父类的终结器,因为不这样做可能会导致资源泄露。开发人员有责任记住调用 super.finalize() 并处理任何异常。与构造函数中自动插入 super(...) 调用不同,Java 编译器不会自动在终结器中插入此调用。

    确保自己代码中的终结器正确无误并不足以防止问题。其他组件可以子类化你的代码并覆盖你的终结器。他们那边不正确的终结器实现可能会有效地破坏之前正确的代码。

    终结器在一个或多个系统线程上执行,这些线程对应用程序来说是未知的。因此,在一个原本是单线程的应用程序中,终结器的存在本质上使其变成了多线程。这引入了死锁和其他线程问题的潜在风险。

    最终,终结器增加了应用程序架构中的耦合性。当应用程序中的多个组件有终结器时,一个组件对象的终结化可能会延迟或干扰另一个组件对象的终结化,特别是因为终结器线程在组件之间是共享的。这种干扰可能会导致特定类型的可终结对象在堆上累积,如上所述,导致一个组件的资源短缺,并最终对其他组件产生不利影响。

替代技术

鉴于终结器存在的问题,开发人员应使用替代技术来避免资源泄漏,即 try-with-resources 和清理器。

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

    java
    try (FileInputStream input = new FileInputStream(file1);
         FileOutputStream output = new FileOutputStream(file2)) {
        ... 从输入复制到输出 ...
    }

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

  • 清理器 — 有些资源的生命周期太长,不适合与 try-with-resources 一起使用,因此 Java 9 引入了 清理器 API 来帮助释放它们。清理器 API 允许程序为在对象变得不可达之后某个时间运行的对象注册一个 清理操作。清理操作避免了终结器的许多缺点:

    • 无对象复活 — 清理操作无法访问对象,因此无法复活对象。

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

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

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

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

摘要

终结(Finalization)机制存在已被广泛认知几十年的严重缺陷。它在 Java 平台中的存在给整个生态系统带来了负担,因为它使所有库和应用程序代码面临安全、可靠性和性能风险。此外,它还给 JDK,特别是垃圾收集(GC)实现带来了持续的维护和开发成本。为了推动 Java 平台向前发展,我们将弃用终结机制以便将来移除。

描述

我们提议:

  • 添加一个命令行选项以禁用终结,这样 GC 就永远不会安排任何终结器运行,

  • 在标准 Java API 中弃用所有终结器以及与终结相关的方法。

请注意,终结与 final 修饰符和 try-finally 结构中的 finally 块是不同的。我们不对 finaltry-finally 提出任何更改。

禁用终结的命令行选项

在 JDK 18 中,终结机制默认仍然启用。一个新的命令行选项 --finalization=disabled 用于禁用终结。使用 --finalization=disabled 启动的 JVM 将不会运行任何终结器——即使是 JDK 本身声明的那些也不会。

您可以使用此选项来确定您的应用程序是否依赖于终结机制,并测试在移除终结机制后它将如何表现。例如,您可以首先在没有该选项的情况下运行应用程序负载测试,以便启用终结机制,并记录以下指标:

  • Java 堆和/或本机内存的内存配置文件,

  • 来自 BufferPoolMXBeanUnixOperatingSystemMXBean::getOpenFileDescriptorCount 的统计信息,

  • JDK Flight Recorder (JFR) 中的 jdk.FinalizerStatistics 事件。该事件提供关于运行时终结器使用的数据,如 JDK 18 发布说明 中所述。JFR 的使用方法如下:

    bash
    java -XX:StartFlightRecording:filename=recording.jfr ...
    jfr print --events jdk.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()。没有终结机制,这些方法将毫无用处。

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

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

未来工作

我们预计在移除终结机制之前将有一个漫长的过渡期,这将为开发人员提供时间来评估其系统是否依赖于终结机制,并在必要时迁移其代码。我们还设想了几个其他步骤:

  • JDK 本身大量使用终结器。其中一些已经被移除或转换为使用 CleanerJDK-8253568 跟踪剩余终结器的移除情况。

  • 知名库如 Netty、Log4j、Guava 和 Apache Commons 使用终结器。我们将与这些库的维护者合作,以确保这些库可以安全地迁移到不使用终结器的状态。

  • 我们将发布文档,以帮助开发人员从多个方面迁移到不使用终结器的状态,例如:

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

    • 当使用终结器时,在运行时发出警告,
    • 默认禁用终结机制,并需要 --finalization=enabled 选项来重新启用它,
    • 稍后,移除终结机制,同时保留非功能性 API,
    • 最后,在大多数代码迁移后,移除上述最终被弃用的方法,包括 Object::finalize

我们不打算重新考虑WeakReferencePhantomReference在历史上扮演的不同角色。相反,我们预计在移除终结机制时更新Java 语言规范,因为终结器与 Java 内存模型交互。