终结符和非终结符
Java finalize()方法允许对象执行特殊的清理操作,然后最终由垃圾回收器将其丢弃。 这是相当知名的,这种语言功能有问题,而且它是安全的使用被限制在一个很窄的一组用例,主要例子是“安全网”模式,其中一个终结的情况下使用对象的所有者忘记调用显式终止方法。 不幸的是,鲜为人知的是,即使这样的用例都很脆弱,而且如果没有特殊的预防措施,也会失败。 与人们普遍认为的PhantomReference相反,它通常被认为是终结器的一个很好的替代品,它也遇到了相同的基本问题。
在深入探讨此问题之前,回顾一下Java终结器的一般否定点很有用。
终结者的普遍否定
1.执行被推迟,甚至可能永远不会发生。
垃圾收集器可以选择推迟清理使用过的对象,直到容量变得更加有限或直到某些执行特征(例如可能减少的负载)表明吉祥的收集期已经实现为止。 此行为的缺点是,终结器要到其对象被收集后才能运行。 此外,终结器方法通常在较小的线程池中执行,从而导致额外的延迟。 当终结器的编写不当时,问题变得更加复杂,这可能会导致执行阻塞操作的可能性,这些阻塞操作可能会严重延迟其他终结器的执行,因为它们都倾向于共享同一池。 如果程序过早退出,或者垃圾回收器具有大量资源,则终结器可能永远不会执行。 因此,决不能以要求终结器采取措施的方式设计类。
2.包含终结器的对象的垃圾收集比没有垃圾的收集要昂贵得多。
具有finalize()方法的对象需要更多的工作来跟踪垃圾回收器,并且finalize方法的执行要求要求垃圾回收器保持与之关联的所有内存,直到执行成功完成为止。 这意味着通常需要收集者重新访问对象,可能需要整个单独的过程。 因此,具有大量实例计数和较短寿命的对象上的终结器可能会引入主要的性能问题。
3.在同一对象图中的对象上同时执行终结器可能会产生不良结果。
这可能导致数据结构中节点之间经常相互引用的不直观行为。 这些节点上的终结器可以同时以任意顺序调用,如果它们访问各自对等方的状态,则可能导致损坏状态。 必须注意确保特定的订单或应对由此产生的波动性。
4.终结器中未捕获的异常将被忽略,并且从不报告
终结器需要适当的异常处理并在发生故障时进行日志记录。 否则,关键的调试数据将丢失,并且对象可能会保持意外状态。
5.终结器可以在损坏状态下无意中复活对象
如果“ this”引用从finalize()方法中泄漏出来,则该对象仍然可见,但处于半清除状态,可能导致应用程序其他部分的错误。
总而言之,这些因素中的一个或多个因素的组合排除了确定性语言中对象清理工具所共有的大多数用例,例如C ++中的析构函数,它们与定义明确的范围或显式的自由操作相关。 相反,Java是围绕面向调用者的清理而设计的。
适当的资源清理
Java中正确的资源清理应该面向调用者。 这种方法要求资源提供一种“关闭”方法,并且调用者必须使用Java 7的try-with-resources(或可选地try / finally)。 这样可以立即进行确定性清除。 即使资源提供了自己的finalize()方法,也应该使用此机制来开发曾经使用资源(带有close()或其他终止方法的代码)的所有代码。 这样做可以大大减少潜在的错误并提高性能。
示例1:正确的尝试资源
try (FileInputStream file = new FileInputStream( “file" )) {
// Try with resources will call file.close when this block is done
}
但是,如果调用者忘记正确使用try-with-resources(或try / finally)机制,API设计人员通常希望或被合同要求为重资源添加附加的保护措施。 作为后者的一个示例,JNDI上下文通常与诸如网络连接之类的资源相关联,并且其Javadoc明确声明调用close()是可选的,如果未调用,清理仍将发生。
瑕疵
为了防止此类遗漏,唯一可用的选项是使用“安全网终结器”模式或使用PhantomReference。 不幸的是,如果不采取预防措施,这些选择将会并且确实会失败。
示例2:安全网络终结器有缺陷
public void work() throws Exception {
FileOutputStream stream = this .stream;
stream.write( PART1 );
stream.write( PART2 );
}
protected void finalize() {
try {
stream.close();
} catch (Throwable t) {
}
}
public static void main(String[] args) throws Exception {
Example example = new Example();
example.work();
}
乍看之下,我们有缺陷的安全网示例似乎没有任何问题,在许多情况下,它可以正确执行。 但是,在正确的条件下,它将以意外的异常失败。 ( 精明的读者会注意到FileOutputStream已经具有内置的终结器,因此此示例是多余的;尽管如此,并非所有资源都有一个,所以此示例旨在作为简要说明 )
异常1:表明过早完成的异常
线程“主”中的异常java.io.IOException:流已关闭
at java.io.FileOutputStream.writeBytes(Native Method)
at java.io.FileOutputStream.write(FileOutputStream.java:325)
at Example.work(Example.java:36)
at Example.main(Example.java:47)
此失败清楚地表明了终结器在执行工作方法期间以某种方式运行,但是出现了问题,如何以及为什么会发生这种情况?
详细了解OpenJDK的HotSpot的机制将提供有关如何发生这种情况的见解。
它如何发生-深入JVM内部
在研究HotSpot的行为时,了解一些关键概念很有用。 在HotSpot下,如果可以从堆上的另一个对象,JNI句柄或从线程堆栈上的方法执行的正在使用的本地引用中访问对象,则将它们视为活动对象。
由于HotSpot具有先进的即时编译器,因此确定是否使用本地引用非常复杂。 该编译器根据目标CPU架构以及包括活动负载模式在内的实时环境因素,将Java字节码转换为优化的本机指令。 由于生成的代码可能会有很大的不同,因此需要一种与垃圾收集器协调的有效且可靠的机制。
在HotSpot下,此机制称为安全点。 当线程达到安全点时,垃圾回收器将有机会安全地操纵线程的状态,并确定活动的本地对象,因为应用程序代码的执行被暂时挂起。 只有程序执行中的某些点才可以成为安全点,其中最值得注意的是方法调用。
在本机代码生成期间,JIT编译器会在每个潜在的安全点存储GC映射。 GC映射包含当时由JIT编译器视为活动对象的对象列表。 然后,垃圾收集器可以使用这些映射并准确确定哪些对象是本地可访问的,而无需了解其背后的本机代码。
通过在上面的示例中,在示例work()方法的开头插入任意方法调用,例如yield(),可以将该时间点的GC映射与以后的方法调用中的映射进行比较,从而确定HotSpot何时确定对象引用符合收集条件。 让我们做更多的分析,看看是什么原因导致了上述异常。
示例4:带有伪方法的安全网络终结器有缺陷,可以比较GC映射
public void work() throws Exception {
Thread.yield(); // Dummy method call, potential safe-point
FileOutputStream stream = this .stream;
stream.write( PART1 ); // Existing call already a potential safe-point
stream.write( PART2 );
}
通过首先安装反汇编程序插件,然后使用适当的VM选项,可以在OpenJDK的印刷装配输出中检查GC映射。 仅当选择了要编译的方法时才会输出,因此需要附加参数来强制执行此操作。 最激进的优化由服务器编译器( C2 )执行,这使其成为此分析的理想选择。 请注意,在本地编译方法之前,此模式通常需要一万次调用。 将编译器阈值设置为1可使此情况立即发生。
示例5:用于反汇编work()方法的JVM命令参数
java —server XX:+UnlockDiagnosticVMOptions -XX:CompileCommand=print,*Example.work -XX:CompileThreshold=1 -XX:-TieredCompilation Example
图1:为简化而简化的HotSpot x86汇编语言反编译,并为每个指令块标注了相应的Java代码
HotSpot遵循标准的x86-64调用约定。 调用work方法时,它将在执行该方法中的任何代码之前将“ this”对象引用的副本放入寄存器“ rsi”中。 第一条指令只是将放置在“ rsi”中的“ this”引用复制到工作寄存器“ rbp”中。
mov rbp,rsi
在第二条指令“调用”上,将调用伪方法Thread.yield,如前所述,该方法是潜在的安全点候选对象,因此C2已包括GC映射(在输出中标记为OopMap)。 此时,作为当前“ this”引用的“ rbp”的内容被标记为活动,因此无法收集该对象,因此此时无法完成。
call 0x00007f0375037f60 ; OopMap{rbp=Oop off=36}
;*invokestatic yield-> Thread.yield(); // New potential safe-point
第三条指令“ mov”将“ stream”字段的内容复制到“ rbp”,覆盖先前存储的对“ this”的引用。 在HotSpot中,Java对象存储在内存的连续映射中,因此读取的字段只是添加到包含该字段的对象地址的偏移量。 在这种情况下,“ stream”位于距离“ this”开头16个字节的位置。
mov rbp,QWORD PTR [rbp+0x10] ;*getfield stream-> FileOutputStream stream = this.stream;
下一组指令设置并对现在存储在“ rbp”寄存器中的“ stream”字段的内容执行write()调用。 “ test”和“ je”指令执行空指针检查,并在必要时抛出NullPointerException。 第一条“ mov”指令将常量“ PART1”(字节数组引用)复制到“ rdx”,从而为方法调用设置了参数。 当前包含“ stream”字段的“ rbp”寄存器被复制到“ rsi”,该寄存器遵循随后的write()调用的调用约定。
test rbp,rbp
je 0x7f0375113ce0
mov rdx,0x7f02cdaea718 ; {oop([B)}
mov rsi,rbp
call 0x00007f0375037b60 ; OopMap{rbp=Oop off=64}
;*invokevirtual write-> stream.write(PART1);
最终进行了write()调用,并且由于它是一个潜在的安全点,因此包含了另一个GC映射。 该图表示仅“ rbp”是可访问的。 由于“ rbp”被“ stream”覆盖,因此它不再包含“ this”,因此在此执行点不再认为“ this”是可访问的。 上图描述了整个work()方法代码中“ rbp”的状态。
由于工作方法的“ this”引用是对该对象的唯一剩余引用,因此终结器现在可以与此write()调用并发执行,从而导致前面提到的失败的堆栈跟踪。 同样,与此对象关联的任何幻像引用都可以传递给相应的清理线程,从而导致相同的过早关闭。
总而言之,该分析向我们展示了可以在对对象进行实时方法调用完成之前收集该对象。
为什么会发生
不幸的是,Java语言规范(12.6.1)明确允许这种行为:
“可以设计程序的优化转换,以将可到达的对象数量减少到少于天真的被认为可到达的对象数量。 例如,Java编译器或代码生成器可以选择设置将不再用于为null的变量或参数,以使此类对象的存储可以更快地被回收。”
更加不祥的是:
“这种转换可能导致对finalize方法的调用比预期的要早。”
尽管不直观,但急切清理的一般概念对性能很有帮助。 举个例子,持有一个对象将是一种浪费,该对象将不再被从事某种形式的长时间活动的方法之一所使用。 但是,此行为与终结器和幻影参考功能的交互作用适得其反且容易出错。
缓解策略
幸运的是,可以使用多种技术来防止这种错误行为。 但是,请记住,这些技术是微妙的,因此在使用和维护代码时要格外小心。
全部同步策略
此策略基于JLS(12.6.1)中的特殊规则:
“如果对象的终结器可以导致该对象上的同步,则该对象必须处于活动状态,并且只要对其持有锁定,就认为该对象可以访问。”
换句话说,如果终结器是同步的,则保证在所有其他挂起的同步方法调用完成之后才调用终结器。 这是最简单的方法,因为它所需要的只是向finalizer和所有可能与之冲突的方法(通常是所有方法)添加一个findizer。
示例6:全部同步策略
public synchronized void work() throws Exception {
FileOutputStream stream = this .stream;
stream.write( PART1 );
stream.write( PART2 );
}
protected synchronized void finalize() {
try {
stream.close();
} catch (Throwable t) {
}
}
public static void main(String[] args) throws Exception {
Example example = new Example();
example.work();
}
这种方法最明显的缺点是,它序列化了对对象的所有访问,这排除了必须支持并发方法访问的任何类。 另一个缺点是同步的开销会严重影响性能。 在将实例固定在单个线程上较长时间的情况下,JVM可以在称为“锁偏置”的过程中“检出”该锁,从而消除了大部分成本。 但是,即使发生这种优化,由于Java内存模型的要求,可能仍需要内存围栏来同步CPU内核之间的状态,这通常会引入不必要的延迟。
RWLock同步策略
对于需要并发访问的对象,可以修改“全部同步”策略以支持并行work()方法执行。 这是通过使用ReadWriteLock和单独的清理线程来完成的。 work()方法在简短的同步下获取读锁,以确保终结器不运行,从而确保始终在写锁之前获取读锁。 单独的清理线程是必要的,因为由于先前列出的原因,清理任务一旦创建,就在写锁上阻塞,并且使JVM终结器执行线程停滞,就应该避免。
示例7:与RWLock同步的策略
private void work() {
ReentrantReadWriteLock lock = this .lock;
try {
synchronized ( this ) { // Object lock prevents finalizer stall
lock.readLock().lock();
}
stream.write( PART1 );
stream.write( PART2 );
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.readLock().unlock();
}
}
protected synchronized void finalize() {
// Delegate to another thread so we do not block the JVM finalizer thread
REAPER .execute( new CleanupTask(lock));
}
private static class CleanupTask implements Runnable {
// Constructor and fields omitted for brevity
public void run() {
try {
lock.writeLock().lock();
safeClose(stream);
} finally {
lock.writeLock().unlock();
}
}
}
这种方法的缺点是它很复杂并且在锁中获取了一个锁。 因为它的预期用途是支持并发工作方法执行,所以锁定偏差将无济于事。
易变策略
一种改进的方法是采取一些措施,使这些对象在work()方法期间保持活动状态。 天真的尝试是在工作方法的末尾读取一个字段。
示例8:天真尝试,不能保证能正常工作
private int dummy;
public void work() throws Exception {
FileOutputStream stream = this.stream;
stream.write( PART1 );
stream.write( PART2 );
// Not guaranteed to work
int dummy = this .dummy;
}
由于优化器可以轻松地丢弃未使用的读取,因此无法保证此方法有效。 纠正此错误的“明智”尝试将利用另一条JLS规则,该规则指出所有字段写入必须对终结器可见。
示例9:聪明的尝试,但也不能保证能正常工作
private int counter;
public void work() throws Exception {
FileOutputStream stream = this .stream;
stream.write(PART1);
stream.write(PART2);
// Not guaranteed to work either
this .counter++;
}
不幸的是,这种尝试也可能失败,因为优化器可以随意重排序指令,甚至可以取消写指令,因为它从未使用过。 示例10中的代码是等效的,因为该方法的结果仍然相同。
示例10:优化器重定位指令
public void work() throws Exception {
FileOutputStream stream = this .stream;
// An optimizer can relocate this instruction, triggering failure
this .counter++;
stream.write( PART1 );
stream.write( PART2 );
}
幸运的是,可以利用Java内存模型中的程序顺序规则来防止优化程序对指令重新排序。 JMM要求,在读取易失性字段或建立“先发生”关系的任何其他事件时,对易失性字段进行写入之前的所有内存效果对于所有其他执行线程都是可见的。
通过将计数器更改为易失性,HotSpot不会在写调用上方对指令进行重新排序。 但是,从理论上讲,将来有一些优化程序可以确定不需要进行易失性写操作的存储效应,并且由于从不使用该字段,因此仍然有可能消除它。 可以通过将值发布到公共静态字段来安全地防止这种情况。 对于尚未加载的代码,公共静态字段的内容必须可见。 结合使用这些方法,可以得出一种有效的基于volatile的策略,该策略允许并发访问而无需任何形式的锁定。
示例11:易变策略
public static int STATIC_COUNTER = 0;
private volatile int counter = 0;
public void work() throws Exception {
FileOutputStream stream = this .stream;
stream.write( PART1 )
stream.write( PART2 );
this .counter++; // Volatile write prevents reordering
}
protected void finalize() {
if (safeClose(stream)) {
STATIC_COUNTER = counter; // Public static write prevents
// possible elimination
}
}
尽管无法避免写操作,但每次写操作仍会发生内存隔离,理想情况下应避免这种情况。
易失性+懒惰写策略
对易失性策略进行小的修改可以降低写操作的成本,但仍然可以确保所需的排序效果。 使用AtomicIntegerFieldUpdater允许类执行延迟写入。 延迟写入使用便宜的存储区(称为存储区),这只能确保写入顺序。 x86和SPARC自然是有序的,因此在这些平台上,懒写实际上是免费的。 在某些平台上(例如ARM),这会花费很少的成本,但仍然比普通的易失性写入要少得多。
示例12:易失性+惰性写入策略
public static int STATIC_COUNTER = 0;
private volatile int counter = 0;
private static AtomicIntegerFieldUpdater UPDATER = …
public void work() throws Exception {
FileOutputStream stream = this .stream;
stream.write( PART1 );
stream.write( PART2 );
UPDATER .lazySet( this , counter + 1); // Volatile write prevents reordering
}
protected void finalize() {
if (safeClose(stream)) {
STATIC_COUNTER = counter; // Public static write prevents
// possible elimination
}
}
本机方法免疫
JNI调用使对主机对象的引用保持活动状态,因此不需要特殊的策略。 但是,将本机方法与Java方法混合是很常见的,因此使用本机代码的类可能仍需要对所有Java方法采取适当的预防措施。
需要改进
这些策略虽然有效,但它们都笨拙,易碎,并且比干净的语言构造要昂贵得多。 .NET平台中已经存在这样的构造。 C#应用程序可以使用简单的GC.KeepAlive()调用,该调用告诉JIT编译器将传递的对象保留在该时间点之前。
如果JDK要实现类似的构造,那么work()方法将如下所示:
示例13:使用潜在的JDK改进的增强示例
public void work() throws Exception {
FileOutputStream stream = this.stream;
stream.write( PART1 );
stream.write( PART2 );
System.keepAlive( this );
}
这种方法没有不必要的开销。 该代码非常干净,将来的维护者也清楚其目的。 如有疑问,只需要检查一下keepAlive()方法的Javadoc。
结论
Java的终结器和幻像引用功能容易出错,通常应避免使用。 但是,这些功能有合理的用例,使用时,应采用本文中的一种策略来防止过早收集问题。
资源的所有使用,无论是否可终结,都应始终使用try-with-resources或try / finally。 如果所有调用者都采取了这一重要步骤,则即使终结器损坏的对象也将正常运行。
希望将来的JVM版本将实现keepAlive()构造,这将大大改善开发人员的体验,并在必要时减少此类错误的可能性。
范例程式码
GitHub上提供了每种策略的代码以及有缺陷的示例。
终结符和非终结符