【问题标题】:Should Java finalizer really be avoided also for native peer objects lifecycle management?对于本机对等对象生命周期管理,是否真的应该避免使用 Java 终结器?
【发布时间】:2017-10-21 01:42:48
【问题描述】:

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

我知道JNI: Properly manage the lifetime of a java object question,但这个问题解决了无论如何都不使用终结器的原因,对于本地同行也不使用。因此,这是对上述问题中答案的反驳的问题/讨论。

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

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

(另见"Why is the finalized method included in 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 类持有对在终结器方法中删除的本机对等点的引用,Bloch 列出了这种方法的缺点。

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

如果两个对象变得不可达,终结器实际上以任意顺序运行,这包括两个指向彼此的对象同时变得不可达的情况,它们可能以错误的顺序被终结,这意味着第二个对象be finalized 实际上会尝试访问已经完成的对象。 [...] 因此,您可以获得悬空指针并查看已释放的 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 上,因为我的理解是 thisother 在超出范围之前会收集垃圾!考虑到this 是调用该方法的对象,而other 是该方法的参数,这更加奇怪,因此它们都应该已经在调用该方法的范围内“处于活动状态”。

对此的快速解决方法是在 thisother 上调用一些虚拟方法(丑陋!),或者将它们传递给本机方法(然后我们可以在其中检索 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 对象的构造函数中创建本地对等点并在 finalize 方法中删除实际上并不危险,前提是:

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

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

【问题讨论】:

  • 通常,如果管理代码未能执行此操作,并且通常会打印日志警告,则终结器被视为调用 dispose()(或其他)的备用选项。
  • 请在为重复投票之前阅读这两个问题。
  • 我相信这不应该被标记为重复。最初的问题是“我应该如何自动删除本机对等对象?”答案是“使用终结器”。我的问题特别是关于建议不要使用终结器的演讲(甚至与原始问题中接受的答案相矛盾!),我正在寻求更多见解和其他知情意见以了解原因这样的建议。
  • 感谢您开始本次讨论。不幸的是,这样的公开讨论不太适合 SO 的格式,但我认为这是这个话题的最佳场所。首先,在我的书中,private BinaryPoly(long nativeHandle) 不应该存在,整个例子都是非常人为的。
  • 这在可预见的未来(如果有的话)可能不会涉及 Android,但请注意 Object.finalize() 在 Java 9 中是 deprecated,尽管注释中没有 forRemoval=true 属性,所以在这个阶段没有明确的计划来实际删除它。它并没有消除支持使用finalize() 的论点的任何优点,但我认为它值得一提(毕竟问题确实有java 标签)。

标签: java android java-native-interface finalize finalization


【解决方案1】:

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

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

使用终结器可以解决所有这些问题,但它需要大量的代码。汉斯-J。 Boehm 的 a great presentation 显示了这些问题和可能的解决方案。

为了保证可见性,你必须同步你的代码,即把带有Release语义的操作放在你的常规方法中,以及一个带有Acquire的操作em> 终结器中的语义。例如:

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

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


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

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

【讨论】:

  • 精彩的答案!在 Boehm 的幻灯片中,幻灯片 34 似乎表明每个方法末尾的 volatile 中的存储 + 在 finalize() 中读取相同的 volatile 足以满足可见性 可达性,但看起来你的观点是这种方法只对可见性有好处,对可达性没有好处。你能对此发表评论吗?
  • @jbapple 感谢您的反馈,非常感谢!我认为这肯定是措辞不清楚,我澄清了上一段中描述的“同步”已经确保了可达性。其他两项(传递对this 的引用以及任何其他必须保持可达性的对象;以及栅栏)可用于在您不关心可见性的极少数情况下确保可达性,因此,不要使用同步(例如,本地对等点在 Java ctor 完成之前构建并且是不可变的)。
【解决方案2】:

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

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

【讨论】:

  • 假设不赞成票的人知道更好的方法但没有告诉。好吧,伙计,继续投票吧,我会继续写一流的代码。
  • 您好,感谢您的回答。使用显式的“关闭”函数和范围是“当它工作时”的一个好建议,但正如 Boehm 在他的演讲中所说的 - 以我的经验 - 显式释放委托对象可能是一项艰巨且容易出错的工作。在我看来,这就像试图做垃圾收集器的工作来销毁一半的对象。这就是为什么我们需要一些更“自动”的东西,比如幻影引用或......在终结器中删除委托/对等对象:)
【解决方案3】:

我认为这场争论的大部分源于 finalize() 的遗留状态。它是在 Java 中引入的,以解决垃圾收集未涵盖的问题,但不一定是系统资源(文件、网络连接等)之类的问题,因此它总感觉有点半生不熟。我不一定同意使用 phantomreference 之类的东西,当模式本身有问题时,它自称是比 finalize() 更好的终结器。

Hugues Moreau 指出 finalize() 将在 Java 9 中被弃用。Java 团队的首选模式似乎是将本地对等对象视为系统资源并通过 try-with-resources 清理它们。实现AutoCloseable 允许您执行此操作。请注意,try-with-resources 和 AutoCloseable 都在 Josh Bloch 直接参与 Java 和 Effective Java 2nd edition 之后。

【讨论】:

    【解决方案4】:

    【讨论】:

    • 嗨,谢谢,但正如问题中所述,我链接的谷歌 i/o 视频都是关于使用幻像引用的。我不相信终结器的绝对坏处,尤其是视频中提到的四个不使用它们的原因
    • 我应该补充一点,在同一个视频中,Boehm 展示了幻像引用的缺点之一,即过早解除分配,他避免将 java 对象传递给本机方法......一个可行的解决方案还使用终结器(请参阅问题中的“在本机方法运行之前可能会调用终结器”)。
    【解决方案5】:

    这怎么可能被垃圾收集?

    因为函数nativeMultiply(long xCppPtr, long yCppPtr) 是静态的。如果一个本地函数是静态的,它的第二个参数是jclass 指向它的类而不是jobject 指向this。所以在这种情况下,this 不是论点之一。

    如果它不是静态的,那么只有other 对象会出现问题。

    【讨论】:

    • 本质上,nativeMultiply() 可能是静态的,但需要两个 jobject 参数。让本地方法的 JNI 代码为两个参数提取 mNativeHandle。在理想情况下,Java 代码不应该处理不透明的原生句柄。
    【解决方案6】:

    让我提出一个挑衅性的提议。如果托管 Java 对象的 C++ 端可以分配在连续内存中,则可以使用 DirectByteBuffer 代替传统的 long 本机指针。这可能真的会改变游戏规则:现在 GC 可以足够聪明地处理这些围绕大型原生数据结构的小型 Java 包装器(例如,决定更早地收集它)。

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

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2012-10-14
      • 2012-11-06
      • 1970-01-01
      • 2013-08-08
      • 1970-01-01
      • 2013-08-22
      • 2012-07-28
      • 1970-01-01
      相关资源
      最近更新 更多