JavaProgrammingSenior Java Developer

What synchronization hazard arises when explicit resource release competes with automated cleanup in JDK classes managing native memory, exemplified by the **Inflater** implementation?

Pass interviews with Hintsage AI assistant

Answer to the question

History: Prior to Java 9, native resource management in classes like Inflater and Deflater relied on Object.finalize(). This mechanism was deprecated due to unpredictability, severe performance overhead, and the risk of object resurrection delaying garbage collection. Java 9 introduced the Cleaner API as a modern alternative, utilizing PhantomReference and ReferenceQueue to decouple cleanup logic from the object's lifecycle while ensuring the object remains unreachable during cleanup.

Problem: In the Inflater implementation, the underlying native z_stream structure must be explicitly deallocated via the end() method to prevent native memory leaks. When an application thread calls end() explicitly while the Cleaner thread simultaneously attempts to run the registered cleanup action, a race condition emerges. Without proper synchronization, both threads might attempt to free the same native pointer, leading to a double-free error, or one thread might access the resource after the other has freed it (use-after-free), resulting in JVM crashes (SIGSEGV) in the native zlib library.

Solution: The solution employs an AtomicBoolean state flag to ensure the native cleanup executes exactly once regardless of which thread initiates it. Both the explicit end() method and the Cleaner's cleanup action perform a compare-and-set (CAS) operation on this flag. Only the thread that successfully transitions the flag from false to true proceeds to invoke the native deallocation routine. This lock-free approach guarantees thread safety while maintaining the high performance required for compression operations.

Situation from life

A high-throughput log compression service processes millions of log entries daily using pooled Deflater instances to minimize allocation overhead. To optimize resource usage, developers implemented a return-to-pool pattern that explicitly calls end() on Deflater instances before releasing them back to the pool, while also relying on garbage collection to reclaim instances that leaked due to unhandled exceptions in the processing pipeline.

The system experienced sporadic but critical JVM crashes (SIGSEGV) under peak load, with core dumps indicating memory corruption within the native zlib library. Investigation revealed that when a Deflater instance was returned to the pool, the application thread called end(), but if the instance became eligible for garbage collection simultaneously, the Cleaner thread would also attempt to clean up the same native z_stream handle. This unsynchronized access to the native resource caused the process to crash unpredictably.

The first solution considered was synchronizing every access to the Deflater instance using synchronized blocks or methods. This approach would effectively prevent the race condition by ensuring mutual exclusion. However, it introduced significant contention overhead in the high-frequency compression pipeline and risked deadlocks if the object was incorrectly accessed from multiple threads concurrently, violating the class's thread-safety contract.

The second approach involved using an AtomicBoolean to track the cleanup state. Both the explicit end() method and the Cleaner action would atomically check and set this flag before touching the native resource. This offered lock-free safety with minimal performance penalty, though it required careful implementation to ensure the native handle wasn't accessed after the atomic check but before the native call.

The third option was to remove explicit end() calls entirely and rely solely on the Cleaner for resource management. This eliminated the race condition completely but introduced unpredictability in native memory release timing, potentially causing severe memory pressure during garbage collection pauses if the GC cycles lagged behind the allocation rate of native structures.

The team selected the AtomicBoolean approach (Solution 2) because it provided deterministic immediate cleanup when possible (explicit call) while ensuring safety if the cleaner ran later. They modified the wrapper class to implement AutoCloseable, ensuring that the atomic state check protected the native deallocation. This resolved the crashes completely while maintaining the required throughput, eliminating native memory related crashes in production.

What candidates often miss

**How does the Cleaner API prevent the object resurrection problem inherent to Object.finalize()?

In Object.finalize(), the object is still reachable when the finalize() method executes because the this reference remains valid, allowing the object to resurrect itself by storing a reference to itself in a static field. This resurrection delays garbage collection indefinitely if the object repeatedly resurrects. The Cleaner API prevents this by using PhantomReference. When the Cleaner's cleanup action runs, the referent (the object being cleaned) is already in the phantom reachable state, meaning it cannot be resurrected because no strong, soft, or weak references exist to it. The cleanup action is a separate Runnable, not a method on the object itself, ensuring the object remains unreachable throughout the entire cleanup process.

Why is Thread.interrupt() ineffective for stopping a Cleaner thread during JVM shutdown, and what are the implications?

The Cleaner thread is a daemon thread that continuously blocks on ReferenceQueue.remove(), waiting for phantom references to become available. While ReferenceQueue.remove() responds to interrupts by throwing InterruptedException, the Cleaner implementation catches this exception and continues its infinite loop, effectively ignoring interrupts. This design ensures that critical resource cleanup completes even during shutdown sequences. However, if a registered cleanup action hangs indefinitely (e.g., waiting on a network timeout or stuck in an infinite loop), the Cleaner thread will never terminate. This can prevent the JVM from shutting down gracefully if other non-daemon threads are waiting for resources that the cleaner is supposed to release.

What catastrophic memory leak occurs if a Cleaner's cleanup action captures a strong reference to the object being cleaned?

If the Runnable passed to Cleaner.register() captures a strong reference to the object (e.g., via this::cleanupMethod or a lambda referencing this), it creates a fatal reference cycle. The Cleaner maintains an internal set of Cleanable objects, each holding a reference to the cleanup Runnable. If that Runnable references the original object, the object remains strongly reachable from the Cleaner thread itself. Consequently, the object never becomes phantom reachable, the PhantomReference never enters the queue, and the cleanup action never runs. Meanwhile, the object cannot be garbage collected, resulting in a severe memory leak that grows unboundedly with every object registered to the Cleaner, eventually causing OutOfMemoryError.