如何在没有 finalize 的情况下处理 Java 错误和清理资源

By | 2022年2月21日

How to handle Java errors and cleanup without finalize

经过几年的讨论,Java 正准备在 JDK 18 中弃用 finalize 方法。JDK 增强提案 421 涵盖了这一点,该提案将 finalize 标记为已弃用,并允许将其关闭以进行测试。 它将保持默认启用。 它将在未来的版本中完全删除。 在这个场合,让我们看看 finalize 的结束意味着什么,以及我们现在应该如何处理错误和资源清理。

什么是finalize

在我们了解为什么 finalize 会消失以及改用什么之前,让我们先了解一下 finalize 是什么或曾经是什么。

基本思想是允许您在对象上定义一个方法,该方法将在对象准备好进行垃圾回收时执行。 从技术上讲,当一个对象变为虚可达时,它就可以进行垃圾回收了,这意味着 JVM 中不会留下强引用或弱引用。

这时 JVM 将执行 object.finalize() 方法,然后特定于应用程序的代码将清理任何资源,例如 I/O 流或数据存储的句柄。

Java 中的根 Object 类有一个 finalize() 方法,以及其他方法,如 equals() 和 hashCode()。 这是为了使每个编写过的 Java 程序中的每个对象都能够参与这种避免资源泄漏的直接机制。

请注意,这也解决了抛出异常和可能遗漏其他清理代码的情况:对象仍将被标记为垃圾回收,并且最终将调用其 finalize 方法。 问题解决了,对吧? 只需覆盖资源消耗对象的 finalize() 方法。

finalize的问题

这就是想法,但现实完全是另一回事。 finalize 存在许多缺点,阻碍了清理乌托邦的实现。 (这种情况类似于 serialize(),另一种在纸面上看起来不错但在实践中出现问题的方法。)

在 finalize 的问题中:

  • Finalize 可以以意想不到的方式运行。有时 GC 会在您认为它之前确定您的对象没有对它的实时引用。
  • Finalize 可能永远不会运行,或者会在很长一段时间后运行。另一方面,您的 finalize 方法可能永远不会运行。正如 JEP 421 RFC 所述,“GC 通常仅在需要满足内存分配请求时才运行。”所以你是在 GC 心血来潮的摆布。
  • Finalize 可以使原本死掉的类复活。有时,一个对象会触发一个异常,使其符合 GC 条件。然而,finalize() 方法有机会首先运行,并且该方法可以做任何事情,包括重新建立对对象的实时引用。这是一个潜在的泄漏源和安全隐患。
  • Finalize 很难正确实现。直接编写一个功能强大且无错误的可靠 finalize 方法并不像看起来那么容易。特别是,不能保证 finalize 的线程含义。终结器可以在任何线程上运行,从而引入非常难以调试的错误条件。忘记调用 finalize() 会导致难以发现的问题。
  • 表现。鉴于 finalize 在实现其既定目的时的不可靠性,JVM 支持它的开销是不值得的。
  • Finalize 使得更脆弱的大规模应用程序变得更加脆弱。研究得出的结论是,使用 finalize 的大型软件更容易脆弱,并且会遇到在重负载下出现的难以重现的错误情况。

没有finalize以后的做法

现在处理错误和清理的正确方法是什么? 我们将在这里查看三个替代方案:try-catch-finally 块、try-with-resource 语句和cleaner。 每个都有其优点和缺点。

Try-catch-finally

处理资源释放的老式方法是通过 try-catch 块。 这在许多情况下都是可行的,但它容易出错且冗长。 例如,要完全捕获嵌套错误条件(即关闭资源时也会引发异常),您需要类似于清单 1 的内容。

FileOutputStream outStream = null;
try {
  outStream = new FileOutputStream("output.file");
  ObjectOutputStream stream = new ObjectOutputStream(outStream);
  stream.write //…
  stream.close();
} catch(FileNotFoundException ffe) {
  throw new RuntimeException("Could not open file for writing", ffee);
} catch(IOException ioe) {
  System.err.println("Error writing to file");
} finally {
  if (outStream != null) {
    try {
      outStream.close();
    } catch (Exception e) {
      System.err.println(“Failed to close stream”, e);
    }
  }
}

这似乎有点矫枉过正,但在一个长期运行且使用量很大的系统中,这些情况可能会导致资源泄漏,从而导致应用程序崩溃。 因此,必须在整个代码库中重复冗长。 这些东西因破坏代码流而臭名昭著。

在清单 1 中您想要做的就是打开一个流,向它写入一些字节,并确保它被关闭,而不管抛出什么异常。 为此,您必须将调用包装在 try 块中,并且如果引发任何已检查的异常,请处理它们(通过引发包装的运行时异常或将异常打印到日志中)。

然后,您需要添加一个 finally 块来仔细检查流。 这是为了确保异常不会阻止关闭。 但是你不能只是关闭流; 您必须将其包装在另一个 try 块中,以确保关闭不会自行出错。

对于一个简单而常见的需求,这需要大量的工作和中断。

Try-with-resource

在 Java 7 中引入的 try-with-resource 语句允许您指定一个或多个资源对象作为 try 声明的一部分。 当 try 块完成时,这些资源保证会被关闭。

具体来说,任何实现 java.lang.AutoCloseable 的类都可以提供给 try-with-resource。 这几乎涵盖了您在 Java 生态系统中可以找到的所有常用资源。

让我们重写清单 1 以使用 try-with-resource 语句,如清单 2 所示。

try (FileOutputStream outStream = new ObjectOutputStream(outStream)) {
  ObjectOutputStream stream = new ObjectOutputStream(outStream);
  stream.write //…
  stream.close();
} catch(FileNotFoundException ffe) {
  throw new RuntimeException("Could not open file for writing", ffee);
} catch(IOException ioe) {
  System.err.println("Error writing to file");
}

您可以看到这里有许多好处可以减少代码占用,但最大的好处是,一旦您通过在 try 块括号内声明流(或您正在使用的任何内容)将其移交给 VM,您 不必再担心了。 它将为您关闭。 没有资源泄漏。

我们已经消除了对 finally 块或任何最终确定的调用的需要。 这解决了大多数用例的主要问题(尽管检查错误处理的冗长仍然存在)。

在某些情况下,当事情太复杂而无法像这样在单个块中处理时,需要更精细和更强大的解决方案。 对于这些情况,Java 开发人员需要更强大的东西。 对于这些情况,您需要cleaner。

Cleaner

Java 9 中引入了 Cleaner 类。 Cleaner 允许您为引用组定义清理操作。 Cleaners 产生一个 Cleanable 实现,该接口继承自 Runnable。 每个 Cleanable 都在一个忽略异常的专用线程中运行。

这里的想法是将清理例程与使用需要清理的对象的代码分离。 让我们使用 Oracle 在文档中提供的示例更具体地说明这一点,如清单 3 所示。

public class CleaningExample implements AutoCloseable {
  // A cleaner, preferably one shared within a library
  private static final Cleaner cleaner = <cleaner>;
  static class State implements Runnable {
    State(...) {
      // initialize State needed for cleaning action
    }
    public void run() {
      // cleanup action accessing State, executed at most once
    }
  }
  private final State;
  private final Cleaner.Cleanable cleanable;
  public CleaningExample() {
    this.state = new State(...);
    this.cleanable = cleaner.register(this, state);
  }
  public void close() {
    cleanable.clean();
  }
}

首先,也许最重要的是,您可以显式调用 close() 方法来清理您的引用。 这与 finalize() 不同,后者完全依赖于来自垃圾收集器的(不确定的)调用。

如果 close() 调用没有显式进行,系统将在作为cleaner.register() 的第一个参数传递的对象变为虚可达时为您执行它。 但是,如果您(开发人员)已经显式执行了 close(),则系统不会调用它。

(请注意,清单 3 中的代码示例生成了一个 AutoCloseable 对象。这意味着它可以传递到 try-with-resource 语句的参数中。)

现在需要注意的是:不要在cleaner的 run 方法中创建对已清理对象的引用,因为这可能会创建僵尸对象(即,将对象重新建立为活动对象)。 这在给出的示例格式中不太可能发生,但如果您将其实现为 lambda(可以访问其封闭范围),则更有可能发生。

接下来,考虑评论“一个更清洁的,最好是在库内共享的”。 这是为什么? 这是因为每个清理器都会产生一个线程,所以共享清理器会降低运行程序的开销。

最后(双关语),请注意,被监控的对象与执行清理工作的代码(在示例中为状态)是分离的。

再见吧finalize

Java 不断发展。 对于我们这些热爱和使用它的人来说,这是个好消息。 finalize() 的弃用和新方法的添加都是对未来承诺的良好迹象。