是否真的可以避免Java终结器用于本机对等体对象生命周期管理?

ath*_*hos 36 java java-native-interface android googleio

根据我作为C++/Java/Android开发人员的经验,我已经了解到终结器几乎总是一个坏主意,唯一的例外是管理Java所需的"本地对等"对象来调用C/C++代码通过JNI.

我知道JNI:正确管理java对象问题的生命周期,但是这个问题解决了不使用终结器的原因,对于本地对等体也是如此.因此,在上述问题中对答案的解释是一个问题/讨论.

Joshua Bloch在他的Effective Java中明确将此案例列为他不使用终结器的着名建议的例外:

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

(另请参阅"堆栈交换中的为什么最终方法包含在Java中?"问题)

然后我看到真正有趣的如何在Google I/O '17中管理Android演讲中的本机内存,其中Hans Boehm实际上主张使用终结器来管理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);
    }
}
Run Code Online (Sandbox Code Playgroud)

在java类持有对在终结器方法中被删除的本机对等体的引用的情况下,Bloch列出了这种方法的缺点.

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

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

作为一个例子:

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

好吧,但如果myBinaryPoly是一个纯Java对象,那也不是这样吗?据我了解,问题来自于在其所有者的终结器内操作可能已完成的对象.如果我们只使用对象的终结器来删除自己的私有本地对等体并且没有做任何其他事情,我们应该没事,对吧?

可以在本机方法运行时调用Finalizer

通过Java规则,但目前不在Android上:
当x的一个方法仍在运行时,可以调用Object 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 neeed. But finalizer can delete tmpx and tmpy here!
    result.mNativeHandle = nativeMultiply(tmpx, tmpy)
    return result;
}
Run Code Online (Sandbox Code Playgroud)

这是可怕的,我真的感到宽慰,这不会发生在Android上,因为我理解的是,thisother在它们超出范围之前收集垃圾!考虑到this调用该方法的对象,这other是方法的参数,这甚至更奇怪,因此它们都应该在调用方法的范围内"活着".

一个快速的解决方法,这将是呼吁两国一些虚拟方法thisother(丑陋!),或者将它们传递给本地方法(在这里我们就可以检索mNativeHandle并在其上运行).等待... this默认情况下已经是本机方法的参数之一!

JNIEXPORT void JNICALL Java_package_BinaryPoly_multiply
(JNIEnv* env, jobject thiz, jlong xPtr, jlong yPtr) {}
Run Code Online (Sandbox Code Playgroud)

怎么this可能收集垃圾?

终结器可以推迟很长时间

"为了正常工作,如果你运行一个分配大量本机内存和相对较少的java内存的应用程序,实际上可能不是垃圾收集器运行得足够迅速实际调用终结器[...]所以你实际可能不得不偶尔调用System.gc()和System.runFinalization(),这样做很棘手[...]

如果本地对等体仅被它所绑定的单个java对象看到,那么这个事实对于系统的其余部分是不透明的,因此GC应该只需要管理Java对象的生命周期,因为它是纯java一个?显然我在这里看不到.

终结器实际上可以延长java对象的生命周期

[...]有时终结器实际上延长了java对象的生命周期,用于另一个垃圾收集周期,这意味着对于分代垃圾收集器,它们实际上可能使它生存到老一代,并且由于正常生命周期可能会大大延长有一个终结者.

我承认我没有真正得到这里的问题以及它与本地同行有什么关系,我会做一些研究并可能更新问题:)

结论

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

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

是否还有其他限制应该添加,或者即使所有限制都得到尊重,也无法确保终结器是否安全?

Dmi*_*eev 7

finalize 和其他使用对象生命周期知识的方法有一些细微差别:

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

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

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

  • volatile每个方法的末尾存储在 a中+在终结器中读取相同volatile的内容。
  • 在每个方法结束时释放对象上的锁+在终结器开始时获取锁(参见keepAliveBoehm 幻灯片中的实现)。

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


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

  • 可以有多个队列接收幻像引用为每个队列选择一个执行终结的线程。
  • 可以在进行分配的同一个线程中完成(例如,线程本地ReferenceQueues)。
  • 更容易执行排序:保持对对象的强引用,该对象BA最终确定为PhantomReferenceto字段时必须保持活动状态A
  • 更容易实现安全终止,因为您必须保持PhantomRefereces强可达,直到它们被 GC 排入队列。


小智 5

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

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

  • 嗨,谢谢你的回答.使用明确的"关闭"函数和范围是一个很好的建议,"当它工作时",但正如Boehm在他的演讲中所说 - 并且根据我的经验 - 明确地发布一个委托对象可能是一项令人生畏和容易出错的工作.就像我看到的那样,它就像是在破坏对象的一半时尝试垃圾收集器的工作.这就是为什么我们需要一些更"自动"的东西,比如幻像引用或者......在终结器中删除委托/同伴对象:) (2认同)