JavaProgrammingSenior Java Developer

When replacing legacy Object.finalize() implementations with java.lang.ref.Cleaner, what specific reachability lifecycle constraint prevents the cleaning action from safely resurrecting the referent object during the cleanup phase?

Pass interviews with Hintsage AI assistant

Answer to the question.

The history traces back to Java 9's introduction of java.lang.ref.Cleaner as a replacement for the deprecated Object.finalize() method, which suffered from unpredictable execution timing, performance penalties, and security vulnerabilities including resurrection attacks. The core problem arises because finalize() allowed the overridden method to store a reference to this back into the live object graph, thereby "resurrecting" an object that the garbage collector had already deemed unreachable, violating the single-construction single-destruction invariant and potentially leaving native resources in an inconsistent state.

The solution leverages PhantomReference semantics within the Cleaner implementation: the cleaning action receives only the Runnable or cleaning action object, not the referent itself, and the referent is guaranteed to be in the phantom reachable state—meaning it has already been finalized and cannot be resurrected—ensuring that cleanup logic operates on an irrevocably unreachable object.

public class NativeResource { private static final Cleaner cleaner = Cleaner.create(); private final long nativeHandle; public NativeResource() { nativeHandle = allocateNative(); cleaner.register(this, new CleanupAction(nativeHandle)); Reference.reachabilityFence(this); } private static class CleanupAction implements Runnable { private final long handle; CleanupAction(long handle) { this.handle = handle; } @Override public void run() { releaseNative(handle); } } private native long allocateNative(); private static native void releaseNative(long handle); }

Situation from life

Our team managed a high-throughput image processing pipeline where ImageProcessor objects wrapped native OpenCV buffers allocated via JNI. Initially, we relied on finalize() to invoke cvReleaseImage(), but we sporadically encountered native memory exhaustion despite Java heap stability, accompanied by intermittent segmentation faults that suggested use-after-free errors in native memory.

The first approach considered was retaining finalize() but adding a resurrection guard where we synchronized on a static map to track "dead" objects. This suffered from unpredictable latency—finalization could occur minutes after heap pressure started—and the guard logic itself created memory leaks by keeping strong references to "dead" objects in the tracking map, ironically preventing their collection entirely while still allowing resurrection race conditions.

The second approach involved using java.lang.ref.Cleaner naively by capturing the native pointer in a lambda within the instance constructor: cleaner.register(this, () -> free(pointer)). While this avoided finalization delays, it risked premature collection if this escaped during construction, and critically, if the lambda accidentally closed over the ImageProcessor instance rather than just the pointer value, it would create a strong reference cycle preventing garbage collection of the referent, though still preventing resurrection per se.

We chose the third approach: implementing a static nested CleaningAction class that held only the native pointer (as a long) and implemented Runnable, completely decoupled from the outer ImageProcessor instance. We registered the cleaning action immediately after successful native allocation and explicitly invoked Reference.reachabilityFence(this) at the end of the constructor to ensure the object remained reachable until registration completed. This eliminated resurrection risks and native leaks, reducing memory pressure incidents from daily occurrences to zero over six months.

What candidates often miss

Why does the Cleaner use PhantomReference rather than WeakReference or SoftReference, and how does this prevent the cleaning action from accessing the referent's state?

PhantomReference is employed because it allows the garbage collector to identify reclaimable objects without allowing the program code to access the referent's fields or methods after it enters the phantom reachable state. Unlike WeakReference, which permits retrieval of the referent via get() until collection, PhantomReference always returns null from get(), ensuring the cleaning action operates on the assumption that the object is logically destroyed and preventing any resurrection attempts or state inspection that could violate post-mortem invariants.

What is the "resurrection" attack in the context of Object.finalize(), and why does it violate the type system's safety guarantees?

Resurrection occurs when the finalize() method stores the this reference into a static field or live object graph, making the object reachable again after the garbage collector had marked it for reclamation. This violates the invariant that an object's constructor runs exactly once and its finalizer runs at most once, allowing malicious or buggy code to observe an object in a partially destructed state where native resources are released but Java fields are still accessible, leading to use-after-free vulnerabilities and inconsistent object behavior.

How does Reference.reachabilityFence interact with the JVM's just-in-time compilation optimizations, and when is it strictly necessary to use it with Cleaner registrations?

Reference.reachabilityFence acts as a compiler barrier that prevents the JVM's optimizer from reordering or eliminating object references before a critical section completes, specifically preventing "early publication" where an object becomes unreachable while its constructor is still executing. It is strictly necessary when registering objects with Cleaner during construction because without it, the JVM might determine that this is no longer needed after the native resource allocation but before the registration call, allowing the cleaner to run and release the resource while the constructor continues to initialize the object, leading to resource corruption.