Java的finalizer在本地对等体对象生命周期管理中也应该避免使用吗?

41
作为一名C++/Java/Android开发者,我有这样的经验,终结器几乎总是一个坏主意,唯一的例外是管理“本地对等”对象,这些对象需要通过JNI调用C/C++代码。
我知道JNI:正确管理java对象生命周期问题,但是这个问题解决的是不使用终结器的原因,即使是对于本地对等体。所以这是一个关于上述问题答案的质疑和讨论。
在他的Effective Java中,Joshua Bloch明确将这种情况列为他著名建议的例外,即不使用终结器。
一个合法的使用finalizers的例子与具有本地对等体的对象相关。本地对等体是一个通过本地方法委托给普通对象的本地对象。因为本地对等体不是普通对象,垃圾回收器不知道它,并且不能在其Java对等体被回收时回收它。假设本地对等体不持有关键资源,那么finalizer是执行此任务的适当工具。如果本地对等体持有必须立即终止的资源,则该类应具有显式终止方法,如上所述。终止方法应执行释放关键资源所需的任何操作。终止方法可以是本地方法,也可以调用本地方法。(另请参见stackexchange上的"为什么Java中包含了finalized方法?"问题。)

我看了 Google I/O '17 上 Hans Boehm 的 如何在 Android 中管理本地内存的演讲,发现他实际上反对使用 finalizer 来处理 Java 对象的本地对等体,并引用了 Effective Java 作为参考。他简要提到了为什么显式删除本地对等体或基于作用域的自动关闭可能不是可行的替代方案,然后建议使用 java.lang.ref.PhantomReference

他提出了一些有趣的观点,但我并不完全信服。我将尝试梳理其中的一些观点并阐述我的疑问,希望有人能进一步解释。

从这个例子开始:

class BinaryPoly {

    long mNativeHandle; // holds a c++ raw pointer

    private BinaryPoly(long nativeHandle) {
        mNativeHandle = nativeHandle;
    }

    private static native long nativeMultiply(long xCppPtr, long yCppPtr);

    BinaryPoly multiply(BinaryPoly other) {
        return new BinaryPoly ( nativeMultiply(mNativeHandle, other.mNativeHandler) );
    }
    
    // …

    static native void nativeDelete (long cppPtr);

    protected void finalize() {
        nativeDelete(mNativeHandle);
    }
}

当一个Java类持有对本地对等体的引用,并在finalizer方法中删除该引用时,Bloch列出了这种方法的缺点。

Finalizers可以以任意顺序运行

如果两个对象变得不可达,那么finalizers实际上会以任意顺序运行,包括指向彼此的两个对象在同时变得不可达的情况下,它们可能以错误的顺序被终结,这意味着最后被终结的对象实际上尝试访问已经被终结的对象。[...] 由此可能导致悬空指针并看到已释放的C ++对象[...]

作为示例:

class SomeClass {
    BinaryPoly mMyBinaryPoly:
    …
    // DEFINITELY DON’T DO THIS WITH CURRENT BinaryPoly!
    protected void finalize() {
        Log.v(“BPC”, “Dropped + … + myBinaryPoly.toString());   
    }
}

好的,但是如果myBinaryPoly是一个纯Java对象,这也是真的吗?据我所知,问题出在处理可能已经完成的对象的所有者的finalizer内部。假设我们只使用对象的finalizer来删除其自己的私有本地对象,并且没有做任何其他事情,那么我们应该没问题,对吧?
Finalizer 可能在本地方法运行时被调用
按照 Java 规则,但目前在 Android 上不适用: 当 x 的某个方法仍在运行并访问本地对象时,x 的终结器可能会被调用。
这里展示了 multiply() 编译后的伪代码以解释这一点:
BinaryPoly multiply(BinaryPoly other) {
    long tmpx = this.mNativeHandle; // last use of “this”
    long tmpy = other.mNativeHandle; // last use of other
    BinaryPoly result = new BinaryPoly();
    // GC happens here. “this” and “other” can be reclaimed and finalized.
    // tmpx and tmpy are still needed. But finalizer can delete tmpx and tmpy here!
    result.mNativeHandle = nativeMultiply(tmpx, tmpy)
    return result;
}

这很可怕,我很庆幸这种情况不会发生在安卓上,因为我的理解是thisother在超出作用域之前就被垃圾回收了!更奇怪的是,this是方法调用的对象,而other是方法的参数,因此它们两个都应该已经在方法被调用的作用域中“存活”。
一个快速的解决方法是在thisother上调用一些虚拟方法(丑陋!),或将它们传递给本地方法(在那里我们可以检索mNativeHandle并对其进行操作)。等等...this默认已经是本地方法的一个参数了!
JNIEXPORT void JNICALL Java_package_BinaryPoly_multiply
(JNIEnv* env, jobject thiz, jlong xPtr, jlong yPtr) {}

我该如何将this垃圾回收?
终结器可能被推迟太久
“为了正确运行,如果您运行分配大量本地内存和相对较少 Java 内存的应用程序,则垃圾收集器可能无法及时运行以实际调用终结器[...] 因此,您可能需要偶尔调用 System.gc() 和 System.runFinalization(),这很棘手[...]”
如果本地对等体仅由其绑定的单个 Java 对象看到,那么这个事实是否对系统的其余部分透明,因此 GC 只需像处理纯 Java 对象一样管理 Java 对象的生命周期?在这里,显然有些事情我没有看到。
终结器实际上可以延长 Java 对象的生命周期
“[...] 有时终结器实际上会延长 Java 对象的生命周期,使其在另一个垃圾回收周期中存活,这意味着对于分代垃圾收集器,它们可能会导致对象存活到老一代,并且寿命可能会因拥有终结器而大大延长。”
我承认我不太明白问题所在以及它与具有本地对等体有何关系,我将进行一些研究并可能更新问题 :)

总结

目前,我仍然认为使用一种类似RAII的方法,在Java对象的构造函数中创建本地对等体并在finalize方法中删除它实际上并不危险,前提是:

  • 本地对等体不持有任何关键资源(在这种情况下,应该有一个单独的方法来释放资源,本地对等体必须只作为本地领域中Java对象的“对应物”)
  • 本地对等体在其析构函数中不跨线程或执行奇怪的并发操作(谁想这样做?!)
  • 本地对等体指针从未在Java对象之外共享,仅属于单个实例,并且仅在Java对象的方法内部访问。在Android上,Java对象可以在调用接受不同本地对等体的jni方法之前访问同一类别的另一个实例的本地对等体,或者更好的是,将Java对象传递给本地方法本身
  • Java对象的终结器仅删除自己的本地对等体,不执行其他操作

是否还应添加其他限制,或者即使遵守所有限制,也无法确保终结器安全?


1
通常情况下,finalizer 被认为是一种后备选项,用于在管理代码未能执行 dispose()(或其他操作)的情况下调用它,并经常打印日志警告。 - chrylis -cautiouslyoptimistic-
2
请在投票之前阅读两个问题,以确定是否重复。 - Basilevs
7
我认为这个问题不应该被标记为重复。原始问题是问“如何自动删除本地对等对象?”,答案是“使用终结器”。我的问题则特别针对一次讲座,建议永远不要使用终结器(甚至与原问题中的被接受答案相矛盾!),我想要更多深入的见解和其他知情人士的意见,以了解这种建议的原因。 - athos
1
感谢您发起这个讨论。不幸的是,这样的开放式讨论并不适合 SO 的格式,但我认为这是讨论此主题的最佳场所。首先,在我的书中,“private BinaryPoly(long nativeHandle)” 不应该存在,而且整个示例非常人为。 - Alex Cohn
4
在可预见的未来(即使有可能),这可能与Android无关,但请注意Object.finalize()在Java 9中已被弃用,尽管在注释中没有forRemoval=true属性,因此目前还没有明确的计划实际删除它。这并不会削弱支持使用 finalize() 的论点的任何好处,但我认为这值得一提(毕竟该问题具有java标签)。 - Hugues M.
6个回答

10
< p > finalize 和其他利用 GC 对象生命周期知识的方法有一些细微差别:

  • 可见性:您是否保证对象o的所有写入方法对于 finalizer 是可见的(即,对象o上的最后一个操作与执行 finalization 的代码之间存在happens-before关系)?
  • 可达性:您如何保证对象o不会过早销毁(例如,在其某个方法正在运行时),这是 JLS 允许的?它确实happen并导致崩溃。
  • 排序:您能强制执行某种顺序来完成对象的 finalization 吗?
  • 终止:在应用程序终止时,您需要销毁所有对象吗?
  • 吞吐量:基于 GC 的方法提供的解除分配吞吐量显著小于确定性方法。

使用终结器可以解决所有这些问题,但需要大量的代码。Hans-J. Boehm有一份很棒的演示文稿,展示了这些问题和可能的解决方案。

为了保证可见性,您需要同步您的代码,即在常规方法中放置具有发布语义的操作,在终结器中放置一个具有获取语义的操作。例如:

  • 在每个方法的末尾将内容存储到volatile + 在终结器中读取相同的volatile
  • 在每个方法的末尾释放对象上的锁+在终结器的开始获得该锁(请参阅Boehm幻灯片中的keepAlive实现)。

为了确保可达性(如果语言规范尚未保证它),可以使用:

上述同步方法也确保了可达性。
将必须保持可达性(= 不可终止)的对象的引用作为参数传递给本地方法。在您所引用的讲话中,nativeMultiplystatic的,因此this可能被垃圾回收。
从Java 9+开始使用{{link3:Reference#reachabilityFence}}。
与普通的finalize不同,PhantomReferences提供了更多控制终结各个方面的方法:
  • 可以有多个队列接收虚引用并且为每个队列选择执行终结的线程。
  • 可以在分配对象的同一线程中进行终结(例如,线程本地的ReferenceQueues)。
  • 更容易实施排序:将一个强引用保留到必须在A终结时仍然存活的对象B作为PhantomReference的字段;
  • 更容易实现安全终止,因为您必须使PhantomRefereces在被GC排队之前保持强可达性。

非常出色的回答!在Boehm的幻灯片中,第34页似乎表明,在每个方法的结尾处以volatile存储+在finalize()中读取相同的volatile,可以同时满足可见性和可达性,但是您的观点似乎认为这种方法仅适用于可见性,而不适用于可达性。您能否就此发表评论? - jbapple
1
@jbapple 谢谢您的反馈,我很感激!我认为可能是措辞不清楚,我澄清了在上一段描述中所述的“同步”,已经确保了可达性。另外两个项目(传递对this和任何其他必须保持可达性的对象的引用;以及栅栏)可以在某些罕见情况下用于确保可达性,当您不关心可见性时,因此不使用同步(例如,在Java ctor完成之前构造本机对等体且不可变)。 - Dmitry Timofeev

5

我的看法是,当你完成对本地对象的使用后,应该以确定性的方式尽快释放它们。因此,使用作用域来管理它们比依赖于终止器更可取。您可以使用终止器作为最后一道清理手段,但我不会仅仅依靠它来管理实际的生命周期,正如您在自己的问题中指出的原因。

因此,让终止器成为最后的尝试,而不是第一个。


假设踩的是那些知道更好方法却不肯分享的人。好吧,继续这样投票吧,我会继续写出一流的代码。 - cineam mispelt
2
嗨,感谢您的回答。使用显式的“close”函数和作用域是一个好建议,“当它起作用时”,但正如Boehm在他的讲话中所述 - 以及我的经验 - 显式释放委托对象可能是一项令人望而生畏且容易出错的工作。在我看来,这就像试图在销毁对象的一半时执行垃圾收集器的工作。这就是为什么我们需要更多“自动化”的东西,比如幻象引用或...在终结器中删除委托/对等体对象 :) - athos

4
我认为这场辩论的大部分源于finalize()的遗留地位。它是在Java中引入的,用于解决垃圾收集无法覆盖的问题,但不一定包括系统资源(文件、网络连接等),因此它总是感觉有点不完整。我不一定赞同使用像phantomreference这样的东西,它声称比finalize()更好,但模式本身存在问题。
Hugues Moreau 指出,在Java 9中finalize()将被弃用。Java团队首选的模式似乎是将诸如本机对等体之类的东西视为系统资源,并通过try-with-resources进行清理。实现AutoCloseable使您能够执行此操作。请注意,try-with-resources和AutoCloseable都晚于Josh Bloch直接参与Java和Effective Java第2版。

1

这怎么可能进行垃圾回收呢?

因为函数 nativeMultiply(long xCppPtr, long yCppPtr) 是静态的。如果一个本地函数是静态的,它的第二个参数是指向其类而不是指向 thisjobjectjclass。所以在这种情况下,this 不是其中的一个参数。

如果它不是静态的,那么只会存在与 other 对象有关的问题。


本质上,nativeMultiply() 可能是静态的,但需要两个 jobject 参数。让 JNI 代码从本地方法中提取出这两个参数的 mNativeHandle。在理想的情况下,Java 代码不应该处理不透明的本地句柄。 - Alex Cohn

1

嗨,谢谢,但正如问题中所述,我链接的Google I/O视频完全是关于使用PhantomReference的。我不确定的是finalizer的绝对糟糕性,特别是视频中提到的四个不使用它们的原因。 - athos
1
我应该补充一下,在同一个视频中,Boehm展示了幽灵引用的一个缺点,即过早释放,他避免将Java对象传递给本地方法...这是一个解决方案,也可以使用finalizer(请参见问题中的“当本地方法仍在运行时可能会调用Finalizer”)。 - athos

0

让我提出一个引人注目的建议。如果您的托管Java对象的C ++侧可以在连续的内存中分配,那么可以使用DirectByteBuffer而不是传统的long 本机指针。这可能真的是一个改变游戏规则的时刻:现在GC可以足够聪明地处理围绕庞大本地数据结构的小型Java包装器(例如决定更早地收集它)。

不幸的是,大多数实际的C ++对象都不属于此类别......


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