Java 中 finalize 方法实用指南

转自 A Guide to the finalize Method in Java

1. 概述

在本教程中,我们将专注于Java语言的一个核心方面 - 根Object类提供的 finalize 方法。

简单来说,这个方法在特定对象的垃圾回收之前被调用。

2. 使用Finalizers

finalize() 方法称为终结器(finalizer)。

当JVM确定该特定实例应该进行垃圾回收时,将调用终结器。这样的终结器可以执行任何操作,包括使对象复活。

然而,终结器的主要目的是在对象从内存中删除之前释放对象使用的资源。终结器可以作为主要的清理操作机制,或者作为其他方法失败时的安全网。

为了理解终结器的工作原理,让我们来看一个类的声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Finalizable {
private BufferedReader reader;

public Finalizable() {
InputStream input = this.getClass().getClassLoader().getResourceAsStream("file.txt");
this.reader = new BufferedReader(new InputStreamReader(input));
}

public String readFirstLine() throws IOException {
String firstLine = reader.readLine();
return firstLine;
}

// 其他类成员
}

Finalizable有一个字段reader,它引用一个可关闭的资源。当从该类创建对象时,它构造一个新的BufferedReader实例来读取类路径中的文件。

这样的实例在readFirstLine方法中用于提取给定文件的第一行。注意,给定代码中没有关闭读取器。

我们可以使用终结器来做到这一点:

1
2
3
4
5
6
7
8
9
@Override
public void finalize() {
try {
reader.close();
System.out.println("在终结器中关闭了BufferedReader");
} catch (IOException e) {
// ...
}
}

很容易看出,终结器的声明就像任何正常的实例方法一样。

实际上,垃圾收集器调用终结器的时间取决于JVM的实现和系统的条件,这是我们无法控制的。

为了让垃圾收集立即发生,我们可以利用System.gc方法。在真实的系统中,我们永远不应该显式地调用它,有很多原因:

  1. 这是昂贵的
  2. 它不会立即触发垃圾收集 - 它只是JVM开始GC的一个提示
  3. JVM知道什么时候需要调用GC

如果我们需要强制GC,我们可以使用jconsole

下面是一个演示终结器操作的测试用例:

1
2
3
4
5
6
@Test
public void whenGC_thenFinalizerExecuted() throws IOException {
String firstLine = new Finalizable().readFirstLine();
assertEquals("baeldung.com", firstLine);
System.gc();
}

在第一条语句中,创建了一个Finalizable对象,然后调用它的readFirstLine方法。这个对象没有被分配给任何变量,因此当调用System.gc方法时,它是可以进行垃圾回收的。

测试中的断言验证了输入文件的内容,并仅用于证明我们的自定义类按预期工作。

当我们运行提供的测试时,将在控制台上打印出有关缓冲读取器在终结器中关闭的消息。这意味着 finalize 方法被调用并清理了资源。

到目前为止,终结器看起来是一种执行销毁前操作的好方法。然而,这并不完全正确。

在接下来的部分中,我们将看到为什么应该避免使用它们。

3. 避免使用Finalizers

尽管它们带来了好处,但终结器也有很多缺点。

3.1. 终结器的缺点

让我们来看看使用终结器执行关键操作时会面临的几个问题。

首先显而易见的问题是缺乏及时性。我们无法知道终结器何时运行,因为垃圾收集可能随时发生。

单独来看,这并不是一个问题,因为终结器仍然会执行,早晚会发生。然而,系统资源并不是无限的。因此,我们可能在清理发生之前耗尽资源,导致系统崩溃。

终结器还会影响程序的可移植性。由于垃圾收集算法依赖于JVM的实现,程序在一个系统上运行得非常好,而在另一个系统上的行为可能会有所不同。

性能成本是另一个重要问题,伴随终结器而来。具体而言,JVM在构造和销毁包含非空终结器的对象时必须执行更多的操作

我们将要讨论的最后一个问题是在终结期间缺乏异常处理。如果终结器抛出异常,终结化过程将停止,对象将处于一种损坏的状态,而不会收到任何通知。

3.2. 终结器效果演示

现在是时候把理论放在一边,看看终结器在实践中的效果。

让我们定义一个新的类,它有一个非空的终结器:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class CrashedFinalizable {
public static void main(String[] args) throws ReflectiveOperationException {
for (int i = 0; ; i++) {
new CrashedFinalizable();
// 其他代码
}
}

@Override
protected void finalize() {
System.out.print("");
}
}

注意*finalize()*方法 - 它只是将一个空字符串打印到控制台

如果该方法完全为空,JVM将认为对象没有终结器。因此,在这种情况下,我们需要为*finalize()*提供一个实现,但实际上它几乎什么也不做。

main方法中,每次迭代都会创建一个新的CrashedFinalizable实例。这个实例没有被分配给任何变量,因此在垃圾回收时它是可以回收的。

让我们在标有*// 其他代码*的行添加一些语句,以查看运行时内存中存在多少对象:

1
2
3
4
5
6
7
8
9
10
11
if ((i % 1_000_000) == 0) {
Class<?> finalizerClass = Class.forName("java.lang.ref.Finalizer");
Field queueStaticField = finalizerClass.getDeclaredField("queue");
queueStaticField.setAccessible(true);
ReferenceQueue<Object> referenceQueue = (ReferenceQueue) queueStaticField.get(null);

Field queueLengthField = ReferenceQueue.class.getDeclaredField("queueLength");
queueLengthField.setAccessible(true);
long queueLength = (long) queueLengthField.get(referenceQueue);
System.out.format("队列中有%d个引用%n", queueLength);
}

上述语句访问了内部JVM类中的一些字段,并在每个百万次迭代后打印出对象引用的数量。

通过执行main方法来启动程序。我们可能会期望它无限运行,但事实并非如此。几分钟后,我们应该看到系统崩溃,出现类似以下的错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
...
There are 21914844 references in the queue
There are 22858923 references in the queue
There are 24202629 references in the queue
There are 24621725 references in the queue
There are 25410983 references in the queue
There are 26231621 references in the queue
There are 26975913 references in the queue
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
at java.lang.ref.Finalizer.register(Finalizer.java:91)
at java.lang.Object.<init>(Object.java:37)
at com.baeldung.finalize.CrashedFinalizable.<init>(CrashedFinalizable.java:6)
at com.baeldung.finalize.CrashedFinalizable.main(CrashedFinalizable.java:9)

Process finished with exit code 1

看起来垃圾收集器没有很好地完成其工作 - 对象的数量一直在增加,直到系统崩溃。

如果我们删除了终结器,引用的数量通常会为0,并且程序会一直运行下去。

3.3. 解释

要理解为什么垃圾收集器没有按预期般丢弃对象,我们需要查看JVM的内部工作方式。

当创建一个带有终结器的对象(也称为引用对象)时,JVM会创建一个相应的java.lang.ref.Finalizer类型的引用对象。在引用对象准备进行垃圾收集时,JVM将其标记为待处理并将其放入引用队列中。

我们可以通过java.lang.ref.Finalizer类中的静态字段queue访问这个队列。

同时,一个名为Finalizer的特殊守护线程会一直运行并查找引用队列中的对象。当找到一个对象时,它会从队列中删除引用对象并调用引用对象上的终结器。

在接下来的垃圾回收周期中,引用对象将被丢弃 - 当它不再被引用对象引用时。

如果一个线程以很高的速度产生对象,这就是我们示例中发生的情况,Finalizer线程无法跟上。最终,内存无法存储所有的对象,我们就会遇到OutOfMemoryError

请注意,像本节中显示的以超光速创建对象的情况在现实生活中并不经常发生。然而,它演示了一个重要的观点 - 终结器是非常昂贵的。

4. 无终结器示例

让我们探索一种解决方案,提供了相同的功能但不使用*finalize()*方法。请注意,下面的示例不是替换终结器的唯一方法。

相反,它用于证明一个重要观点:总是有办法避免使用终结器。

以下是我们新类的声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class CloseableResource implements AutoCloseable {
private BufferedReader reader;

public CloseableResource() {
InputStream input = this.getClass().getClassLoader().getResourceAsStream("file.txt");
reader = new BufferedReader(new InputStreamReader(input));
}

public String readFirstLine() throws IOException {
String firstLine = reader.readLine();
return firstLine;
}

@Override
public void close() {
try {
reader.close();
System.out.println("在close方法中关闭了BufferedReader");
} catch (IOException e) {
// 处理异常
}
}
}

很容易看出,新的CloseableResource类与之前的Finalizable类唯一的区别是实现AutoCloseable接口而不是终结器定义。

注意,CloseableResourceclose方法的内容几乎与类Finalizable中终结器的内容相同。

以下是一个测试方法,它读取一个输入文件并在完成任务后释放资源:

1
2
3
4
5
6
7
@Test
public void whenTryWResourcesExits_thenResourceClosed() throws IOException {
try (CloseableResource resource = new CloseableResource()) {
String firstLine = resource.readFirstLine();
assertEquals("baeldung.com", firstLine);
}
}

在上面的测试中,CloseableResource实例在try-with-resources语句的try块中创建,因此在try-with-resources块执行完成时,该资源将自动关闭。

运行上述测试方法,我们将看到来自CloseableResource类的close方法的输出消息。

5. 结论

在本

教程中,我们专注于Java的一个核心概念 - finalize方法。它在理论上看起来很有用,但在运行时可能会产生丑陋的副作用。而且,更重要的是,总是有一种替代方案来避免使用终结器。

一个关键的要点是,finalize从Java 9开始已被弃用,并将最终被移除。

如往常一样,本教程的源代码可以在GitHub上找到