对于本机对等对象生命周期管理,是否真的应该避免使用 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上没有发生,因为我的理解是,在垃圾超出范围之前收集垃圾!考虑到这是调用方法的对象,并且这是方法的参数,因此它们都应该已经在调用方法的范围内“处于活动状态”,这甚至更奇怪。this
other
this
other
对此的快速解决方法是在两个和(丑陋!)上调用一些虚拟方法,或者将它们传递给本机方法(然后我们可以在其中检索并对其进行操作)。等等... 默认情况下,它已是本机方法的参数之一!this
other
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对象的构造函数中创建本机对等体并在finize方法中删除实际上并不危险,前提是:
- 本机对等体不持有任何关键资源(在这种情况下,应该有一个单独的方法来释放资源,本机对等体必须仅充当本机域中的java对象“对应物”)
- 本机对等体不会跨越线程,也不会在其析构函数中执行奇怪的并发操作(谁会想要这样做?!?)
- 本机对等指针永远不会在 java 对象外部共享,只属于单个实例,并且只能在 java 对象的方法内部访问。在Android上,Java对象可以访问同一类的另一个实例的本机对等体,就在调用接受不同本机对等体的jni方法之前,或者更好的是,只是将java对象传递给本机方法本身。
- Java 对象的终结器只删除自己的本机对等体,而不执行任何其他操作
是否有任何其他限制应该添加,或者即使遵守所有限制,也确实没有办法确保终结器是安全的?