Java 8 中对强可达对象调用 finalize() 方法

30

我们最近将消息处理应用程序从Java 7升级到Java 8。自从升级以来,我们偶尔会遇到一个异常,即在读取流时该流已经关闭。日志显示,终结器线程正在调用包含流的对象的finalize()方法(这反过来又关闭了该流)。

代码的基本概述如下:

MIMEWriter writer = new MIMEWriter( out );
in = new InflaterInputStream( databaseBlobInputStream );
MIMEBodyPart attachmentPart = new MIMEBodyPart( in );
writer.writePart( attachmentPart );

MIMEWriterMIMEBodyPart是一个自制的MIME/HTTP库的组成部分。MIMEBodyPart扩展了HTTPMessageHTTPMessage具有以下特点:

public void close() throws IOException
{
    if ( m_stream != null )
    {
        m_stream.close();
    }
}

protected void finalize()
{
    try
    {
        close();
    }
    catch ( final Exception ignored ) { }
}
异常出现在MIMEWriter.writePart的调用链中,如下所示:
  1. MIMEWriter.writePart()写入部分的头信息,然后调用part.writeBodyPartContent(this)
  2. MIMEBodyPart.writeBodyPartContent()调用我们的实用程序方法IOUtil.copy(getContentStream(),out)将内容流式传输到输出流中
  3. MIMEBodyPart.getContentStream()只返回传递到构造函数中的输入流(请参见上面的代码块)
  4. IOUtil.copy有一个循环,从输入流读取8K块并将其写入输出流,直到输入流为空。
IOUtil.copy正在运行时,调用MIMEBodyPart.finalize()会收到以下异常:
java.io.IOException: Stream closed
    at java.util.zip.InflaterInputStream.ensureOpen(InflaterInputStream.java:67)
    at java.util.zip.InflaterInputStream.read(InflaterInputStream.java:142)
    at java.io.FilterInputStream.read(FilterInputStream.java:107)
    at com.blah.util.IOUtil.copy(IOUtil.java:153)
    at com.blah.core.net.MIMEBodyPart.writeBodyPartContent(MIMEBodyPart.java:75)
    at com.blah.core.net.MIMEWriter.writePart(MIMEWriter.java:65)

我们在 HTTPMessage.close() 方法中添加了一些日志,记录了调用者的堆栈跟踪,并证明正在运行 IOUtil.copy() 时肯定是终结器线程调用 HTTPMessage.finalize()

MIMEBodyPart 对象在当前线程的堆栈中作为 this 存在于 MIMEBodyPart.writeBodyPartContent 的堆栈帧中。我不明白为什么 JVM 会调用 finalize()

我尝试将相关代码提取出来,在自己的机器上进行紧密循环运行,但无法复现该问题。我们可以在其中一个开发服务器上高负载下可靠地复现该问题,但是任何尝试创建较小的可重现测试用例的尝试均失败。代码在 Java 7 下编译,但在 Java 8 下执行。如果我们切换回 Java 7 而没有重新编译,则不会发生此问题。

作为一种解决方法,我已经使用 Java Mail MIME 库重写了受影响的代码,并且问题已经消失了(可能是因为 Java Mail 没有使用 finalize())。但是,我担心应用程序中的其他 finalize() 方法可能被错误地调用,或者 Java 正在尝试回收仍在使用的对象。

我知道当前最佳实践不建议使用 finalize(),我可能会重新审视这个自制库,以删除 finalize() 方法。话虽如此,有人遇到过这个问题吗?有没有人对原因有任何想法?


2
您IP地址为143.198.54.68,由于运营成本限制,当前对于免费用户的使用频率限制为每个IP每72小时10次对话,如需解除限制,请点击左下角设置图标按钮(手机用户先点击左上角菜单按钮)。 - Bhaskar
1
这听起来非常像是JIT错误。我建议打开JIT调试并查看是否有任何模式。 - chrylis -cautiouslyoptimistic-
@chrylis,您建议打开哪些标志?只需要使用-XX:+PrintCompilation来尝试查看问题的发生是否与某个方法的JIT编译对齐吗? - Nathan
@WW。这是一个好想法,但是只有一个MIMEBodyPart可以使用给定的流in - Nathan
更新:finalize方法已于Java 9中被弃用。请参阅问题为什么Java 9中废弃了finalize()方法? - Basil Bourque
显示剩余2条评论
3个回答

45

以下是一些推测。即使栈上的局部变量中还有对该对象的引用,即使栈上有一个调用该对象实例方法的活跃调用,该对象仍然可以被终结和垃圾回收!前提是该对象是不可访问的。即使它在栈上,如果后续代码没有使用该引用,那么它就可能是不可访问的。

请参见这篇答案,了解一个对象如何在局部变量仍在作用域内时被垃圾回收的示例。

下面是一个示例,说明对象在实例方法调用处于活动状态时如何被终结:

class FinalizeThis {
    protected void finalize() {
        System.out.println("finalized!");
    }

    void loop() {
        System.out.println("loop() called");
        for (int i = 0; i < 1_000_000_000; i++) {
            if (i % 1_000_000 == 0)
                System.gc();
        }
        System.out.println("loop() returns");
    }

    public static void main(String[] args) {
        new FinalizeThis().loop();
    }
}

loop()方法正在运行时,无法通过对FinalizeThis对象的引用执行任何代码,因此它是不可访问的。 因此,它可以被终结和垃圾回收。 在JDK 8 GA上,这将打印以下内容:

loop() called
finalized!
loop() returns

每次都会发生。

MimeBodyPart可能出现了类似的情况。它被存储在本地变量中吗?(看起来是这样,因为代码似乎遵循一个惯例,即字段以m_前缀命名。)

更新

在评论中,提问者建议进行以下更改:

    public static void main(String[] args) {
        FinalizeThis finalizeThis = new FinalizeThis();
        finalizeThis.loop();
    }

他做了这个更改后,并没有观察到最终结果,我也是一样。不过,如果进行以下进一步的更改:

    public static void main(String[] args) {
        FinalizeThis finalizeThis = new FinalizeThis();
        for (int i = 0; i < 1_000_000; i++)
            Thread.yield();
        finalizeThis.loop();
    }

我怀疑没有循环时,会再次发生finalization。 原因可能是在没有循环的情况下,main()方法被解释而不是编译。 解释器可能对可达性分析不太积极。 如果有yield循环,则main()方法将被编译,并且JIT编译器检测到finalizeThis在执行loop()方法时变得不可访问。

另一种触发此行为的方法是使用JVM的-Xcomp选项,它强制在执行之前对方法进行JIT编译。 我不会以这种方式运行整个应用程序-- JIT编译所有内容可能很慢并占用大量空间--但在小型测试程序中,而不是用循环调试,这样做非常有用。


7
谢谢 @Stuart Marks - 你让我重新对 Stack Overflow 有信心了。关于你的程序,有趣的是它在 Java 7 和 Java 8 上给了我相同的结果。我的问题只出现在我们切换到 Java 8 时。不过你的分析很有道理,进一步鼓舞了我从我们的代码库中删除 finalize() 方法。 - Nathan
2
@Nathan 我已根据你的评论更新了我的答案。不确定为什么你只在Java 8上看到这个问题。7和8之间可能有很多JIT启发式算法的变化,这可能是导致此类行为差异的原因。另外,如果你删除了finalizer,请确保最终某些东西会关闭流。也许使用try-with-resources。 - Stuart Marks
7
另外一点需要提醒:如果JVM确定“没有后续代码会触及该引用”,并且将其finalize,那就明显表明调用者忘记调用close()方法了,对吧?毕竟,调用close()方法意味着要触及该引用,所以finalize方法实现了其目的:关闭被遗忘的资源(只是有点早了)... - Holger
4
@Holger,是的,让终结器执行可等待的副作用是一种历史悠久的技术。 关闭流时,OP的应用程序显然有一些调用代码创建流,然后将其传递到MIMEBodyPart构造函数并存储在字段中。 这是MIMEBodyPart实例变得不可达并被终结时,它会关闭流。 调用代码仍然引用该流,并在尝试使用它时发现它已关闭。 结论是MIMEBodyPart终结器不应该关闭流,因为它没有打开它。 - Stuart Marks
3
MIMEBodyPart类中有finalize()close()方法,如问题所示。finalize()方法并不直接调用流的close()方法,而是通过其自己的实例方法close()来调用close()方法。因此,似乎close()应该在MIMEBodyPart实例上被调用,并且如果尚未调用close(),则finalize()将帮助执行此操作。这似乎就是这里的情况,即在调用之前,MIMEBodyPart实例的close()方法尚未被调用,否则它无法在调用之前成为垃圾收集对象。 - Holger
显示剩余5条评论

1
你的终结器不正确。
首先,它不需要catch块,并且必须在自己的finally{}块中调用super.finalize()。终结器的规范形式如下:
protected void finalize() throws Throwable
{
    try
    {
        // do stuff
    }
    finally
    {
        super.finalize();
    }
}

其次,您假设您持有对“m_stream”的唯一引用,这可能是正确的,也可能不正确。 “m_stream”成员应该自行完成最终化。但您无需执行任何操作即可完成此操作。最终,“m_stream”将是一个“FileInputStream”或“FileOutputStream”或套接字流,它们已经正确地完成了最终化操作。
我只会将其删除。

我原则上同意调用super.finalize(),但在这种情况下,HTTPMessage扩展了具有空finalize()Object。 我同意目前的代码不是最佳选择,但我不确定这是否相关。 问题是在Java 8下似乎错误地调用了finalize(),而在Java 7下没有。 - Nathan
我仍然会将其移除并观察发生了什么。你不需要它,而且正如我所说的那样,它可能是错误的来源。 - user207421
我同意在这种情况下不需要使用finalize()(因为我们在代码的后面直接调用了in.close())。然而,我们的HTTP/MIME库在其他场景中可能需要使用finalize()。正如我所提到的,我将来会重新审查该库及其所有用途,以删除finalize()。我已经重写了相关代码以避免问题。我们的代码中还有其他区域也有finalize()方法。我的担忧是我们可能会在代码的其他部分遇到同样的问题。 - Nathan
调用 super.finalize() 应该放在前面,还是必须在这个 finalizer 需要执行的操作之后才能调用? - David Conrad
1
@DavidConrad 你可能会很幸运,但为什么要冒险?资源应该按照分配的相反顺序释放。 - user207421
我曾想避免使用try/finally,但你说得对,当然是这个顺序。我现在明白了。谢谢。 - David Conrad

1

finalize 有99个问题,其中早期终止是一个新问题。

Java 9引入了Reference.reachabilityFence来解决这个问题。文档还提到,在Java 8中可以使用 synchronized (obj) { ... } 作为替代方案。

但真正的解决方案是不使用 finalize


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接