对于本机对等对象生命周期管理,是否真的应该避免使用 Java 终结器?

根据我作为C++/Java/Android开发人员的经验,我开始了解到终结器几乎总是一个坏主意,唯一的例外是java需要管理一个“本机对等”对象,通过JNI调用C/C++代码。

我知道JNI:正确管理Java对象的生存期问题,但这个问题解决了无论如何都不使用终结器的原因,对于本机对等体也是如此。因此,这是一个关于上述问题中答案组合的问题/讨论。

Joshua Bloch在他的《Effective Java》中明确地将这种情况列为他关于不使用终结器的著名建议的例外:

终结器的第二个合法用法涉及具有本机对等体的对象。本机对等体是普通对象通过本机方法委派到的本机对象。由于本机对等体不是普通对象,因此垃圾回收器不知道它,并且在回收其 Java 对等体时无法回收它。终结器是执行此任务的适当工具,假设本机对等方不持有任何关键资源。如果本机对等方持有必须立即终止的资源,则该类应具有显式终止方法,如上所述。终止方法应执行释放关键资源所需的任何操作。终止方法可以是本机方法,也可以调用一个。

(另请参阅stackexchange上的“为什么最终确定的方法包含在Java中?”问题)

然后,我在Google I / O '17上观看了非常有趣的如何在Android中管理本机内存的演讲,Hans Boehm实际上主张不要使用终结器来管理Java对象的本机对等体,还引用了有效的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 类包含对在终结器方法中删除的本机对等体的引用,则 Bloch 会列出此方法的缺点。

终结器可以按任意顺序运行

如果两个对象变得不可访问,则终结器实际上以任意顺序运行,这包括当两个指向彼此的对象同时变得无法访问时,它们可能以错误的顺序完成,这意味着要最终确定的第二个对象实际上尝试访问已经完成的对象。[...]因此,您可以获得悬空的指针并查看已解除分配的c ++对象[...]

举个例子:

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

好吧,但是如果myBinaryPoly是一个纯Java对象,这不是也是真的吗?据我所知,问题来自于在其所有者的终结器中操作一个可能已终结的对象。如果我们只使用对象的终结器来删除它自己的私有本机对等体而不做任何其他事情,我们应该没问题,对吧?

终结器可以在本机方法运行期间调用

通过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;
}

这很可怕,我实际上松了一口气,这在Android上没有发生,因为我的理解是,在垃圾超出范围之前收集垃圾!考虑到这是调用方法的对象,并且这是方法的参数,因此它们都应该已经在调用方法的范围内“处于活动状态”,这甚至更奇怪。thisotherthisother

对此的快速解决方法是在两个和(丑陋!)上调用一些虚拟方法,或者将它们传递给本机方法(然后我们可以在其中检索并对其进行操作)。等等... 默认情况下,它已是本机方法的参数之一!thisothermNativeHandlethis

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对象的构造函数中创建本机对等体并在finize方法中删除实际上并不危险,前提是:

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

是否有任何其他限制应该添加,或者即使遵守所有限制,也确实没有办法确保终结器是安全的?


答案 1

finalize使用对象生存期的GC知识的其他方法有几个细微差别:

  • 可见性:你是否保证对象 o 的所有写入方法对终结器都是可见的(即,对象 o 上的最后一个操作与执行终结的代码之间存在之前发生关系)?
  • 可访问性:你如何保证一个对象o不会被过早破坏(例如,当它的一个方法正在运行时),这是JLS允许的?它确实发生并导致崩溃。
  • 排序:是否可以强制执行对象最终确定的特定顺序?
  • 终止:是否需要在应用终止时销毁所有对象?
  • 吞吐量:与确定性方法相比,基于 GC 的方法提供的解除分配吞吐量要小得多。

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

为了保证可见性,您必须同步代码,即在常规方法中放置具有 Release 语义的操作,并在终结器中使用 Acquire 语义的操作。例如:

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

为了保证可访问性(当语言规范尚未保证时),您可以使用:


普通和之间的区别在于,后者可以让您更好地控制最终确定的各个方面:finalizePhantomReferences

  • 可以有多个队列接收幻像引用,为每个队列选择一个执行终结的线程。
  • 可以在进行分配的同一线程中完成(例如,线程本地)。ReferenceQueues
  • 更易于强制执行排序:保留对对象的强引用,该对象在最终确定为BAPhantomReferenceA;
  • 更容易实现安全终止,因为您必须保持强力可访问性,直到它们被GC排队。PhantomRefereces

答案 2

我自己的看法是,一旦你完成了原生对象,就应该以确定性的方式释放它们。因此,使用范围来管理它们比依赖终结器更可取。您可以使用终结器进行清理作为最后的手段,但是,我不会仅仅使用来管理实际的生存期,因为您在自己的问题中实际指出了原因。

因此,让终结器成为最后的尝试,但不是第一次。


推荐