转载

第8项:避免使用终结方法和清空方法

声明:本文章仅为个人学习交流使用,切勿作为商业用途,若将此文章作为商业用途,后果自负,与本人无关。

终结方法是不可预测的,通常很危险,一般情况下是不必要的(Finalizers are unpredictable, often dangerous, and generally unnecessary.)。使用 终结方法会导致行为不稳定,降低性能,以及可移植性问题。当然,终结方法也有可用之处,我们将在本项的最后再做介绍;但是,作为一项规则,我们应该避免使用它们。在 Java 9 中,终结方法已经过时了,但是在 Java 库中还在使用。Java 9 中替代终结方法的方式是清理方法。清理方法比终结方法危险性更低,但仍然是不可预测的,性能低,而且是不必要的(Cleaners are less dangerous than finalizers, but still unpredictable,

slow, and generally unnecessary)。

提醒 C ++程序员不要将终结方法或清理方法视为 Java 的 C ++析构函数的类比。 在 C ++中,析构函数是回收与对象关联的资源的常用方法,对象是构造函数的必要对应物。 在 Java 中,当一个对象无法访问时,垃圾回收器会回收与对象相关联的内存,而不需要程序员的特别处理(requiring no special effort on the part of the programmer)。C++的析构函数也可以被用来回收其他的非内存资源。在 Java 中,使用 try-with-resources 或者 try-finally 块来完成这个目的。

终结方法或者清理方法的缺点在于不能保证会被及时地执行[JLS, 12.6]。从一个对象变得不可达开始,到它的终结方法或清理方法被执行,所花费的这段时间是任意长的(也就是说我们无法预知一个对象在销毁之后和执行终结方法和清理方法之间的间隔时间)。这意味着,对时间有严格要求(time-critical)的任务不应该由终结方法或清理方法来完成。例如,用中介方法来关闭已经打开的文件,这是严重的错误,因为打开文件的描述符是一种有限的资源。如果由于系统在运行终结方法或清理方法时延迟而导致许多文件处于打开状态,则程序可能会因为无法再打开文件而运行失败。

执行终结算法和清除方法的及时性主要取决于垃圾回收算法,垃圾回收算法在不同的 JVM 实现中大相径庭。如果程序依赖于终结方法或清理方法被执行的时间点,这个程序可能在你测试它的 JVM 上完美运行,然而在你最重要客户的 JVM 平台上却运行失败,这完全是有可能的。

延迟终结过程并不只是一个理论问题。为类提供终结方法可以延迟其实例的回收过程。一位同事在调试一个长期运行的 GUI 应用程序的时候,该应用程序莫名其妙地出现 OutOfMemoryError 错误而死亡。分析表明,该应用程序死亡的时候,其终结方法队列中有数千个图形对象正在等待被回收和终结。遗憾的是,终结方法所在的线程优先级比应用程序其他线程的要低得多,所以对象没有在符合回收条件的时候及时被回收( so objects were not getting finalized at the rate they became eligible for finalization)。语言规范并不保证哪个线程将会执行终结方法,所以,除了避免使用中介方法之外,并没有很轻便的办法能够避免这样的问题。在这方面,清理方法比终结方法要好一些,因为类的创建者可以控制他们自己的清理线程,但是清理方法仍然是在后台运行,还是在垃圾收集器的控制下,因此无法保证及时清理。

语言规范不仅不保证终结方法会被及时地执行,而且根本就不保证它们会被执行。当一个程序终止的时候,某些已经无法访问的对象上的终结方法却根本没有被执行,这完全是有可能的。因此,你不应该依赖终结方法或者清理方法来更新重要的持久状态。例如,依赖终结方法或者清理方法来释放共享资源(比如数据库)上的永久锁,很容易让整个分布式系统垮掉。

不要被 System.gcSystem.runFinalization 这两个方法所诱惑,他们确实增加了终结方法和清理方法被执行的机会,但是他们不保证终结方法或清理方法一定会被执行。唯一声称保证这两个方法一定会被执行的方法是 System.runFinalizersOnExit ,以及它臭名昭著的孪生兄弟 Runtime.runFinalizersOnExit 。这两个方法都有致命的缺陷,已经被废弃了[ThreadStop]。

终结方法的另一个问题是忽略了在终止过程中被抛出的未捕获的异常,那么该对象的终结过程也会终止(Another problem with finalizers is that an uncaught exception thrown during finalization is ignored, and finalization 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 five (not fifty) for the insurance of a safety net if you don’t use it)。

终结方法有一个很严重的安全问题:它们会打开你的类直到终结方法对其进行攻击(they open your class up to finalizer attacks)。使用终结方法进行攻击的原理很简单(The idea behind a finalizer attack is simple):如果从构造方法或将其序列化的等价方法(readObject 和 readResolve[第 12 章])中抛出异常,恶意子类的终结方法可以在部分构造的对象上运行,这些对象应该“死在藤上(died on the vine)”。这些终结方法可以在一个静态域上记录下这些对象的引用,保护它们不被垃圾回收器回收。一旦这些异常的对象呗记录下来,在这个对象上调用任意方法是一件简单的事情,这些方法本来就不应该被允许存在。从构造函数中抛出异常应足以防止对象的创建,在终结方法中,事实并非如此(Throwing an exception from a constructor should be sufficient to prevent an object from coming into existence; in the presence of finalizers, it is not)。这种攻击会产生可怕的后果。final 修饰的类不会受到终结方法的攻击,因为没人可以编写 final 类的恶意子类。要保护非 final 类受到终结方法的攻击,请编写一个不执行任何操作的 final finalize 方法。

某些类(比如文件或线程)封装了需要终止的资源,对于这些类的对象,你应该用什么方法来替代终结方法和清理方法呢?(So what should you do instead of writing a finalizer or cleaner for a class whose objects encapsulate resources that require termination, such as files 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 net
public 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 的混乱程度。 更现实的是,它可能是一个包含指向本地对等体的指针的 final 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)。

第8项:避免使用终结方法和清空方法

关注公众号,回复 E3 获取全文翻译!

原文  https://juejin.im/post/5df78a6551882512691acd93
正文到此结束
Loading...