根据我作为 C++/Java/Android 开发人员的经验,我了解到终结器几乎总是一个坏主意,唯一的例外是管理 Java 调用 C/C++ 代码所需的“本机对等”对象通过 JNI。
我知道JNI: Properly manage the life of a java object question,但是这个问题解决了无论如何都不使用终结器的原因,对于本机对等点也不使用。因此,这是对上述问题中答案的反驳的问题/讨论。
Joshua Bloch 在其Effective Java中明确将这种情况列为他关于不使用终结器的著名建议的例外:
终结器的第二个合法用途涉及具有本地对等点的对象。本机对等点是普通对象通过本机方法委托给其的本机对象。因为本地对等点不是普通对象,所以垃圾收集器不知道它,也无法在其 Java 对等点被回收时回收它。假设本地对等点没有关键资源,终结器是执行此任务的合适工具。如果本地对等点拥有必须立即终止的资源,则该类应具有显式终止方法,如上所述。终止方法应该执行释放关键资源所需的任何操作。终止方法可以是本机方法,也可以调用一个。
(另请参阅“为什么 Java 中包含最终方法?” stackexchange 上的问题)
然后我在 Google I/O '17 上观看了真正有趣的How to manage native memory in 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);
}
}
在 java 类持有对在终结器方法中删除的本机对等点的引用时,布洛赫列出了这种方法的缺点。
终结器可以以任意顺序运行
如果两个对象变得不可达,终结器实际上以任意顺序运行,这包括两个指向彼此的对象同时变得不可达的情况,它们可能以错误的顺序被终结,这意味着实际上要终结的第二个对象尝试访问已经完成的对象。[...] 因此,您可以获得悬空指针并查看已释放的 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
and上调用一些虚拟方法other
(丑陋!),或者将它们传递给本机方法(然后我们可以在其中检索mNativeHandle
并对其进行操作)。等等......this
默认情况下已经是本机方法的参数之一!
JNIEXPORT void JNICALL Java_package_BinaryPoly_multiply
(JNIEnv* env, jobject thiz, jlong xPtr, jlong yPtr) {}
怎么可能this
被垃圾收集?
终结器可能会延迟太久
“为了使其正常工作,如果您运行的应用程序分配了大量本机内存和相对较少的 java 内存,那么垃圾收集器实际上可能不会足够迅速地运行以实际调用终结器 [...] 所以你实际上可能必须偶尔调用 System.gc() 和 System.runFinalization(),这很棘手 [...]”</p>
如果本地对等点只能被它所绑定的单个 java 对象看到,那么这个事实对系统的其余部分不是透明的,因此 GC 应该只需要管理 Java 对象的生命周期,因为它是纯java一个?很明显,我在这里看不到一些东西。
终结器实际上可以延长 java 对象的生命周期
[...] 有时终结器实际上将 java 对象的生命周期延长到另一个垃圾收集周期,这意味着对于分代垃圾收集器,它们实际上可能导致它在老一代中存活,并且生命周期可能会大大延长,因为只是有一个终结器。
我承认我真的不明白这里的问题是什么以及它与拥有本地同行有何关系,我会进行一些研究并可能更新问题:)
综上所述
目前,我仍然认为使用一种 RAII 方法是在 java 对象的构造函数中创建本地对等点并在 finalize 方法中删除实际上并不危险,前提是:
- 本机对等点不持有任何关键资源(在这种情况下,应该有一个单独的方法来释放资源,本机对等点只能充当本机领域中的 java 对象“对应物”)
- 本机对等点不跨越线程或在其析构函数中执行奇怪的并发操作(谁愿意这样做?!?)
- 本机对等指针永远不会在 java 对象之外共享,只属于单个实例,并且只能在 java 对象的方法内部访问。在 Android 上,java 对象可以访问同一类的另一个实例的本地对等点,就在调用接受不同本地对等点的 jni 方法之前,或者更好的是,只是将 java 对象传递给本地方法本身
- java 对象的终结器只删除它自己的本地对等点,不做任何其他事情
是否应该添加任何其他限制,或者即使遵守所有限制,也确实无法确保终结器是安全的?