Loading content...
Every garbage-collected language offers some form of finalizer—a special method that runs when the garbage collector reclaims an object. To developers coming from C++ (where destructors are deterministic and reliable), finalizers look like the perfect solution for resource cleanup: define the cleanup logic once, and the runtime ensures it runs automatically.
This is a trap. Finalizers are fundamentally broken for resource management. They run at unpredictable times (or never), on arbitrary threads, with no access to the original execution context. Relying on them for closing connections, releasing handles, or cleaning up external resources is a guaranteed path to production incidents.
By the end of this page, you will understand what finalizers actually are, why they fail for resource management, the specific problems they create, and when (if ever) they should be used. You'll learn to recognize finalizer anti-patterns and understand why modern language design has moved away from them.
A finalizer (also called a destructor in some languages, though this is misleading) is a special method that the garbage collector invokes before reclaiming an object's memory. The intent is to give objects a 'last chance' to perform cleanup.
Terminology Across Languages:
| Language | Syntax | When Called | Guaranteed? |
|---|---|---|---|
| Java | finalize() method (deprecated Java 9+) | Before GC reclaims object | No - may never run |
| C# | ~ClassName() destructor | Before GC reclaims object | No - may not complete before process exit |
| Python | __del__() method | When reference count hits zero OR GC runs | No - problematic with cycles |
| JavaScript | FinalizationRegistry (ES2021) | After object is GC'd | No - explicitly not guaranteed |
| Go | runtime.SetFinalizer() | Before GC reclaims object | No - SetFinalizer docs warn against use |
The key insight: Every language's documentation warns against relying on finalizers for resource management. This isn't overcautious advice—it reflects fundamental limitations in how garbage collection works.
How Finalization Works (Java/.NET):
This means finalized objects require at least two GC cycles to be reclaimed, and the finalizer runs on a completely different thread with no relation to the original code.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
// Java finalize() - DEPRECATED since Java 9 public class ConnectionWrapper { private Connection connection; public ConnectionWrapper(String url) throws SQLException { this.connection = DriverManager.getConnection(url); } public ResultSet query(String sql) throws SQLException { return connection.createStatement().executeQuery(sql); } // This looks like it would work, but it doesn't reliably @Override protected void finalize() throws Throwable { try { if (connection != null && !connection.isClosed()) { System.out.println("Finalizer running - closing connection"); connection.close(); } } finally { super.finalize(); } }} // Test that shows the problem:public class FinalizerDemo { public static void main(String[] args) throws Exception { // Create 100 connections for (int i = 0; i < 100; i++) { ConnectionWrapper wrapper = new ConnectionWrapper("jdbc:..."); wrapper.query("SELECT 1"); // wrapper goes out of scope, eligible for GC } // At this point, we've created 100 connections // How many are closed? Unknown! // Depends on whether GC ran and processed finalizers // Try to force it System.gc(); // This is a HINT, not a command Thread.sleep(1000); // Maybe finalizers run? // Still no guarantee all connections are closed // If DB has max_connections = 50, we probably crashed by loop 50 }}Finalizers suffer from a constellation of interrelated problems that make them unsuitable for reliable resource management. Let's examine each in detail.
Problems 6-9:
| Problem | Description | Consequence |
|---|---|---|
| 6. Object Resurrection | Finalizers can re-register the object with a live reference, preventing collection | Memory leaks, zombie objects, undefined behavior |
| 7. Order Undefined | Multiple finalizable objects collected together have no defined finalization order | Dependencies between finalized objects may be violated |
| 8. Dead References | By the time finalizer runs, objects this object references may already be finalized/collected | Attempting to use them causes NullPointerException or undefined behavior |
| 9. Security Vulnerabilities | Finalizers can be exploited to resurrect partially-constructed objects | Object escapes constructor exception, exposing incomplete state |
One of the most insidious finalizer problems is object resurrection. A finalizer can make a 'dead' object alive again by storing a reference to it somewhere reachable. This creates zombie objects that violate normal lifecycle expectations.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
// Object resurrection - the object escapes its own death public class ZombieObject { private static final Set<ZombieObject> zombies = new HashSet<>(); private boolean isZombie = false; private final String name; public ZombieObject(String name) { this.name = name; System.out.println(name + " created"); } @Override protected void finalize() throws Throwable { System.out.println(name + " being finalized..."); // RESURRECTION: Object saves itself! zombies.add(this); isZombie = true; System.out.println(name + " has been resurrected as zombie!"); } public void doSomething() { if (isZombie) { System.out.println(name + " doing zombie things (should this work?)"); } else { System.out.println(name + " doing normal things"); } }} public class ResurrectionDemo { public static void main(String[] args) throws Exception { // Create object ZombieObject obj = new ZombieObject("Object1"); obj.doSomething(); // "Object1 doing normal things" // Make it unreachable obj = null; // Force GC - finalizer runs, object resurrects itself System.gc(); Thread.sleep(1000); // Object is alive again in the zombie set! for (ZombieObject zombie : ZombieObject.zombies) { zombie.doSomething(); // Still works! "Object1 doing zombie things..." } // Now clear the zombie set ZombieObject.zombies.clear(); System.gc(); Thread.sleep(1000); // The finalizer only runs ONCE // So this time, the object is collected with NO finalization // If it held resources, they're leaked }} /*Output:Object1 createdObject1 doing normal thingsObject1 being finalized...Object1 has been resurrected as zombie!Object1 doing zombie things (should this work?)*/Object resurrection creates a security vulnerability: if a constructor throws an exception after setting 'this' somewhere accessible (or via finalizer resurrection), attackers can access a partially-constructed object. This can bypass security checks that would normally run later in construction. Java had CVE-2008-5353 partly due to this, and similar vulnerabilities have been found in other platforms.
The Double-Finalization Problem:
When an object is resurrected, its finalizer has already run. If the object becomes unreachable again, the finalizer does not run a second time (in most implementations). This means:
This is why resurrecting objects is considered a critical anti-pattern, not just a code smell.
Finalizer problems multiply under high load, creating feedback loops that can bring systems down. This is particularly insidious because finalizer-based code often works fine in testing (low load) and fails catastrophically in production (high load).
The Load Feedback Loop:
The cruel irony: under high load (when resource management matters most), finalizers become less reliable, not more.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
// Simulating finalizer behavior under load class FinalizableConnection { private static finalizationQueue: FinalizableConnection[] = []; private static resourcesInUse = 0; private static MAX_RESOURCES = 100; constructor() { if (FinalizableConnection.resourcesInUse >= FinalizableConnection.MAX_RESOURCES) { throw new Error("Resource exhausted!"); } FinalizableConnection.resourcesInUse++; // Register for "finalization" FinalizableConnection.finalizationQueue.push(this); } // Simulated finalizer that processes slowly static processFinalizationQueue(): void { // Finalizer can only process 10 per "cycle" for (let i = 0; i < 10 && FinalizableConnection.finalizationQueue.length > 0; i++) { FinalizableConnection.finalizationQueue.shift(); FinalizableConnection.resourcesInUse--; } } static stats(): string { return `InUse: ${FinalizableConnection.resourcesInUse}, Queue: ${FinalizableConnection.finalizationQueue.length}`; }} // Simulate loadfunction simulateLoadWithFinalizers(requestsPerSecond: number): void { let tick = 0; let failures = 0; const interval = setInterval(() => { tick++; // Process finalization queue (slow) FinalizableConnection.processFinalizationQueue(); // Handle incoming requests for (let i = 0; i < requestsPerSecond; i++) { try { new FinalizableConnection(); // Connection used and "dropped" immediately } catch (e) { failures++; } } console.log(`Tick ${tick}: ${FinalizableConnection.stats()}, Failures: ${failures}`); if (tick >= 20) clearInterval(interval); }, 1000);} // At 20 req/sec, finalizer processes 10/sec// Net: +10 resources/sec, exhaustion in ~10 secondssimulateLoadWithFinalizers(20); /*Output:Tick 1: InUse: 20, Queue: 20, Failures: 0Tick 2: InUse: 30, Queue: 30, Failures: 0...Tick 9: InUse: 90, Queue: 90, Failures: 0Tick 10: InUse: 100, Queue: 100, Failures: 0Tick 11: InUse: 100, Queue: 110, Failures: 10 <- EXHAUSTIONTick 12: InUse: 100, Queue: 120, Failures: 30 <- GETTING WORSE...*/Each major garbage-collected language has evolved its approach to finalization, and the trend is consistently toward deprecation or discouragement.
Java's Finalization Journey:
finalize() introduced as cleanup mechanismfinalize() officially deprecatedThe Cleaner API (Java 9+):
Java introduced java.lang.ref.Cleaner as a replacement. It's still non-deterministic but safer:
Cleaner cleaner = Cleaner.create();
class Resource {
private static final Cleaner.Cleanable cleanable;
Resource() {
cleanable = cleaner.register(this, () -> {
// Cleanup logic - no 'this' reference!
System.out.println("Cleaning up");
});
}
public void close() {
cleanable.clean(); // Explicit cleanup
}
}
The Cleaner avoids resurrection because the cleanup action cannot reference the object being cleaned.
Despite all their problems, finalizers have a small number of legitimate uses. Understanding these helps clarify what finalizers can't do by contrast.
malloc/new that the GC doesn't know about (last-ditch reclaim)For every 'legitimate' finalizer use, there's usually a better alternative. Native memory can use reference-counting or explicit free. Leak detection can use weak references and periodic scanning. Metrics can use explicit lifecycle hooks. The only truly safe finalizer is one that only logs a warning that it ran at all.
The 'Warning Finalizer' Pattern:
The safest finalizer is one that detects bugs rather than fixing them:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
public sealed class ManagedResource : IDisposable{ private bool _disposed = false; #if DEBUG // Only in debug builds - adds GC overhead private readonly StackTrace _allocationStack; #endif public ManagedResource() { #if DEBUG _allocationStack = new StackTrace(true); #endif } public void Dispose() { if (_disposed) return; _disposed = true; // Actual cleanup ReleaseResources(); // Tell GC we don't need finalization GC.SuppressFinalize(this); } // This finalizer should NEVER run in correct code ~ManagedResource() { // If we get here, Dispose() was not called - this is a BUG Debug.WriteLine("⚠️ RESOURCE LEAK DETECTED: ManagedResource was not disposed!"); #if DEBUG Debug.WriteLine($"Object was allocated at:\n{_allocationStack}"); #endif // Cleanup anyway (best effort) try { ReleaseResources(); } catch { // Swallow - we're in finalizer } } private void ReleaseResources() { // Release native handles, etc. }} // In production logs:// ⚠️ RESOURCE LEAK DETECTED: ManagedResource was not disposed!// Object was allocated at:// at ManagedResource..ctor() in Resource.cs:line 15// at DataProcessor.ProcessItem() in Processor.cs:line 42// at MainController.HandleRequest() in Controller.cs:line 108//// This stack trace tells you exactly where to add using/Dispose()Language designers have learned from finalizer failures and developed better patterns. Here's how modern code handles resource cleanup without finalizers:
| Approach | How It Works | Language Examples |
|---|---|---|
| Deterministic Scope Exit | Cleanup guaranteed when leaving a code block | C++ RAII, Rust Drop, C# using, Python with |
| Explicit Dispose/Close | Caller explicitly calls cleanup method | Java AutoCloseable, .NET IDisposable |
| Reference Counting | Cleanup when count hits zero | Swift ARC, Rust Rc/Arc, Python (primary) |
| Ownership Types | Compiler enforces single owner, ensures cleanup | Rust ownership, C++ unique_ptr |
| Pools/Arenas | Bulk allocation/deallocation | Connection pools, memory arenas |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// Modern TypeScript: Explicit Disposable with 'using' (TS 5.2+) // The Symbol.dispose protocolinterface Disposable { [Symbol.dispose](): void;} interface AsyncDisposable { [Symbol.asyncDispose](): Promise<void>;} class DatabaseConnection implements AsyncDisposable { private _connection: Connection; private _disposed = false; private constructor(connection: Connection) { this._connection = connection; } static async create(url: string): Promise<DatabaseConnection> { const connection = await connect(url); return new DatabaseConnection(connection); } async query(sql: string): Promise<Result> { if (this._disposed) throw new Error("Connection disposed"); return this._connection.execute(sql); } async [Symbol.asyncDispose](): Promise<void> { if (this._disposed) return; this._disposed = true; await this._connection.close(); }} // Usage: 'using' declaration handles cleanupasync function processData(): Promise<void> { await using conn = await DatabaseConnection.create("postgresql://..."); const users = await conn.query("SELECT * FROM users"); // Connection automatically disposed here - deterministic, guaranteed!} // No finalizers needed. No GC dependency. Cleanup at exact line.Let's consolidate the essential lessons about finalizers:
What's Next:
Now that we understand why finalizers fail, we'll learn what works: explicit cleanup methods. These deterministic, predictable, debugging-friendly approaches are how real-world resource management should be done.
You now understand why finalizers are fundamentally unsuitable for reliable resource management. This knowledge protects you from a common trap that causes production incidents. Next, we'll explore explicit cleanup methods—the robust alternative that actually works.