Understanding Object Finalization and Garbage Collection in .NET

The Finalization Misconception

Myth: Finalizers are like destructors in C++, and you need them to clean up resources properly. Reality: Finalizers run non-deterministically on a separate thread, slow down garbage collection, and should almost never be used in modern .NET code.

The Finalize method gives you one last chance to release unmanaged resources before the garbage collector reclaims an object's memory. Unlike C++ destructors that run immediately when objects go out of scope, finalizers run at an unpredictable time during garbage collection. This non-determinism makes them unreliable for timely cleanup of file handles, database connections, or other scarce resources.

You'll learn how finalizers actually work in the .NET garbage collector, why they hurt performance, when you absolutely must use them, and why the IDisposable pattern is the correct choice for nearly all resource cleanup scenarios. We'll build examples that demonstrate the pitfalls of finalization and show you the right patterns for managing resources.

How Finalization Works in the Garbage Collector

When you create an object with a finalizer, the garbage collector adds it to a special finalization queue. This happens at allocation time, before your constructor even runs. Objects in this queue get special treatment during garbage collection—they can't be reclaimed immediately even when they're unreachable.

During a GC cycle, when the collector finds an unreachable object with a finalizer, it moves that object to the freachable queue instead of releasing its memory. A dedicated finalizer thread processes this queue, calling each object's Finalize method. Only after finalization completes can the next garbage collection actually reclaim the memory. This means finalizable objects always survive at least one extra GC generation, increasing memory pressure.

FinalizerExample.cs
public class UnmanagedResource
{
    private IntPtr nativeHandle;
    private bool disposed = false;

    public UnmanagedResource()
    {
        nativeHandle = AllocateNativeMemory(1024);
        Console.WriteLine($"Resource allocated: {nativeHandle}");
    }

    // Finalizer syntax using destructor notation
    ~UnmanagedResource()
    {
        Console.WriteLine($"Finalizer running for {nativeHandle}");
        Cleanup(false);
    }

    private void Cleanup(bool disposing)
    {
        if (!disposed)
        {
            if (disposing)
            {
                // Dispose managed resources here
                Console.WriteLine("Disposing managed resources");
            }

            // Always release unmanaged resources
            if (nativeHandle != IntPtr.Zero)
            {
                FreeNativeMemory(nativeHandle);
                Console.WriteLine($"Released native memory: {nativeHandle}");
                nativeHandle = IntPtr.Zero;
            }

            disposed = true;
        }
    }

    private IntPtr AllocateNativeMemory(int size)
    {
        // Simulates native memory allocation
        return new IntPtr(new Random().Next(1000, 9999));
    }

    private void FreeNativeMemory(IntPtr handle)
    {
        // Simulates native memory deallocation
    }
}

The destructor syntax ~UnmanagedResource() is actually syntactic sugar for overriding the protected Finalize method. The compiler automatically generates the proper Finalize override with exception handling. You never call finalizers directly—only the garbage collector invokes them.

Why Finalizers Cause Problems

Finalizers introduce several serious issues that make them unsuitable for most cleanup scenarios. Understanding these problems helps you appreciate why IDisposable exists and why the .NET Framework team recommends avoiding finalizers whenever possible.

Non-deterministic timing: You cannot predict when a finalizer runs. It might execute immediately after an object becomes unreachable, or it might wait until your application terminates. If you're holding a database connection or file handle, this delay can exhaust system resources long before cleanup happens.

Performance penalties: Objects with finalizers cost more than normal objects. They require extra bookkeeping during allocation, survive additional GC generations, and force the finalizer thread to process them. In high-throughput applications, even a small percentage of finalizable objects can measurably degrade performance.

FinalizationIssues.cs
public class ProblematicFinalizer
{
    private FileStream fileStream;

    public ProblematicFinalizer(string path)
    {
        fileStream = File.OpenWrite(path);
    }

    ~ProblematicFinalizer()
    {
        // PROBLEM 1: This might not run for seconds or minutes
        // The file stays locked until then
        fileStream?.Dispose();

        // PROBLEM 2: You can't access other managed objects safely
        // They might already be finalized
        // var logger = GetLogger(); // DANGEROUS!

        // PROBLEM 3: Exceptions in finalizers are swallowed
        // You won't see errors unless you're debugging
        throw new Exception("This vanishes silently");
    }
}

// Demonstration of the resource leak problem
void LeakingFileHandles()
{
    for (int i = 0; i < 1000; i++)
    {
        var obj = new ProblematicFinalizer($"temp_{i}.txt");
        // Oops! Never disposed, files stay locked until finalization
    }

    // Files remain open until GC runs and finalizer thread processes them
    // Could be seconds or minutes later
    Console.WriteLine("Created 1000 file handles");
}

The code above demonstrates the resource leak problem. Even though we created 1000 file handles, they remain open until the garbage collector decides to run and the finalizer thread processes each object. If the system runs out of file handles before that happens, your application crashes with no indication that finalizers were involved.

The IDisposable Pattern: The Right Way

IDisposable provides deterministic cleanup through explicit Dispose calls. Unlike finalizers, Dispose runs immediately when you call it, releasing resources at predictable times. The using statement automates this pattern, ensuring disposal even if exceptions occur.

The standard dispose pattern combines IDisposable for deterministic cleanup with a finalizer as a safety net. If consumers forget to dispose, the finalizer eventually cleans up unmanaged resources. Most importantly, calling Dispose suppresses finalization, avoiding the performance penalty when objects are disposed properly.

ProperDisposable.cs
public class ManagedAndUnmanagedResource : IDisposable
{
    private IntPtr unmanagedHandle;
    private FileStream managedStream;
    private bool disposed = false;

    public ManagedAndUnmanagedResource(string filePath)
    {
        unmanagedHandle = AllocateNativeMemory(4096);
        managedStream = File.OpenWrite(filePath);
    }

    // Public Dispose method - call this explicitly
    public void Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this); // Prevent finalizer from running
    }

    // Protected virtual method for inheritance support
    protected virtual void Dispose(bool disposing)
    {
        if (!disposed)
        {
            if (disposing)
            {
                // Dispose managed resources
                managedStream?.Dispose();
                Console.WriteLine("Managed resources disposed");
            }

            // Always clean up unmanaged resources
            if (unmanagedHandle != IntPtr.Zero)
            {
                FreeNativeMemory(unmanagedHandle);
                unmanagedHandle = IntPtr.Zero;
                Console.WriteLine("Unmanaged resources freed");
            }

            disposed = true;
        }
    }

    // Finalizer as safety net - only runs if Dispose wasn't called
    ~ManagedAndUnmanagedResource()
    {
        Console.WriteLine("WARNING: Finalizer ran - Dispose was not called!");
        Dispose(disposing: false);
    }

    private IntPtr AllocateNativeMemory(int size) =>
        new IntPtr(new Random().Next(1000, 9999));
    private void FreeNativeMemory(IntPtr handle) { }
}

// Correct usage with deterministic cleanup
void CorrectUsage()
{
    using (var resource = new ManagedAndUnmanagedResource("data.bin"))
    {
        // Use the resource
    } // Dispose called automatically here

    // Or with C# 8+ using declaration
    using var resource2 = new ManagedAndUnmanagedResource("data2.bin");
    // Dispose called automatically at end of scope
}

The Dispose(bool disposing) method uses a flag to distinguish between explicit disposal and finalization. When disposing is true, you can safely clean up both managed and unmanaged resources. When false (called from finalizer), you must only touch unmanaged resources because managed objects might already be finalized. The GC.SuppressFinalize call tells the garbage collector to skip finalization, dramatically improving performance.

When Finalizers Are Actually Needed

You need a finalizer only when your class directly owns unmanaged resources and you want a safety net for when consumers forget to call Dispose. This scenario is rare in modern .NET development. Most classes work with managed objects or use SafeHandle wrappers that already have finalizers.

Classes like FileStream, Socket, and database connection classes implement finalizers because they directly manage operating system resources. Your application code typically uses these classes rather than implementing its own low-level resource management. If you're wrapping a native library with P/Invoke, you might need finalizers—but even then, SafeHandle is usually better.

SafeHandleExample.cs
using Microsoft.Win32.SafeHandles;
using System.Runtime.InteropServices;

// SafeHandle already has a finalizer - you don't need your own
public class NativeFileWrapper : IDisposable
{
    private SafeFileHandle handle;
    private bool disposed = false;

    public NativeFileWrapper(string path)
    {
        handle = CreateFile(
            path,
            0x40000000, // GENERIC_WRITE
            0,
            IntPtr.Zero,
            2, // CREATE_ALWAYS
            0,
            IntPtr.Zero);

        if (handle.IsInvalid)
            throw new IOException($"Failed to create {path}");
    }

    public void Write(byte[] data)
    {
        if (disposed) throw new ObjectDisposedException(nameof(NativeFileWrapper));
        // Use handle.DangerousGetHandle() for P/Invoke calls
    }

    public void Dispose()
    {
        if (!disposed)
        {
            handle?.Dispose(); // SafeHandle's Dispose handles everything
            disposed = true;
        }
    }

    // NO FINALIZER NEEDED - SafeFileHandle already has one

    [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
    private static extern SafeFileHandle CreateFile(
        string lpFileName,
        uint dwDesiredAccess,
        uint dwShareMode,
        IntPtr lpSecurityAttributes,
        uint dwCreationDisposition,
        uint dwFlagsAndAttributes,
        IntPtr hTemplateFile);
}

SafeHandle-derived classes like SafeFileHandle handle finalization correctly and provide critical finalization guarantees. They ensure the handle gets released even if your code fails to dispose. By using SafeHandle, you get robust resource management without writing your own finalizer.

Try It Yourself

Create a program that demonstrates the difference between finalizers and explicit disposal. You'll see when each runs and understand why deterministic cleanup matters.

Program.cs
class ResourceWithFinalizer : IDisposable
{
    private readonly int id;
    private bool disposed = false;

    public ResourceWithFinalizer(int resourceId)
    {
        id = resourceId;
        Console.WriteLine($"[{id}] Resource created");
    }

    public void Dispose()
    {
        Console.WriteLine($"[{id}] Dispose() called");
        if (!disposed)
        {
            GC.SuppressFinalize(this);
            disposed = true;
        }
    }

    ~ResourceWithFinalizer()
    {
        Console.WriteLine($"[{id}] Finalizer running - Dispose was NOT called!");
    }
}

Console.WriteLine("=== Test 1: Proper disposal ===");
using (var r1 = new ResourceWithFinalizer(1))
{
    Console.WriteLine($"[1] Using resource");
}
Console.WriteLine("After using block\n");

Console.WriteLine("=== Test 2: No disposal (leak) ===");
var r2 = new ResourceWithFinalizer(2);
r2 = null; // Leaked - never disposed
Console.WriteLine("After leak\n");

Console.WriteLine("=== Forcing GC ===");
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Console.WriteLine("After GC");
FinalizerDemo.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

Steps:

  1. Create project: dotnet new console -n FinalizerDemo
  2. Navigate: cd FinalizerDemo
  3. Replace Program.cs with the code above
  4. Execute: dotnet run

What you'll see:

=== Test 1: Proper disposal ===
[1] Resource created
[1] Using resource
[1] Dispose() called
After using block

=== Test 2: No disposal (leak) ===
[2] Resource created
After leak

=== Forcing GC ===
[2] Finalizer running - Dispose was NOT called!
After GC

Resource 1 is disposed immediately when the using block exits, while resource 2's finalizer runs only when garbage collection happens. The warning message for resource 2 demonstrates the finalizer safety net—it runs because we forgot to call Dispose, but it runs much later than when the object became unreachable.

Knowing the Limits

Don't add finalizers to classes that only manage managed resources. If your class wraps a FileStream, DbConnection, or other IDisposable objects, your Dispose method should dispose them. Adding a finalizer adds overhead without benefit because those objects already have their own finalizers.

Avoid finalizers in high-allocation scenarios. If you create thousands of objects per second, even well-implemented finalizers create GC pressure and finalization queue bottlenecks. Profile your application under load—you might discover finalizers are causing unexpected pause times or memory bloat.

Never use finalizers for critical cleanup logic. Finalizers might not run at all if the process terminates abnormally or during AppDomain unload in some scenarios. For truly critical operations like committing transactions or saving state, use explicit Dispose calls or application shutdown hooks.

If you find yourself writing a finalizer, step back and ask whether SafeHandle, IDisposable alone, or a redesign might be better. The vast majority of .NET classes never need finalizers. When you do need one, ensure you're implementing the full dispose pattern correctly with GC.SuppressFinalize to avoid performance penalties.

Common Questions

Should I use finalizers or IDisposable for cleanup?

Use IDisposable for deterministic cleanup. Call Dispose explicitly or use using statements. Add a finalizer only as a safety net for when consumers forget to dispose. Most classes need IDisposable; very few need finalizers.

When exactly does the finalizer run?

Finalizers run on a dedicated thread after garbage collection detects an unreachable object. Timing is non-deterministic—it might happen immediately or minutes later. You cannot predict or control when finalization occurs.

What's the performance cost of finalizers?

Finalizers force objects to survive an extra GC generation, increasing memory pressure. The finalization queue adds overhead, and the finalizer thread can become a bottleneck. Avoid finalizers unless absolutely necessary for unmanaged resource cleanup.

Back to Articles