是否真的应该避免Java终结器对于本地对等生命周期pipe理?

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

我知道JNI:正确地pipe理一个java对象问题的生命周期 ,但是这个问题解决了不使用终结器的原因,无论是本地对等 。 所以这是一个关于上述问题的答案的疑问/讨论。

Joshua Bloch在他的Effective Java中明确列举了这个例子,他的着名build议是不使用终结器:

终结者的第二个合法使用涉及与本地同伴的对象。 本地对等体是普通对象通过本地方法委托的本地对象。 由于本地对等体不是普通对象,因此垃圾收集器不知道它,并且在其Java对等体被回收时不能回收它。 假定本地对等体不具有关键资源,终结器是执行此任务的合适工具。 如果本地对等体拥有必须立即终止的资源,那么类应该有一个明确的终止方法,如上所述。 终止方法应该做任何需要释放关键资源。 终止方法可以是本地方法,也可以调用它。

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

然后,我看到了真正有趣的如何在谷歌I / O17 pipe理Android通话中的本地内存 ,Hans Boehm实际上主张不要使用终结器来pipe理Java对象的本地对等 ,同时也引用Effective Java作为参考。 在快速提到为什么显式删除本地对等或基于范围的自动closures可能不是一个可行的select之后,他build议使用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对象,这是不是真的? 据我所知,这个问题来自对其所有者终结器内的一个可能最终确定的对象的操作。 如果我们只使用一个对象的终结器来删除它自己的私有本地对等体而不做别的事情,那我们应该没问题吧?

当本地方法正在运行时,可能会调用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; } 

这是可怕的,我实际上松了一口气,这不会发生在Android上,因为我知道的是, thisother垃圾收集之前,他们超出了范围! 考虑到this是方法被调用的对象, this更加怪异, other是方法的论点,所以它们在调用方法的范围内应该已经“活着”了。

一个快速的解决方法是在thisother (丑陋!)上调用一些虚拟方法,或者将它们传递给本地方法(然后我们可以检索mNativeHandle并对其进行操作)。 等等… this已经是默认的本地方法的参数之一!

 JNIEXPORT void JNICALL Java_package_BinaryPoly_multiply (JNIEnv* env, jobject thiz, jlong xPtr, jlong yPtr) {} 

这怎么可能垃圾收集?

终结者可能被推迟太久

为了正确工作,如果你运行的应用程序分配了大量的本地内存和相对较less的Java内存,实际上可能并不是垃圾收集器运行得足够快,从而实际调用了终结器[…]不得不偶尔调用System.gc()和System.runFinalization(),这很难做到“

如果本地对等方只能被一个与之绑定的java对象看到,这对于系统的其他部分是不是透明的,因此GC应该只需要pipe理Java对象的生命周期,因为它是一个纯java的一个? 显然有一些我在这里看不到。

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

有时,终结器实际上将java对象的生命周期延长到另一个垃圾收集周期,这意味着对于世代垃圾回收器来说,它们可能实际上导致它存活到老一代,并且生命期可能由于公正有一个终结者。

我承认我真的不知道这里有什么问题,以及它如何与一个本地的同龄人,我会做一些研究,并可能更新的问题:)

结论是

就目前而言,我仍然相信使用某种RAII方法是在java对象的构造函数中创build本地对等方,并且在finalize方法中删除本地对等方实际上并不危险,只要:

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

是否还有其他限制应该join,还是确实没有办法确保即使在尊重所有限制的情况下终结器也是安全的。

Solutions Collecting From Web of "是否真的应该避免Java终结器对于本地对等生命周期pipe理?"

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

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

我认为这个辩论大部分来自finalize()的遗留状态。 它是在Java中引入的,用于解决垃圾回收没有涉及的东西,但不一定是像系统资源(文件,networking连接等)的东西,所以它总是觉得有点儿不爽。 我不一定同意使用类似phantomreference的东西,当模式本身存在问题时,它宣称是比finalize()更好的终结器。

Hugues Moreau指出finalize()将在Java 9中被弃用.Java团队的首选模式似乎将本地对等事物当作系统资源来处理,并通过尝试资源来清理它们。 实现AutoCloseable允许你这样做。 请注意,试用资源和自动closuresdate都是Josh Bloch直接参与Java和Effective Java 2nd版的。

finalize和其他使用GC对象生命周期的方法有一些细微之处:

  • 可见性 :你是否保证对象o的所有写操作方法对于终结器是可见的(即,对象o的最后一个操作和执行终结的代码之间有一个事前 – 事前关系)?
  • 可达性 :你如何保证,一个对象o不被过早地破坏(例如,当它的一个方法正在运行时),这是JLS允许的吗? 它确实 发生并导致崩溃。
  • sorting :你能强制执行一个确定对象的顺序吗?
  • 终止 :你的应用程序终止时是否需要销毁所有的对象?

用finalizer可以解决所有这些问题,但是它需要大量的代码。 汉斯-J。 博姆有一个很好的介绍 ,显示这些问题和可能的解决scheme。

为了保证可见性 ,你必须同步你的代码,也就是说,把你的常规方法中的操作和释放语义放在一起,在你的终结器中使用Acquire语义。 例如:

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

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

  • 同步。
  • 来自Java 9的Reference#reachabilityFence
  • 在本地方法中传递必须保持可访问(= 不可终止 )的对象的引用。 在你参考的谈话中 , nativeMultiplystatic ,因此this 可能是垃圾收集。

明确finalizePhantomReferences之间的区别在于后者让你更多地控制定稿的各个方面:

  • 可以有多个队列接收幻影参考, 挑选一个线程执行每个人的定稿。
  • 可以在分配的同一线程中完成(例如,线程本地ReferenceQueues )。
  • 更容易执行sorting:当A被定位为PhantomReferenceA的字段时,保持对必须保持活动的对象B的强引用;
  • 更容易实现安全终止,因为您必须保持PhantomRefereces强烈可及,直到它们被GC排队。

让我拿出一个挑衅性的build议。 如果托pipeJava对象的C ++端可以分配在连续内存中,那么可以使用DirectByteBuffer ,而不是传统的本地指针。 这可能确实是一个改变游戏规则的东西:现在GC可以对这些围绕巨大的本地数据结构的小型Java包装(例如,决定早些收集它)足够聪明。

不幸的是,大多数现实生活中的C ++对象都不属于这个类别。