Java优化 - 避免 Finalization
Java的finalize()机制试图自动管理资源,像C++里的资源获取即初始化(RAII)。一个析构函数(Java里是finalize())在对象销毁的时候自动清除和释放资源。
因此,基本用例很简单。当一个对象被增加的时候,它需要一些资源,在对象的生存期内占有这些资源。当对象死亡的时候,自动放弃对资源的所有权。
一个标准例子是,程序员打开一个文件以后,不再需要它的时候,太容易忘记调用close()函数了。
让我们看一个简单的C++的例子,显示如何在C风格的文件I/O上包装一个RAII包装器:
class file_error {};
class file {
public:
file(const char* filename) : _h_file(std::fopen(filename, "w+")) {
if (_h_file == NULL) {
throw file_error();
}
}
// Destructor
~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;
};
这是一个好的设计,特别是当一个类型的存在的唯一原因是为了持有一个资源比如文件或者网络套接字的时候。这种情况下,把资源绑定到对象生存期上是有意义的。对象持有资源的自动释放成了平台的责任,而不是程序员的责任。
忘记清理
产品的代码可能已经很好地运行了几年。有一个服务器通过TCP连接到另一个服务器,建立权限信息。权限服务相对稳定,有很好的负载均衡,通常立即相应请求。一个请求打开一个新的TCP连接-远非理想的设计。
某个周末,发生了一次变更,导致相应时间略微变慢。这导致TCP连接偶尔超时-生产系统中从未走过的代码路径。抛出TimeOutException,没有任何日志,也没有finally块-以前的成功路径会调用close()函数。
悲惨的是,问题还没有结束。不调用close()函数意味着TCP连接还在open状态。最终,程序用尽了文件句柄,这样就影响了该box内的其他进程。解决方案是重写代码,首先关闭全部连接,其次是使用连接池而不是每个资源打开一个连接。
为什么不使用Finalization解决本问题
最初,Java的Object就提供了finalize()方法。默认地,没有任何操作(通常都应该如此)。可以覆盖finalize(),提供一些行为。JavaDoc是这样描述的:当GC认为没有什么再引用对象的时候,由GC调用。子类覆盖finalize()方法,处置系统资源或者执行其他清理。
如果一个类型提供了finalize()方法(JVM会通过在构造器的成功返回上注册单独的finalizable对象),那么该类型的所有对象都会受到GC的特殊对待。
HotSpot的一个细节是,VM会在标准的Java指令上附加一些特别的字节码。这些特别的字节码用来重写标准字节码,以应对一些特殊情况。
包括标准Java和HotSpot特定的字节码定义列表,在hotspot/share/interpreter/bytecodes.cpp内。对于Finalization功能,使用的是return_register_finalizer。JVM用它重写Object.()。
可以在HotSpot解释器内看到完成finalization的实际注册的代码。文件src/hotspot/cpu/x86/c1_Runtime.cpp包含了x86代码-这是处理器相关的,因为HotSpot重度使用了低级的机器语言。register_finalizer_id包含了寄存器代码。
一旦对象注册为需要finalization,就不会在GC周期立即回收。对象有了扩展的生存期:
- Finalizable对象被移到一个队列
- 在应用线程重新启动以后,单独的finalization线程从队列中取值,为每个对象运行finalize()方法
- 一旦finalize()方法结束,对象可以在下个周期被回收
总之,这意味着所有的要被finalized的对象必须首先由GC标记为不可达的,然后finalized,然后下次GC真正回收。这样,finalizable对象会多存活一个GC周期。对象就很容易成为Tenured,这样就可能存在想当长时间。finalization队列的处理如下图:
finalize()还有其他问题。比如,方法被finalization线程执行时抛了异常会发生什么?此时没有上下文,异常会被忽略。所以,开发者无法了解、也无法恢复故障。
finalization可以包含阻塞操作,因此需要JVM生成一个新线程来运行finalize()方法。该线程的增加和管理也不在开发者的控制之内,但是要避免锁住整个JVM系统。
finalization的主要实现都发生在Java中。JVM的单独线程执行finalization的时候,同时其他应用线程还在运行。主要代码包含在包私有的java.lang.ref.Finalizer类中,它很好阅读。
该类也包含一些在运行时为一些特定的类提供额外的权限的功能。比如,它包含的代码像这样:
/* Invoked by VM */
static void register(Object finalizee) {
new Finalizer(finalizee);
}
当然,在常规程序代码中,这是很荒谬的,因为它增加了未使用的对象。在这里,它hook住一个新的finalizable对象。
finalization的实现也重度依赖FinalReference类。它是java.lang.ref.Reference的子类,运行时知道这是一个特例。像soft和weak引用,FinalReference对象被GC特别对待,包含一个VM和Java代码之间有趣的交互的机制。
由于两种语言在内存管理方便的不同,这样的实现存在致命缺陷。在C++里,动态内存是手动处理的,程序员显式地控制对象的生存期。所以,对象的删除可能造成破坏,所以资源的请求和释放直接和对象的生存期相关。Java的内存管理系统GC是按需运行的。所以,finalize()只在对象被回收时才运行,无法确定运行时间。
换句话说,finalization不能安全地自动回收资源,这是因为GC没有随时运行。所以,可能导致耗尽资源。
所以,应该避免使用finalization,Java 9中,Object.finalize()被修改成deprecated。
try-with-resources
Java 7之前,程序员负责关闭资源:
public void readFirstLineOld(File file) throws IOException {
BufferedReader reader = null;
try {
reader = new BufferedReader(new FileReader(file));
String firstLine = reader.readLine();
System.out.println(firstLine);
} finally {
if (reader != null) {
reader.close();
}
}
}
开发者必须:
- 增加BufferedReader,初始化成null,确保对finally块可见
- 处理IOException(包含它隐藏了的FileNotFoundException),也可以直接抛出
- 执行和外部资源相关的业务逻辑
- 如果reader非null,就关闭资源
上面的例子只有一个外部资源,但是如果要处理多个外部资源,会非常复杂。比如原始的JDBC调用。
Java 7增加了语言级的结构try-with-resources,允许在try关键字后面的括号内打开指定的资源。任何实现了AutoCloseable接口的对象,都可以在try括号内使用。try块的结束的时候,自动调用close()方法,而不用程序员调用。下面的代码和前面的代码一样,不管业务逻辑是否抛异常都能运行:
public void readFirstLineNew(File file) throws IOException {
try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
String firstLine = reader.readLine();
System.out.println(firstLine);
}
}
使用javap可以比较两个版本的字节码。第一个例子的:
public void readFirstLineOld(java.io.File) throws java.io.IOException;
Code:
0: aconst_null
1: astore_2
2: new #2 // class java/io/BufferedReader
5: dup
6: new #3 // class java/io/FileReader
9: dup
10: aload_1
11: invokespecial #4 // Method java/io/FileReader."<init>":(Ljava/io/File;)V
14: invokespecial #5 // Method java/io/BufferedReader."<init>":(Ljava/io/Reader;)V
17: astore_2
18: aload_2
19: invokevirtual #6 // Method java/io/BufferedReader.readLine:()Ljava/lang/String;
22: astore_3
23: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
26: aload_3
27: invokevirtual #8 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
30: aload_2
31: ifnull 54
34: aload_2
35: invokevirtual #9 // Method java/io/BufferedReader.close:()V
38: goto 54
41: astore 4
43: aload_2
44: ifnull 51
47: aload_2
48: invokevirtual #9 // Method java/io/BufferedReader.close:()V
51: aload 4
53: athrow
54: return
Exception table:
from to target type
2 30 41 any
41 43 41 any
第二个例子:
public void readFirstLineNew(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只是自动生成样板的编译器机制。可是,考虑一致性的时候,它是一个很有用的简化,不用考虑释放和清除工作。能带来更好的封装和无bug的代码。
try-with-resources是最佳实践,类似C++的RAII模式,不过它的作用仅限于try范围内,这是因为Java平台无法确切知道对象的生存期。
现在我们知道,这两种机制,尽管目的相同,实现机制却是不同的。
Finalization依赖汇编代码,在运行时注册特别的GC行为代码。然后使用GC清除引用队列。
try-with-resources是纯编译时特性,可以看作是语法糖,生成常规字节码,没有特殊的运行时行为。使用try-with-resources可能有一点性能影响,因为它导致了大量的自动生成的字节码。
不过,除非try-with-resources造成的性能影响十分严重,否则它还是最佳选择。