本文要点
- 了解 C++ RAII 模式和 Java 收尾机制(Finalization)间的差异。
- 深入 Hotspot 的源代码,厘清 Finalizer 的注册机制。
- 对比 finalize() 方法与 Java 7 的 try-with-resources(TWR)语句。
- 查看 TWR 在字节码中的实现方式。
- 理解 TWP 优于 finalize() 的原因。
本文内容经授权摘录自《Java 优化》(“Optimizing Java”)一书。该书即将由 O’Reilly 出版社出版,作者是 Ben Evans 和 James Gough,可从 O’Reilly 和 Amazon 获得预览版。
InfoQ 最近报道了有建议要弃用 Object 的 finalize() 方法。finalize() 方法自 Java 1.0 开始就存在于 Java 中,虽然该方法一直被认为是一个糟糕的设计,也是 Java 平台遗留的一个大“毒瘤”。但是要在 Java 的 Object 类上弃用该方法,这无疑是一个非同寻常的操作。
背景知识
finalize() 机制意在力图提供一种自动资源管理,类似于 C++ 及类似语言的 RAII (资源获取即初始化,Resource Acquisition Is Initialisation)模式。在 RAIL 模式中,提供了析构函数(在 Java 中就是 finalize())实现自动清理资源,并在对象销毁时释放资源。
该模式的基本用例非常简单。当一个对象被创建时,就接管了对一些资源的所有权。对象资源的所有权会持续存在于对象的整个生命周期。之后,当对象消亡时,资源的所有权会自动放弃。
下面让我们看一个简单的 C++ 例子。该例子显示了如何使用 RAII 模式包裹一个 C 风格的文件 I/O 操作。该技术的核心在于使用对象析构方法(在开始处添加“~”标识析构方法名,其后与类名相同)进行清理:
class file_error {}; class file { public: file(const char* filename) : _h_file(std::fopen(filename, "w+")) { if (_h_file == NULL) { throw file_error(); } } // 析构函数。 ~file() { std::fclose(_h_file); } void write(const char* str) { if (std::fputs(str, _h_file) == EOF) { throw file_error(); } } void write(const char* buffer, std::size_t numc) { if (numc != 0 && std::fwrite(buffer, numc, 1, _h_file) == 0) { throw file_error() ; } } private: std::FILE* _h_file; };
该方法的标准合理性基于这一观察:编程人员在打开一个文件句柄后,很容易在不再需要时忘记调用 close() 函数,因此有必要将资源的所有权绑定到对象的生命周期。这样,对象资源的自动清除就变成了平台的职责,而非编程人员的职责。
这一理念给出了一种很好的设计,尤其是当一个类型存在的唯一理由是充当文件或网络 Socket 等资源的“持有者”时。
在 Java 中,上述设计的实现依赖于 JVM 的垃圾回收器,因为子系统可明确地指出对象不再存活。如果在类型上给出了一个 finalize() 方法,那么该类型的所有对象会受到特殊的对待。垃圾回收器将会对覆写了 finalize() 方法的对象做特殊处理。
注释:JVM 对可终结对象的注册机制是:一旦
Object.
(即对特定类型的最终超类构造函数)成功返回,就在这些对象上运行一个特定的 Handler。
我们需要知道 Hotspot 的一个实现细节,那就是除了标准的 Java 指令之外,虚拟机还具有一些实现特定指令的特殊字节码。这些特殊字节码用于重写标准虚拟机,以处理某些特定的场景。
此处提供了字节码定义的完整列表,其中包括了标准Java 以及特定于Hotspot 的实现。
对我们而言,我们关心的是一个特定用例,即return_register_finalizer 指令。具有该指令是十分有必要的,因为JVMTI 可能会为Object.
实际标记一个对象为需终结的具体实现代码,可在Hotspot 的解释器中看到。在文件 hotspot/src/cpu/x86/vm/c1_Runtime1_x86.cpp 中,包括了用于 x86 的 Hotspot 解释器的核心代码。代码必须是特定于处理器的,因为 Hotspot 大量地使用了底层的汇编语言和机器码。注册代码包含在 register_finalizer_id 方法中。
一个对象一旦被注册为需要终结,它并非立刻在垃圾回收周期中被回收(Reclaimed),而是要历经如下的生命周期延续:
- 先前已注册的可终结对象会被识别,并置于一个特殊的终结队列中。
- 随垃圾回收进程重启应用线程后,将会有独立的终结线程从上述队列中获取对象,并清空队列。
- 每个对象将从队列中移出,并启动另一个终结线程,由该终结线程对该实例运行 finalize() 方法。
- 一旦 finalize() 方法终止,对象就已准备好,在下一回收周期中被实际回收。
总而言之,所有要被终结的对象,必须首先经由一个垃圾回收的标记,被标识为不可访问,然后才能被终结,之后,需重新运行垃圾回收,对数据进行回收。这也意味着,可终结对象至少额外地多存活了一个垃圾回收循环。对于变成年老代(Tenured)的对象,这可能需要大量的时间。
该机制还存在一些超乎我们可接受程度的复杂性,即全部清空(drain)队列线程必须启动另一个实际运行 finalize() 方法的终结线程。必须采用这种做法,以防止出现可能的 finalize() 被阻塞情况。
如果 finalize() 运行于全部清空队列线程上,那么如果 finalize() 方法的编写存在问题,那么就会阻止整个机制正常工作。为避免发生这样的问题,我们将不得不为每个需终结的对象实例创建一个全新的线程。
此外,终结线程还必须忽略任何已抛出的异常。乍一看这很奇怪,但是终结线程并不具备处理异常的有效方法,并且创建可终结对象的原始上下文早已不存在了。对于任一给出的用户代码,没有任何可行的方法能感知到异常,或是从异常中恢复。
为澄清这一点,我们回顾一下,Java 异常提供了一种展开(unwind)堆栈的方式,用于在从非致命错误中恢复的当前执行线程中发现方法。考虑到这一点,我们就能理解终结需要忽略异常这一限制,即 finalize() 调用并非发生在创建或执行对象的线程上,而是发生在另一个完全不同的线程上。
终结的主要实现实际上用是 Java 编写的。JVM 具有单独的线程执行终结。对于大部分所需的工作,这类线程是与应用线程同时运行的。线程的核心功能包含在 java.lang.ref.Finalizer 类中,该类是包私有(package-private)类,读取相当容易。
Finalizer 类还提供了一些洞察,有助于理解一些额外的权限是如何通过被赋予该权限的运行时而赋予一个类。例如,该类包含了如下代码:
/* 由 VM 调用 */ static void register(Object finalizee) { new Finalizer(finalizee); }
当然,上面的代码在正常的应用代码中是毫无意义的,因为它创建了一个未使用的对象。除非构造函数具有副作用(通常在 Java 中,副作用被认为是不好的设计),否则它不会做任何事情。在这种情况下,一种做法是“勾”(hook)一个新的可终结对象。
终结的实现还严重地依赖于 FinalReference 类。该类是被 Java 运行时和虚拟机特殊处理的 java.lang.ref.Reference 类的一个子类。类似于更广为人知的软引用(Soft Reference)和弱引用(Weak Reference),FinalReference 对象会被垃圾回收子系统特殊处理,由此所形成的机制,提供了一种在虚拟机和 Java 代码间有趣的交互(包括平台和用户)。
缺点
如果从技术角度全面地看,Java 终结机制的实现存在着严重的缺陷,这是由于该机制与平台内存管理模式间的不匹配所导致的。
对于 C++ 而言,内存是手工处理的,对象处于编程人员的显式控制下,具有显式的生命周期管理。这意味着,终结可在删除对象时发生,资源的获取和释放直接地依赖于对象的生命周期。
Java 的内存管理子系统是一种垃圾回收器,只有当无法再分配可用内存时才需要运行。因而,内存管理的运行时间间隔不确定(如果有可能的话)。由此,finalize() 方法只有在对象被回收时才运行,时间也是不确定的。
如果将 finalize() 机制用于资源(例如文件句柄)的自动释放,那么对于何时(如果有的话)这些资源将实际可用,该机制缺乏保障。这使得 finalize() 机制从根本上不适合于它所声明的用途,即自动资源管理。
Try-with-resources 语句(TWR)
为了安全地处理占有资源的对象,在 Java 7 中引入了 try-with-resources 语句。该语句提供了一种新的语法特性,专门设计用于资源的自动处理。这一语言层结构允许被管理的资源指定在关键字 try 后的圆括号对中。
这必须是一个对象构造语句,在正常的 Java 代码中是不允许的。Java 编译器还会检查被创建的对象类型是否实现了 AutoCloseable 接口。该接口是 Java 7 中引入的 Closeable 接口的超接口,专用于此用途。
这样,资源就存在于 try 语句块的范围中,并且在 try 语句块范围的最后。TWR 实现了对 close() 方法的自动调用,而不是让开发人员记住去调用该函数。从行为上看,对 close() 方法的调用类似于在 finally 语句块中的处理。因此,即使在业务逻辑中抛出了异常,close() 也会运行。
注释: 事实上,相比于人工编写的代码,清理的自动部分所生成的代码更好。这是因为 javac 知道如何按顺序关闭相互依赖的资源,例如 JDBC 连接及其相关类型。这意味着,使用 try-with-resources 语句是该机制的最佳使用方法,而不是采用手工关闭这样的原有方式。
最关键的问题在于,现在局部变量的生存期限于一个单一的范围中,因此自动清理变成依赖于一个范围,而不再依赖于对象的生存期。例如:
public void readFirstLine(File file) throws IOException { try (BufferedReader reader = new BufferedReader(new FileReader(file))) { String firstLine = reader.readLine(); System.out.println(firstLine); } }
即使一个简单的 try-with-resources 语句,也会被编译为一系列规模相当大的字节码。我们可以使用 javap 的 -p 选项进行查看生成的字节码,并导出为如下的反编译形式:
public void readFirstLine(java.io.File) throws java.io.IOException; Code: 0: new #2 // class java/io/BufferedReader 3: dup 4: new #3 // class java/io/FileReader 7: dup 8: aload_1 9: invokespecial #4 // Method java/io/FileReader."<init>":(Ljava/io/File;)V 12: invokespecial #5 // Method java/io/BufferedReader."<init>":(Ljava/io/Reader;)V 15: astore_2 16: aconst_null 17: astore_3 18: aload_2 19: invokevirtual #6 // Method java/io/BufferedReader.readLine:()Ljava/lang/String; 22: astore 4 24: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream; 27: aload 4 29: invokevirtual #8 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 32: aload_2 33: ifnull 108 36: aload_3 37: ifnull 58 40: aload_2 41: invokevirtual #9 // Method java/io/BufferedReader.close:()V 44: goto 108 47: astore 4 49: aload_3 50: aload 4 52: invokevirtual #11 // Method java/lang/Throwable.addSuppressed:(Ljava/lang/Throwable;)V 55: goto 108 58: aload_2 59: invokevirtual #9 // Method java/io/BufferedReader.close:()V 62: goto 108 65: astore 4 67: aload 4 69: astore_3 70: aload 4 72: athrow 73: astore 5 75: aload_2 76: ifnull 105 79: aload_3 80: ifnull 101 83: aload_2 84: invokevirtual #9 // Method java/io/BufferedReader.close:()V 87: goto 105 90: astore 6 92: aload_3 93: aload 6 95: invokevirtual #11 // Method java/lang/Throwable.addSuppressed:(Ljava/lang/Throwable;)V 98: goto 105 101: aload_2 102: invokevirtual #9 // Method java/io/BufferedReader.close:()V 105: aload 5 107: athrow 108: return Exception table: from to target type 40 44 47 Class java/lang/Throwable 18 32 65 Class java/lang/Throwable 18 32 73 any 83 87 90 Class java/lang/Throwable 65 75 73 any
尽管终结和 try-with-resources 语句在设计意图上是一致的,但两者是完全不同的。终结严重依赖于解释器中的汇编代码去注册要终结的对象,并使用垃圾回收器,通过队列以及独立的专用终结线程进行清理。尤其是,在终结中几乎不追踪(Trace)字节码的运行机制,追踪能力是由特定的虚拟机内部机制提供的。
与之相对比,try-with-resources 语句完全是一种编译时机制,可以看成是一种语法糖(Syntactic sugar)。它仅生成常规的字节码,不具有任何其他特殊的运行时行为。try-with-resources 语句自动生成大量的字节码,这是它唯一可见的效果。这一行为可能会影响到 JIT 编译器对使用该语句的方法有效地进行内联或编译。但是,这并不构成应避免使用该语句的原因。
总结一下,终结几乎在所有情况下都不适用于做资源管理。终结依赖于垃圾回收,而垃圾回收本身就是一种非确定性过程。因此,任何依赖于终结的机制都无法确定资源的释放时间,缺乏时间上的保证。
无论终结是否会在 JDK 中被弃用并最终被移除,我们给出的建议依然不变,即永远不要编写对 finalize() 方法重写的类,并对自身代码中存在的相似类进行重构。
要实现 C++ 的 RAII 模式及类似的模式,我们推荐的最佳实践是 try-with-resources 语句。它的确限制了将模式用于语句块范围的代码,但这是由于 Java 平台缺少进入对象生存期的底层可见性。Java 开发人员需在处理资源对象时,练习使用这些规则,并从尽可能高的高度审视它们,因为这些规则本身就是好的设计实践。
作者简介
Ben Evans是初创公司 jClarity 的联合创始人,该公司致力于开发可以为开发和运维团队提供帮助的性能工具和服务。他是 LJC(伦敦的 Java 用户组)的组织者之一,也是 JCP 执行委员会的成员之一,帮助定义 Java 生态系统中的一些标准。他还是“Java Champion”荣誉得主。他曾与人合著过《The Well-Grounded Java Developer》(中文版是《Java 程序员修炼之道》)和《Java in a Nutshell》(第 6 版)。他曾就 Java 平台、性能、并发和相关主题发表过多次演讲。Ben 提供演讲、教学、撰写和咨询服务,细节可联系商谈。
查看英文原文: Under The Hood with the JVM’s Automatic Resource Management
评论