跳到主要内容

JEP 421:弃用 Finalization 以进行移除

QWen Max 中英对照 JEP 421: Deprecate Finalization for Removal

总结

在未来版本中弃用并移除终结器(finalization)。目前终结器默认仍保持启用状态,但可以禁用以方便早期测试。在未来的版本中,它将默认被禁用,并在之后的版本中被彻底移除。依赖终结器的库和应用程序的维护者应考虑迁移到其他资源管理技术,例如 try-with-resources 语句清理器(cleaners)

目标

  • 帮助开发者理解终结(finalization)的危险。
  • 让开发者为在未来版本的 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();
}
java

此代码有误:如果复制操作抛出异常,并且 finally 块中的 output.close() 语句也抛出异常,那么输入流将会泄漏。处理所有可能执行路径上的异常既繁琐又难以做到万无一失。(这里的修复方法涉及一个嵌套的 try-finally 结构,留给读者作为练习。)即使未处理的异常只是偶尔发生,泄漏的资源也会随着时间的推移而累积。

终结化及其缺陷

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

乍一看,这似乎是一个防止资源泄漏的有效安全网:如果一个包含仍然打开的资源的对象变得不可达(上面的 input 对象),那么垃圾回收器(GC)将调度终结器,终结器将关闭资源。实际上,终结过程利用了垃圾回收的能力来管理非内存资源(Barry Hayes,Finalization in the Collector Interface,国际内存管理研讨会,1992 年)。

不幸的是,终结存在几个严重的关键性基本缺陷:

  • 不可预测的延迟 — 从对象变为不可达那一刻到其终结器被调用之间,可能会经过任意长的时间。实际上,GC 并不保证任何终结器一定会被调用。

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

  • 始终启用 — 终结化没有显式的注册机制。具有终结器的类会为该类的每个实例启用终结化,无论是否需要。即使某个对象不再需要终结化,也无法取消对其的终结化。

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

这些缺陷在二十多年前就已被广泛认识。早在 1998 年,就出现了谨慎使用 Java 析构器的建议(Bill Venners,对象析构与清理:如何设计类以实现正确的对象清理,JavaWorld,1998 年 5 月),并在 Joshua Bloch 于 2001 年出版的《Effective Java》一书中被重点提及(第 6 条:“避免使用终结方法”)。自 2008 年以来,SEI CERT Oracle Java 编码标准 就已建议不要使用终结方法

现实世界的后果

这些缺陷结合在一起,导致了在安全性、性能、可靠性以及可维护性方面出现重大的现实问题。

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

    简单地从一个类中省略终结器并不能防止这个问题。子类可以声明一个终结器,从而获得对未正确构造或反序列化的对象的访问权限。缓解这个问题需要采取额外的步骤,而这些步骤在终结机制不是平台一部分的情况下是不需要的。这个问题以及缓解它的技术在 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(带资源的 try 语句) —— Java 7 引入了 try-with-resources statement,作为对上述 try-finally 构造的改进。该语句允许以一种无论是否发生异常都能保证调用其 close 方法的方式来使用资源。前面的例子可以重写如下:

    try (FileInputStream input = new FileInputStream(file1);
    FileOutputStream output = new FileOutputStream(file2)) {
    ... 将字节从 input 复制到 output ...
    }
    java

    try-with-resources 能够正确处理所有异常情况,避免了终结器(finalization)这种安全网的需求。任何在单个词法作用域内打开和关闭的资源都应转换为与 try-with-resources 配合使用。如果某个带有终结器的类的实例可以仅在 try-with-resources 语句中使用,则该终结器可能是不必要的,可以移除。

  • Cleaners(清理器) —— 某些资源的生命周期过长,无法很好地与 try-with-resources 协作,因此 Java 9 引入了 cleaner API 来帮助释放这些资源。清理器 API 允许程序为一个对象注册一个 清理操作,该操作会在对象变为不可达之后的某个时间运行。清理操作避免了终结器的许多缺点:

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

    • 按需启用 —— 构造函数可以在对象完全初始化后为其注册清理操作。这意味着清理操作永远不会处理未初始化或部分初始化的对象。此外,程序可以取消对象的清理操作,这样垃圾回收器(GC)就不再需要调度该操作。

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

    然而,与终结器类似,清理操作是由垃圾回收器调度的,因此可能会遭受无限延迟的影响。因此,在需要及时释放资源的情况下不应使用清理器 API。此外,清理器不应被用来替代仅作为安全网以防止未捕获异常或遗漏 close() 方法调用的终结器;在这种情况下,应先调查使用 try-with-resources,然后再将终结器转换为清理器。

    尽管存在延迟问题,但在某些场景下,清理器对高级开发者仍然很有价值,例如实现不允许显式 close() 方法的 API。考虑一个在其底层实现中使用本地内存的 BigInteger 类版本。向 BigInteger 类添加 close() 方法会从根本上改变其编程模型,并排除某些优化。由于用户代码无法 close 一个 BigInteger,实现者必须依赖垃圾回收器调度清理操作来释放本地内存。实现者可以在开发者受益于更简单的 API 和运行时开销之间进行权衡。(孵化中的 Foreign Function & Memory API (JEP 419) 提供了一种更好的访问本地内存的方式,支持使用清理器来释放本地内存并避免资源泄漏。)

总结

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

描述

我们建议:

  • 添加一个命令行选项以禁用终结操作,这样垃圾回收器(GC)就不会安排任何终结器运行,并且
  • 弃用标准 Java API 中的所有终结器以及与终结相关的所有方法。

需要注意的是,终结化与 final 修饰符和 try-finally 构造中的 finally 块都不同。对于 finaltry-finally,都没有提出任何更改建议。

用于禁用终结的命令行选项

在 JDK 18 中,默认情况下仍然启用终结(Finalization)。新的命令行选项 --finalization=disabled 可以禁用终结功能。使用 --finalization=disabled 启动的 JVM 将不会运行任何终结器,甚至包括在 JDK 内部声明的终结器也不会执行。

你可以使用该选项来帮助确定你的应用程序是否依赖终结操作,并测试一旦移除终结操作后它将如何运行。例如,你可以首先在不禁用终结操作的情况下运行应用程序负载测试(即启用终结操作),并记录以下指标:

  • Java 堆和/或本地内存的内存配置文件,
  • 来自 BufferPoolMXBeanUnixOperatingSystemMXBean::getOpenFileDescriptorCount 的统计信息,以及
  • JDK Flight Recorder (JFR)jdk.FinalizerStatistics 事件。该事件提供了运行时关于终结器使用的数据,如 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.** 中的其他三个终结器已经被终止弃用,并且已经从 Java 18 中移除,这与本 JEP 无关。)

此外,我们将:

  • 终极弃用 java.lang.Runtime.runFinalization()java.lang.System.runFinalization()。在没有终结(finalization)的情况下,这些方法没有任何作用。

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

    该方法并非终结机制的一部分,而是用于查询该机制的运行状态。我们将弃用此方法,因为开发者应避免使用它:一旦终结被移除,将永远不会有对象处于待终结状态,该方法将始终返回零。我们不会终极弃用该方法,因为我们不打算移除它。MemoryMXBean 是一个接口,因此移除方法可能会对各种独立实现产生不利影响。

未来工作

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

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

  • 一些知名库如 Netty、Log4j、Guava 和 Apache Commons 使用了终结器。我们将与这些库的维护者合作,确保它们能够安全地迁离终结化(finalization)机制。

  • 我们将发布文档以帮助开发者完成从终结化迁移的多个方面工作,例如:

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

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

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