@Rays
2017-06-18T09:51:33.000000Z
字数 7190
阅读 1656
语言开发
Java
摘要: Object::finalize的弃用是Java生态系统的一个不同寻常的过程。我们深入Hotspot JVM,查看其工作机制。我们还比较了RAII和Java 7,以及try-with-resources(TWR)语法。文章对比了这些用于自动资源管理的完全不同方法,并解释了TWP应被应用开发人员用于替代终结的原因。
作者: Ben Evans
审校: Victor Grazi
正文:
本文要点
- 了解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.<init>
而重写字节码。要准确地遵循标准并在正确的时间注册Finalizer方法,需要识别在不重写的情况下Object.<init>
完成的时间点,并且使用特殊字节码对该时间点进行标记。
实际标记一个对象为需终结的具体实现代码,可在Hotspot的解释器中看到。在文件hotspot/src/cpu/x86/vm/c1_Runtime1_x86.cpp中,包括了用于x86的Hotspot解释器的核心代码。代码必须是特定于处理器的,因为Hotspot大量地使用了底层的汇编语言和机器码。注册代码包含在register_finalizer_id
方法中。
一个对象一旦被注册为需要终结,它并非立刻在垃圾回收周期中被回收(Reclaimed),而是要历经如下的生命周期延续:
总而言之,所有要被终结的对象,必须首先经由一个垃圾回收的标记,被标识为不可访问,然后才能被终结,之后,需重新运行垃圾回收,对数据进行回收。这也意味着,可终结对象至少额外地多存活了一个垃圾回收循环。对于变成年老代(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()机制从根本上不适合于它所声明的用途,即自动资源管理。
为了安全地处理占有资源的对象,在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