Purpose of Finalizers in C# Classes

This article explains the purpose of finalizers in C#, detailing how they allow objects to perform cleanup operations on unmanaged resources before the Garbage Collector reclaims their memory. It covers the internal mechanics of the finalization process, the performance implications of using finalizers, and the best-practice implementation using the standard Dispose pattern.


What Is a Finalizer?

A finalizer (historically referred to as a destructor) is a class-level method declared using a tilde (~) prefix followed by the class name. It cannot take parameters, cannot have access modifiers, and cannot be invoked explicitly from application code.

class NativeResourceWrapper
{
    ~NativeResourceWrapper()
    {
        // Cleanup code for unmanaged resources
    }
}

Behind the scenes, the C# compiler converts this syntax into an override of the base System.Object.Finalize method, wrapping the body inside a try...finally block that calls base.Finalize().

The Primary Purpose: Releasing Unmanaged Resources

The primary purpose of a finalizer is to provide a safety net for releasing unmanaged resources before an object is permanently removed from memory.

Managed memory—such as standard C# objects, arrays, and strings—is handled automatically by the .NET Garbage Collector (GC). However, the GC has no awareness of unmanaged system resources, which include:

  • Operating system file handles
  • Window handles (HWND)
  • Native memory allocations (e.g., allocations via Marshal.AllocHGlobal)
  • Direct hardware contexts or external database connections

If a managed object encapsulating an unmanaged resource loses all references without releasing that resource, an operating system resource leak occurs. A finalizer ensures that when the GC eventually reclaims the managed wrapper, the underlying unmanaged resource is closed or freed.

How Finalizers Work Under the Hood

Finalizers do not run immediately when an object goes out of scope; their execution is non-deterministic. The lifecycle proceeds as follows:

  1. Object Registration: When an object with a finalizer is instantiated, a pointer to it is placed on an internal GC structure called the Finalization Queue.
  2. First GC Pass: When the GC runs and detects that the object is no longer reachable, it does not immediately free its memory. Instead, it moves the reference to the F-Reachable Queue (finalization-reachable queue).
  3. Generation Promotion: Because the object is referenced by the F-Reachable queue, it survives the current collection and is promoted to the next GC generation (e.g., Generation 0 to Generation 1), delaying memory reclamation.
  4. Finalizer Thread Execution: A dedicated, low-priority runtime thread sequentially processes the F-Reachable queue, invoking each object's finalizer.
  5. Final GC Pass: Once the finalizer has executed, the object becomes eligible for actual memory collection in a subsequent GC cycle.

Performance Costs and Caveats

Finalizers should be used sparingly because they introduce measurable runtime overhead:

  • Delayed Memory Reclamation: Finalized objects survive at least one extra GC collection cycle, extending their lifetime and increasing memory pressure.
  • Thread Contention: All finalizers execute sequentially on a single finalizer thread. A blocking operation or exception within a finalizer can stall cleanup for all other pending objects.
  • Non-Deterministic Timing: You cannot predict when a finalizer will run, making them unsuitable for time-sensitive cleanup operations.

Best Practice: The Dispose Pattern and SafeHandle

Modern C# development treats finalizers strictly as a fallback mechanism rather than the primary method of cleanup. Instead, classes managing unmanaged resources should implement the IDisposable interface to allow callers to perform deterministic cleanup via the using statement.

When implementing both, use GC.SuppressFinalize to prevent the finalizer from running if the consumer already called Dispose():

public class ResourceHolder : IDisposable
{
    private IntPtr _unmanagedResource;
    private bool _disposed;

    public ResourceHolder()
    {
        _unmanagedResource = /* allocate native resource */;
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this); // Skips the costly finalization queue
    }

    protected virtual void Dispose(bool disposing)
    {
        if (!_disposed)
        {
            if (disposing)
            {
                // Free managed objects
            }

            // Free unmanaged resources
            if (_unmanagedResource != IntPtr.Zero)
            {
                // NativeMethods.CloseHandle(_unmanagedResource);
                _unmanagedResource = IntPtr.Zero;
            }

            _disposed = true;
        }
    }

    ~ResourceHolder()
    {
        // Executes only if Dispose() was not called
        Dispose(false);
    }
}

In modern .NET applications, the need to write custom finalizers is almost entirely eliminated by using derived implementations of System.Runtime.InteropServices.SafeHandle, which encapsulate native handle lifetimes safely and efficiently.