☕ Java

finalize()

The finalize() method was a mechanism for performing cleanup on objects before they were garbage collected. It was deprecated in Java 9, deprecated for removal in Java 18, and removed entirely in Java 21. Understanding why finalize() was designed, why it was deprecated, what is wrong with it, and what replaces it is essential both for maintaining legacy code that uses it and for writing correct cleanup code going forward. This entry covers finalize() mechanics, its fundamental problems, why it was removed, and the correct modern alternatives: try-with-resources, Cleaner, and PhantomReference.

How finalize() Worked and Why It Was Designed

The finalize() method was defined on java.lang.Object and could be overridden by any class. When the garbage collector determined that an object with a non-trivial finalize() method was unreachable, it did not immediately collect the object. Instead, it placed the object on a finalisation queue and made it "finalisation eligible." A dedicated finaliser thread processed the queue, calling finalize() on each object. After finalize() returned, the object became eligible for actual collection on the next GC cycle. The design intent was to allow cleanup of non-memory resources — native handles, file descriptors, sockets — when an object was collected. The idea was that if a developer forgot to call close(), finalize() would be a safety net. This intent was understandable: C++ destructors provided similar cleanup guarantees, and Java's GC seemed like a natural place to add comparable finalisation. The reality was that finalize() was unsafe, unpredictable, and harmful in every non-trivial use. The problems were numerous and fundamental, not incidental. The JVM makes no guarantee about when or even whether finalize() will be called. Under no circumstances should finalize() be relied upon for correctness. It should be treated as if it does not exist in modern Java code.
Java
// ── How finalize() worked (historical — removed in Java 21) ──────────
@Deprecated(since="9", forRemoval=true)  // deprecated in Java 9
public class LegacyResource {

    private long nativeHandle;

    public LegacyResource() {
        this.nativeHandle = allocateNativeResource();
    }

    // Called by the JVM finaliser thread at some undefined future time
    // (or possibly never, if the JVM exits)
    @Override
    protected void finalize() throws Throwable {
        try {
            if (nativeHandle != 0) {
                releaseNativeResource(nativeHandle);
                nativeHandle = 0;
            }
        } finally {
            super.finalize();
        }
    }

    private native long allocateNativeResource();
    private native void releaseNativeResource(long handle);
}

// ── Why finalize() was fundamentally broken ───────────────────────────
//
// Problem 1: Non-deterministic timing
//   Object becomes unreachable → GC discovers → added to finaliser queue
//   Finaliser thread processes queue → finalize() called → THEN collectable
//   Time from unreachable to finalize(): milliseconds to NEVER
//   File handles, DB connections held open indefinitely
//
// Problem 2: Objects could be resurrected
//   finalize() can store 'this' in a static field — object becomes reachable again!
//   Object escapes collection in one GC cycle, must reach finalization again
//   finalize() called at most ONCE per object — second unreachable cycle
//   has no safety net at all
//
// Problem 3: No ordering guarantees
//   A's finalize() may see B in an inconsistent state
//   B's finalize() may not have run yet when A's finalize() runs
//   Cannot safely use other finalizable objects from finalize()
//
// Problem 4: Exceptions in finalize() are silently ignored
//   if (condition) throw new RuntimeException("cleanup failed");
//   The exception is swallowed — cleanup partially executed, no notification
//
// Problem 5: Performance impact
//   Objects with non-trivial finalize() require an extra GC cycle to collect
//   (survive one cycle to be queued, survive another to be actually collected)
//   Long-lived objects with finalize() accumulate in old gen
//   Finaliser thread can become a bottleneck under heavy allocation

Problems with finalize() — Why It Was Removed

Finalize() had problems severe enough to justify its removal from the language — an unusual step for Java, which has extremely high backward compatibility standards. The resurrection problem was particularly insidious. The finalize() method could store the this reference somewhere reachable, making the object reachable again. This "resurrection" prevented collection, and because finalize() is only called once per object, a resurrected object that later became unreachable again would be collected without any finalisation. This meant that the safety-net guarantee was false: finalize() might not run even for a finalisable object. The security problem in Java's serialisation and class loading code was significant enough to drive the deprecation. Hostile subclasses could override finalize() to execute code after an object was supposed to have been cleaned up — allowing privilege escalation through the finalisation mechanism. This was fixed in various ways over Java versions but remained a design-level vulnerability. The performance problem was quantifiable: any class with a non-trivial finalize() method required two GC cycles to collect instead of one. Objects were quarantined in the finalisation queue after the first cycle, then collected after finalize() ran. Under high allocation rates, the finaliser thread could not keep up with the queue, causing promotion to old generation, increasing GC pressure, and ultimately contributing to OutOfMemoryError in extreme cases. JEP 421 measured meaningful GC performance improvements in real applications after removing finalisation.
Java
// ── Demonstration of finalize() problems ────────────────────────────

// Problem: Resurrection — finalize() saves 'this', preventing collection
public class ResurrectionExample {

    static ResurrectionExample lastInstance;

    @Override
    protected void finalize() {
        System.out.println("finalize() called — but I'm escaping!");
        lastInstance = this;   // RESURRECTION — object becomes reachable again
        // Now the object survives this GC cycle
        // Next time it becomes unreachable, finalize() is NOT called again
        // It will be silently collected — the "safety net" is gone
    }
}

// Problem: Exception swallowed — incomplete cleanup with no notification
public class SilentFailure {

    @Override
    protected void finalize() {
        System.out.println("Starting cleanup...");
        throw new RuntimeException("Cleanup failed!");
        // This exception is SILENTLY SWALLOWED by the JVM
        // Cleanup is incomplete, but no one is notified
        System.out.println("This never runs");
    }
}

// Problem: Performance — two GC cycles to collect finalizable objects
// Normal object:           unreachable → COLLECTED (1 cycle)
// Finalizable object:      unreachable → queued → finalize() → COLLECTED (2 cycles)
//
// In HotSpot, finalizable objects:
// - Cannot be collected in the same young GC they become unreachable
// - Must survive to be placed on the finaliser queue
// - Survive long enough to be promoted to old gen frequently
// - Old gen collection is much more expensive than young gen

// ── Why explicitly removed in Java 21 ─────────────────────────────────
// JEP 421: Deprecate Finalization for Removal (Java 18)
// Reasoning:
// 1. Replacement mechanisms (try-with-resources, Cleaner) have existed since Java 7/9
// 2. finalize() creates real security vulnerabilities in serialisation
// 3. Performance cost measurable and significant
// 4. Almost no legitimate use cases exist that cannot be better served by alternatives
// 5. Strong backward compatibility commitment delayed removal for 10+ years

Modern Alternatives — Cleaner and PhantomReference

The Cleaner class (java.lang.ref.Cleaner, introduced in Java 9) is the official replacement for finalize() for the rare case where post-GC cleanup of native resources is genuinely needed. Cleaner uses PhantomReference internally and provides a correct, race-free mechanism for running cleanup actions when objects become unreachable. The key design principle of Cleaner: the cleanup action must be a separate Runnable that does not hold a reference to the object being cleaned. If the cleanup action held a reference to the object, the object would be permanently reachable through the cleanup action's enclosing reference and could never be collected. The cleanup state must be held in a separate static inner class or a dedicated state object that contains only the resources to be cleaned, not a reference to the cleanable object itself. Cleaner cleanup actions are called from the Cleaner's dedicated thread when the associated object becomes phantom-reachable (after GC determines it is unreachable and processes the phantom reference). Unlike finalize(), Cleaner provides: prompt cleanup (the phantom reference is enqueued promptly by the GC), thread safety (the Cleaner thread is separate from GC threads), correct handling of exceptions (errors in cleanup are logged but do not affect GC), and no resurrection risk (PhantomReference cannot be used to resurrect objects). Critically, Cleaner is a last resort, not the first choice. The first choice is always explicit resource management with try-with-resources. Cleaner is only appropriate when a class wraps native resources (memory, file descriptors, OS handles) and cannot guarantee that callers will always call close().
Java
// ── Cleaner — the modern replacement for finalize() ─────────────────
import java.lang.ref.Cleaner;

public class NativeResourceWrapper implements AutoCloseable {

    // One Cleaner per application or library — shared, thread-safe
    private static final Cleaner CLEANER = Cleaner.create();

    // ── State class — holds only the native resource info ─────────────
    // CRITICAL: must be static (or non-inner) — must NOT hold a reference
    // to NativeResourceWrapper, or the wrapper could never be collected
    private static class CleanupState implements Runnable {
        private long nativeHandle;

        CleanupState(long handle) {
            this.nativeHandle = handle;
        }

        @Override
        public void run() {
            // Called by Cleaner thread when NativeResourceWrapper is GC'd
            // OR when clean() is called explicitly
            if (nativeHandle != 0) {
                System.out.println("Cleaning up handle: " + nativeHandle);
                releaseNativeHandle(nativeHandle);
                nativeHandle = 0;
            }
        }

        private static native void releaseNativeHandle(long handle);
    }

    private final long              nativeHandle;
    private final Cleaner.Cleanable cleanable;

    public NativeResourceWrapper() {
        this.nativeHandle = allocateNativeHandle();
        CleanupState state = new CleanupState(nativeHandle);
        // Register: when THIS object becomes unreachable, run state.run()
        this.cleanable = CLEANER.register(this, state);
        // Note: state must not reference 'this' — no reference to NativeResourceWrapper
    }

    public void doWork() {
        // ... use nativeHandle ...
    }

    @Override
    public void close() {
        cleanable.clean();   // explicit cleanup — also cancels the GC-triggered cleanup
    }

    private static native long allocateNativeHandle();
}

// ── Usage — try-with-resources is ALWAYS preferred ─────────────────────
try (NativeResourceWrapper resource = new NativeResourceWrapper()) {
    resource.doWork();
}   // close() called → clean() called → immediate cleanup

// Cleaner is the safety net if caller forgets close():
NativeResourceWrapper leakedResource = new NativeResourceWrapper();
leakedResource = null;   // no close() called
// GC eventually detects unreachable → phantom reference enqueued
// → Cleaner thread calls CleanupState.run() → native handle freed
// (timing not guaranteed, but will happen before OOM)

Related Topics in Java Memory Management

Garbage Collection
Garbage collection is the automatic process by which the JVM reclaims memory occupied by objects that are no longer reachable from the running program. It is one of Java's most defining features — eliminating the manual memory management required in C and C++ and the entire class of bugs that comes with it: use-after-free, double-free, and memory leaks caused by forgotten deallocation. Understanding garbage collection means understanding reachability, GC roots, the collection process, what the GC guarantees and what it does not, how to work with it rather than against it, and how to diagnose and resolve GC-related performance problems. This entry covers the reachability model, GC triggers, what GC does not collect, finalization, the GC performance trade-off triangle, and practical guidance for writing GC-friendly code.
GC Algorithms
Garbage collection algorithms are the strategies the JVM uses to identify and reclaim unreachable objects. Different algorithms make different trade-offs between throughput, latency, and memory overhead. The foundational algorithms are mark-sweep, mark-compact, and copying — each with distinct properties that make them suitable for different parts of the heap and different application requirements. Modern production collectors (G1GC, ZGC, Shenandoah) combine these algorithms with concurrency, parallelism, and incremental collection to achieve the best overall performance. This entry covers the core algorithms in depth, their correctness properties, performance characteristics, why generational collection is effective, and how the major production collectors implement these algorithms.
Memory Leak
A memory leak in Java occurs when objects that are no longer needed by the application remain reachable through strong references, preventing the garbage collector from reclaiming their memory. Unlike C or C++, Java cannot leak memory through forgotten deallocation — the GC handles that. Java leaks memory through forgotten references: objects that the application logic no longer needs but that are still reachable through data structures, caches, listeners, or static fields. Memory leaks manifest as growing heap usage, increasing GC frequency and duration, and ultimately OutOfMemoryError. This entry covers every major leak pattern in Java, how to detect and diagnose leaks, how to fix each pattern, and tools for prevention.
Reference Types (Weak, Soft, Phantom)
Java provides four reference strengths beyond the ordinary strong reference: strong (the default), soft, weak, and phantom. Each has a different policy for when the garbage collector is permitted to reclaim the referenced object. Soft references are collected when the JVM is low on memory — ideal for memory-sensitive caches. Weak references are collected at the next GC cycle — ideal for canonicalising maps and listeners. Phantom references are enqueued after the object is finalised but before its memory is reclaimed — ideal for post-collection cleanup actions. Understanding these reference types enables building caches that do not cause OutOfMemoryError, listeners that automatically deregister themselves, and deterministic cleanup of native resources. This entry covers all four strengths with their precise semantics, the ReferenceQueue mechanism, and the canonical use case for each type.