暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

避免使用终结方法和清理方法(Avoid fifinalizers and cleaners)

原创 手机用户0951 2022-12-21
91

避免使用终结方法和清理方法(Avoid fifinalizers and

cleaners)

  终结方法是不可预测的,通常很危险,一般情况下是不必要的(Finalizers are unpredictable, often

dangerous, and generally unnecessary.)。使用终结方法会导致行为不稳定,降低性能,以及可移植

性问题。当然,终结方法也有可用之处,我们将在本项的最后再做介绍;但是,作为一项规则,我们应

该避免使用它们。在 Java 9 中,终结方法已经过时了,但是在 Java 库中还在使用。Java 9 中替代终结

方法的方式是清理方法。清理方法比终结方法危险性更低,但仍然是不可预测的,性能低,而且是不必

要的(Cleaners are less dangerous than fifinalizers, but still unpredictable, slow, and generally

unnecessary)。

  提醒 C++ 程序员不要将终结方法或清理方法视为 Java 的 C++ 析构函数的类比。在 C++ 中,析构函

数是回收与对象关联的资源的常用方法,对象是构造函数的必要对应物。在 Java 中,当一个对象无法

访问时,垃圾回收器会回收与对象相关联的内存,而不需要程序员的特别处理(requiring no special

effffort on the part of the programmer)。C++ 的析构函数也可以被用来回收其他的非内存资源。在

Java 中,使用 try-with-resources 或者 try-fifinally 块来完成这个目的。

  终结方法或者清理方法的缺点在于不能保证会被及时地执行[JLS, 12.6]。从一个对象变成不可达开

始,到它的终结方法或清理方法被执行,所花费的这段时间是任意长的(也就是说我们无法预知一个对象

在销毁之后和执行终结方法和清理方法之间的间隔时间)。这意味着,对时间有严格要求(time-critical)

的任务不应该由终结方法或清理方法来完成。例如,用终结方法来关闭已经打开的文件,这是严重的错

误,因为打开文件的描述符是一种有限的资源。如果由于系统在运行终结方法或清理方法时延迟而导致

许多文件处于打开状态,则程序可能会因为无法再打开文件而运行失败。

  执行终结算法和清除方法的及时性主要取决于垃圾回收算法,垃圾回收算法在不同的 JVM 实现中大

相径庭。如果程序依赖于终结方法或清理方法被执行的时间点,这个程序可能在你测试它的 JVM 上完美

运行,然而在你最重要客户的 JVM 平台上却运行失败,这完全是有可能的。

  延迟终结过程并不只是一个理论问题。为类提供终结方法可以延迟其实例的回收过程。一位同事在

调试一个长期运行的 GUI 应用程序的时候,该应用程序莫名其妙地出现 OutOfMemoryError 错误而死

亡。分析表明,该应用程序死亡的时候,其终结方法队列中有数千个图形对象正在等待被回收和终结。

遗憾的是,终结方法所在的线程优先级比应用程序其他线程的要低得多,所以对象没有在符合回收条件

的时候及时被回收( so objects were not getting fifinalized at the rate they became eligible for

fifinalization)。语言规范并不保证哪个线程将会执行终结方法,所以,除了避免使用终结方法之外,并

没有很轻便的办法能够避免这样的问题。在这方面,清理方法比终结方法要好一些,因为类的创建者可

以控制他们自己的清理线程,但是清理方法仍然是在后台运行,还是在垃圾收集器的控制下,因此无法

保证及时清理。

  语言规范不仅不保证终结方法会被及时地执行,而且根本就不保证它们会被执行。当一个程序终止

的时候,某些已经无法访问的对象上的终结方法却根本没有被执行,这完全是有可能的。因此,你不应

该依赖终结方法或者清理方法来更新重要的持久状态。例如,依赖终结方法或者清理方法来释放共享资

源(比如数据库)上的永久锁,很容易让整个分布式系统垮掉。

  不要被 System.gc 和 System.runFinalization 这两个方法所诱惑,他们确实增加了终结方法和

清理方法被执行的机会,但是他们不保证终结方法或清理方法一定会被执行。唯一声称保证这两个方法

一定会被执行的方法是 System.runFinalizersOnExit ,以及它臭名昭著的孪生兄弟

Runtime.runFinalizersOnExit 。这两个方法都有致命的缺陷,已经被废弃了[ThreadStop]。

  终结方法的另一个问题是忽略了在终止过程中被抛出的未捕获的异常,那么该对象的终结过程也会

终止(Another problem with fifinalizers is that an uncaught exception thrown during fifinalization is

ignored, and fifinalization of that object terminates)[JLS, 12.6]。未捕获的异常会使对象处于破坏的状

态(a corrupt state)。如果另一个线程企图使用这种被破坏的对象,则可能发生任何不确定的行为。正常情况下,未被捕获的异常将会使线程终止,并打印出堆栈信息,但是,如果异常发生在终止过程中,

则不会如此,甚至连警告都不会打印出来。清理方法就不会有这种问题,因为使用清理方法的库可以控

制其所在的线程。

  使用终结方法和清理方法会严重影响性能。在我的机器上,创建一个简单的 AutoCloseable 对象,

使用 try-with-resources 关闭它,并让垃圾收集器回收它的时间大约是 12 ns。使用终结方法之后时间

增加到 550ns。换句话说,用终结方法创建和销毁对象慢了大约 50 倍。这主要是因为终结器会抑制有

效的垃圾收集。如下所述,如果你使用清理方法或终结方法去清理类的所有实例,清理方法和终结方法

的速度是差不多的(在我的机器上每个实例大约 500ns),但是如果你只是把这两个方法作为安全保障

(safety net)的话,清理方法比终结方法快很多。在这种情况下,在我的机器上创建,清理 d 和销毁一个

对象大约需要 66 ns,这意味着如果你不使用它,你需要支付五倍(而不是五十)安全保障的成本

(which means you pay a factor of fifive (not fififty) for the insurance of a safety net if you don’t use

it)。

  终结方法有一个很严重的安全问题:它们会打开你的类直到终结方法对其进行攻击(they open

your class up to fifinalizer attacks)。使用终结方法进行攻击的原理很简单(The idea behind a fifinalizer

attack is simple):如果从构造方法或将其序列化的等价方法(readObject 和 readResolve[第 12 章])中

抛出异常,恶意子类的终结方法可以在部分构造的对象上运行,这些对象应该“死在藤上(died on the

vine)”。这些终结方法可以在一个静态域上记录下这些对象的引用,保护它们不被垃圾回收器回收。一

旦这些异常的对象被记录下来,在这个对象上调用任意方法是一件简单的事情,这些方法本来就不应该

被允许存在。从构造函数中抛出异常应足以防止对象的创建,在终结方法中,事实并非如此(Throwing

an exception from a constructor should be suffiffifficient to prevent an object from coming into

existence; in the presence of fifinalizers, it is not)。这种攻击会产生可怕的后果。fifinal 修饰的类不会受

到终结方法的攻击,因为没人可以编写 fifinal 类的恶意子类。要保护非 fifinal 类受到终结方法的攻击,请

编写一个不执行任何操作的 fifinal fifinalize 方法。

  某些类(比如文件或线程)封装了需要终止的资源,对于这些类的对象,你应该用什么方法来替代

终结方法和清理方法呢?(So what should you do instead of writing a fifinalizer or cleaner for a class

whose objects encapsulate resources that require termination, such as fifiles or threads?)对于这些

类,你只需要让其实现 AutoCloseable 接口,并要求其客户端在每个实例不再需要的时候调用实例上

的 close 方法,通常使用 try-with-resources 来确保即使出现异常时资源也会被终止(第 9 项)。值得一

提的一个细节是实例必须跟踪其本身是否已被关闭: close 方法必须在一个字段中记录这个实例已经无

效,而其他方法必须检查此字段并抛出 IllegalStateException(如果其他方法在实例关闭之后被调

用)。

  那么清理方法和终结方法有什么作用呢?它们可能有两种合理的用途。第一种用途是,当对象的所

有者忘记调用其终止方法的情况下充当安全网(safety net)。虽然不能保证清理方法或终结方法能够及时

调用(或者根本不运行),晚一点释放关键资源总比永远不释放要好。如果你正在考虑编写这样的一个

安全网终结方法,就要考虑清楚,这种额外的保护是否值得你付出这份额外的代价。某些 Java 类库

(如 FileInputStream、FileOutputStream、ThreadPoolExecutor、和 java.sql.Connection)具有充

当安全网终结方法。

  清理方法的第二个合理用途与对象的本地对等体(native peers)有关。本地对等体是普通对象通过本

机方法委托的本机(非 Java)对象,因为本地对等体不是普通对象,因此垃圾收集器不会知道它,并且在

回收 Java 对等体时无法回收它。假设性能可接受并且本地对等体没有关键资源,则清理方法或终结方

法可以是用于该任务的适当工具。如果性能不可接受或者本机对等体拥有必须回收的资源,则该类应该

具有 close 方法,这正如之前所说的。

  清理方法使用起来有一点棘手。下面是一个使用 Room 类简单演示。让我们假设在 rooms 回收之

前必须进行清理。这个 Room 类实现了 AutoCloseable 接口;事实上,它的自动清理安全网采用的是

清理方法的实现仅仅是一个实现细节(the fact that its automatic cleaning safety net uses a cleaner is

merely an implementation detail)。跟终结方法不一样的是,清理方法不会污染类的公共 API:

// An autocloseable class using a cleaner as a safety netpublic class Room implements AutoCloseable {

private static final Cleaner cleaner = Cleaner.create();

// Resource that requires cleaning. Must not refer to Room!

private static class State implements Runnable {

int numJunkPiles; // Number of junk piles in this room

State(int numJunkPiles) {

this.numJunkPiles = numJunkPiles;

}

// Invoked by close method or cleaner

@Override public void run() {

System.out.println("Cleaning room");

numJunkPiles = 0;

}

}

// The state of this room, shared with our cleanable

private final State state;

// Our cleanable. Cleans the room when it’s eligible for gc

private final Cleaner.Cleanable cleanable;

public Room(int numJunkPiles) {

state = new State(numJunkPiles);

cleanable = cleaner.register(this, state);

}

@Override public void close() {

cleanable.clean();

}

}

  静态嵌套 State 类包含清理程序清理 Room 所需的资源。 在这种情况下,它只是 numJunkPiles 字

段,它表示 room 的混乱程度。更现实的是,它可能是一个包含指向本地对等体的指针的 fifinal long。

State 实现了 Runnable,它的 run 方法最多被调用一次,当我们在 Room 构造函数中使用我们的清理

器注册 State 实例时,我们得到了 Cleanable。对 run 方法的调用将由以下两种方法之一触发:通常是

通过调用 Room 的 close 方法调用 Cleanable 的 clean 方法来触发。如果客户端无法在 Room 实例符

合垃圾收集条件时调用 close 方法,则清理器将(希望)调用 State 的 run 方法。

  State 实例不引用其 Room 实例至关重要。如果是这样,它将创建一个循环,以防止 Room 实例符

合垃圾收集的资格(以及自动清理)。因此,State 必须是静态嵌套类,因为非静态嵌套类包含对其封

闭实例的引用(第 24 项)。使用 lambda 同样不可取,因为它们可以轻松捕获对封闭对象的引用。

  正如我们之前所说,Room 的清洁剂仅用作安全网。如果客户端在 try-with-resource 块中包围所

有 Room 实例,则永远不需要自动清理。这个表现良好的客户端演示了这种行为:

public class Adult {

public static void main(String[] args) {

try (Room myRoom = new Room(7)) {

System.out.println("Goodbye");

}

}

}

  正如你所期望的那样,运行 Adult 程序会打印 Goodbye,然后是 Cleaning Room。但是,这个永

远不会清理 room 的不合理的程序怎么样呢?public class Teenager {

public static void main(String[] args) {

new Room(99);

System.out.println("Peace out");

}

}

  你可能希望它打印出 Peace out,然后是 Cleaning Room,但在我的机器上,它从不打印

Cleaning Room; 它只是退出。这是我们之前谈到的不可预测性。 Cleaner 规范说:“在 System.exit

期间清理方法的行为是特定实现的。不保证是否调用清理操作。”虽然规范没有说明,但正常程序退出

也是如此。在我的机器上,将 System.gc() 添加到 Teenager 类的 main 方法就足以让它在退出之前打

印 Cleaning Room,但不能保证你会在你的机器上看到相同的行为。

  总之,除了作为安全网或终止非关键的本机资源之外,不要使用清理方法,也不要使用 Java 9 之前

的版本(终结方法)。即使这样,也要注意不确定性和影响性能导致的后果(Even then, beware the

indeterminacy and performance consequences)。

第 7 项:消除过期的对象引用

第 9 项:try-with-resources 优先于 try-fifinally

「喜欢这篇文章,您的关注和赞赏是给作者最好的鼓励」
关注作者
【版权声明】本文为墨天轮用户原创内容,转载时必须标注文章的来源(墨天轮),文章链接,文章作者等基本信息,否则作者和墨天轮有权追究责任。如果您发现墨天轮中有涉嫌抄袭或者侵权的内容,欢迎发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论