☕ 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 allocationProblems 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+ yearsModern 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
Stack Memory
Stack memory is the region of memory where the JVM stores method invocation frames, local variables, and partial results. Every thread has its own private stack created at thread creation, and the stack grows and shrinks as methods are called and return. Stack memory operates on a last-in, first-out discipline — the frame for the most recently called method sits on top, and when that method returns its frame is immediately discarded. Understanding stack memory explains why local variables are thread-safe by default, why recursive algorithms can cause StackOverflowError, why primitive values behave differently from objects, and what the JVM does at every method call and return. This entry covers stack frame structure, the stack pointer, local variable storage, operand stacks, frame lifecycle, thread isolation, and the performance characteristics that make stack allocation extremely fast.
Heap Memory
The heap is the runtime data area from which all Java object instances and arrays are allocated. It is shared across all threads in a JVM process, grows dynamically up to a configured maximum, and is managed entirely by the garbage collector. Understanding heap memory means understanding how objects are allocated, how the generational hypothesis drives GC design, how the major garbage collectors (G1, ZGC, Shenandoah, Parallel) partition and manage the heap, what triggers garbage collection, how to tune heap size and GC behaviour, and how to diagnose heap-related problems including OutOfMemoryError and excessive GC pause times. This entry covers heap structure, generational design, object allocation, garbage collection triggers, common collectors, heap tuning, and memory leak detection.
Metaspace
Metaspace is the JVM memory region that stores class metadata — the internal representations of loaded classes, methods, fields, constant pools, and annotations. It replaced PermGen (Permanent Generation) in Java 8. Unlike PermGen which was a fixed-size heap region, Metaspace is allocated from native memory (outside the Java heap) and grows dynamically up to an optional maximum. Understanding Metaspace means understanding what class metadata contains, what causes Metaspace to grow, how class unloading reclaims Metaspace, what OutOfMemoryError from Metaspace looks like, how to monitor and limit it, and what the practical implications are for application servers, OSGi containers, and frameworks that generate classes dynamically.
JVM Memory Model
The JVM Memory Model encompasses two related but distinct concepts. The first is the runtime memory architecture — how the JVM partitions memory into regions (stack, heap, Metaspace, code cache, native memory) and what each region stores. The second, more precise meaning is the Java Memory Model (JMM) defined in the Java Language Specification — the formal set of rules that govern how multithreaded programs observe memory: when writes made by one thread become visible to other threads, what ordering guarantees exist, and how volatile, synchronized, and final provide those guarantees. This entry covers both: the complete runtime memory architecture with all regions explained, and the Java Memory Model's happens-before relationship, visibility rules, and the practical consequences for concurrent code.