Should Java finalizer really be avoided also for native peer objects lifecycle management?

2.8k views Asked by At

In my experience as a C++/Java/Android developer, I have come to learn that finalizers are almost always a bad idea, the only exception being the management of a "native peer" object needed by the java one to call C/C++ code through JNI.

I am aware of the JNI: Properly manage the lifetime of a java object question, but this question addresses the reasons not to use a finalizer anyway, neither for native peers. So it's a question/discussion on a confutation of the answers in the aforementioned question.

Joshua Bloch in his Effective Java explicitly lists this case as an exception to his famous advice on not using finalizers:

A second legitimate use of finalizers concerns objects with native peers. A native peer is a native object to which a normal object delegates via native methods. Because a native peer is not a normal object, the garbage collector doesn't know about it and can’t reclaim it when its Java peer is reclaimed. A finalizer is an appropriate vehicle for performing this task, assuming the native peer holds no critical resources. If the native peer holds resources that must be terminated promptly, the class should have an explicit termination method, as described above. The termination method should do whatever is required to free the critical resource. The termination method can be a native method, or it can invoke one.

(Also see "Why is the finalized method included in Java?" question on stackexchange)

Then I watched the really interesting How to manage native memory in Android talk at the Google I/O '17, where Hans Boehm actually advocates against using finalizers to manage native peers of a java object, also citing Effective Java as a reference. After quickly mentioning why explicit delete of the native peer or automatic closing based on scope might not be a viable alternative, he advises using java.lang.ref.PhantomReference instead.

He makes some interesting points, but I am not completely convinced. I will try to run through some of them and state my doubts, hoping someone can shed further light on them.

Starting from this example:

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);
    }
}

Where a java class holds a reference to a native peer that gets deleted in the finalizer method, Bloch lists the shortcomings of such an approach.

Finalizers can run in arbitrary order

If two objects become unreachable, the finalizers actually run in arbitrary order, that includes the case when two objects who point to each others become unreachable at the same time they can be finalized in the wrong order, meaning that the second one to be finalized actually tries to access an object that’s already been finalized. [...] As a result of that you can get dangling pointers and see deallocated c++ objects [...]

And as an example:

class SomeClass {
    BinaryPoly mMyBinaryPoly:
    …
    // DEFINITELY DON’T DO THIS WITH CURRENT BinaryPoly!
    protected void finalize() {
        Log.v(“BPC”, “Dropped + … + myBinaryPoly.toString());   
    }
}

Ok, but isn't this true also if myBinaryPoly is a pure Java object? As I understand it , the problem comes from operating on a possibly finalized object inside its owner's finalizer. In case we are only using the finalizer of an object to delete its own private native peer and not doing anything else, we should be fine, right?

Finalizer may be invoked while the native method is till running

By Java rules, but not currently on Android:
Object x’s finalizer may be invoked while one of x’s methods is still running, and accessing the native object.

Pseudo-code of what multiply() gets compiled to is shown to explain this:

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;
}

This is scary, and I am actually relieved this doesn't happen on android, because what I understand is that this and other get garbage collected before they go out of scope! This is even weirder considering that this is the object the method is called on, and that other is the argument of the method, so they both should already "be alive" in the scope where the method is being called.

A quick workaround to this would be to call some dummy methods on both this and other (ugly!), or passing them to the native method (where we can then retrieve the mNativeHandle and operate on it). And wait... this is already by default one of the arguments of the native method!

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

How can this be possibly garbage collected?

Finalizers can be deferred for too long

“For this to work correctly, if you run an application that allocates lots of native memory and relatively little java memory it may actually not be the case that the garbage collector runs promptly enough to actually invoke finalizers [...] so you actually may have to invoke System.gc() and System.runFinalization() occasionally, which is tricky to do [...]”

If the native peer is only seen by a single java object which it is tied to, isn’t this fact transparent to the rest of the system, and thus the GC should just have to manage the lifecycle of the Java object as it was a pure java one? There's clearly something I fail to see here.

Finalizers can actually extend the lifetime of the java object

[...] Sometimes finalizers actually extend the lifetime of the java object for another garbage collection cycle, which means for generational garbage collectors they may actually cause it to survive into the old generation and the lifetime may be greatly extended as a result of just having a finalizer.

I admit I don't really get what's the issue here and how it relates to having a native peer, I will make some research and possibly update the question :)

In conclusion

For now, I still believe that using a sort of RAII approach were the native peer is created in the java object's constructor and deleted in the finalize method is not actually dangerous, provided that:

  • the native peer doesn't hold any critical resource (in that case there should be a separate method to release the resource, the native peer must only act as the the java object "counterpart" in the native realm)
  • the native peer doesn't span threads or do weird concurrent stuff in its destructor (who would want to do that?!?)
  • the native peer pointer is never shared outside the java object, only belongs to a single instance, and only accessed inside the java object's methods. On Android, a java object may access the native peer of another instance of the same class, right before calling a jni method accepting different native peers or, better, just passing the java objects to the native method itself
  • the java object's finalizer only deletes its own native peer, and does nothing else

Is there any other restriction that should be added, or there's really no way to ensure that a finalizer is safe even with all restrictions being respected?

6

There are 6 answers

1
ferini On

How can this be possibly garbage collected?

Because function nativeMultiply(long xCppPtr, long yCppPtr) is static. If a native function is static, its second parameter is jclass pointing to its class instead of jobject pointing to this. So in this case this is not one of the arguments.

If it had not been static there would be only issue with the other object.

2
Dmitry Timofeev On

finalize and other approaches that use GC knowledge of objects lifetime have a couple of nuances:

  • visibility: do you guarantee that all the writes methods of object o made are visible to the finalizer (i.e., there is a happens-before relationship between the last action on object o and the code performing finalization)?
  • reachability: how do you guarantee, that an object o isn't destroyed prematurely (e.g., whilst one of its methods is running), which is allowed by the JLS? It does happen and cause crashes.
  • ordering: can you enforce a certain order in which objects are finalized?
  • termination: do you need to destroy all the objects when your app terminates?
  • throughput: GC-based approaches offer significantly smaller deallocation throughput than the deterministic approach.

It is possible to solve all of these issues with finalizers, but it requires a decent amount of code. Hans-J. Boehm has a great presentation which shows these issues and possible solutions.

To guarantee visibility, you have to synchronize your code, i.e., put operations with Release semantics in your regular methods, and an operation with Acquire semantics in your finalizer. For example:

  • A store in a volatile at the end of each method + read of the same volatile in a finalizer.
  • Release lock on the object at the end of each method + acquire the lock at the beginning of a finalizer (see keepAlive implementation in Boehm's slides).

To guarantee reachability (when it's not already guaranteed by the language specification), you may use:


The difference between plain finalize and PhantomReferences is that the latter gives you way more control over the various aspects of finalization:

  • Can have multiple queues receiving phantom refs and pick a thread performing finalization for each of them.
  • Can finalize in the same thread that did allocation (e.g., thread local ReferenceQueues).
  • Easier to enforce ordering: keep a strong reference to an object B that must remain alive when A is finalized as a field of PhantomReference to A;
  • Easier to implement safe termination, as you must keep PhantomRefereces strongly reachable until they are enqueued by GC.
2
cineam mispelt On

My own take is that one should release native objects as soon as you are done with them, in a deterministic fashion. As such, using scope to manage them is preferable to relying on the finalizer. You can use the finalizer to cleanup as a last resort, but, i would not use solely to manage the actual lifetime for the reasons you actually pointed out in your own question.

As such, let the finalizer be the final attempt, but not the first.

0
Scott On

I think most of this debate stems from the legacy status of finalize(). It was introduced in Java to address things that garbage collection didn't cover, but not necessarily things like system resources (files, network connections, etc.) so it always felt kind of half baked. I don't necessarily agree with using something like phantomreference, which professes to be a better finalizer than finalize() when the pattern itself is problematic.

Hugues Moreau pointed out that finalize() will be deprecated in Java 9. The preferred pattern of the Java team appears to be treating things like native peers as a system resource and cleaning them up via try-with-resources. Implementing AutoCloseable allows you to do this. Note that try-with-resources and AutoCloseable post-date both Josh Bloch's direct involvement with Java and Effective Java 2nd edition.

0
Alex Cohn On

Let me come up with a provocative proposal. If your C++ side of a managed Java object can be allocated in contiguous memory, then instead of the traditional long native pointer, you can use a DirectByteBuffer. This may really be a game changer: now GC can be smart enough about these small Java wrappers around huge native data structures (e.g. decide to collect it earlier).

Unfortunately, most real life C++ objects don't fall into this category...

2
wanpen On