40

根据我作为 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 上,因为我的理解是,thisother垃圾超出范围之前收集垃圾!this考虑到调用该方法的对象和该方法的参数,这甚至更奇怪other,因此它们都应该在调用该方法的范围内已经“活着”。

一个快速的解决方法是在thisand上调用一些虚拟方法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 对象的终结器只删除它自己的本地对等点,不做任何其他事情

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

4

6 回答 6

9

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

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

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

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

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

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


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

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

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

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

于 2017-06-05T10:35:23.497 回答
4

我认为这场辩论的大部分源于 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 之后。

于 2017-06-10T19:21:19.583 回答
1

这怎么可能被垃圾收集?

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

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

于 2018-01-08T08:41:53.307 回答
1

请参阅https://github.com/android/platform_frameworks_base/blob/master/graphics/java/android/graphics/Bitmap.java#L135 使用幻像引用而不是终结器

于 2017-06-10T06:17:47.183 回答
0

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

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

于 2017-06-11T21:25:23.560 回答