Back to Blog
C#

Understanding C# Destructors and Finalization

c# destructor: Learn how C# destructors (finalizers) work, when they run, and why IDisposable is usually the better cleanup mechanism.

C#FinalizerIDisposableGarbage CollectionMemory Management
Illustration of a C# destructor symbol with a garbage collector and a clock, representing nondeterministic finalization.

In C#, the term destructor is a legacy name for a finalizer. The syntax is a method with the same name as the class prefixed by a tilde, and it has no parameters, no access modifier, and no return type. A C# destructor is invoked by the garbage collector when it determines that an object is no longer reachable, but the exact timing is nondeterministic. This article explains how finalizers behave, why they are rarely the right tool for resource cleanup, and how they interact with IDisposable and the finalization queue.

Declaring a Finalizer in C#

The syntax for a finalizer is simple, but the semantics are easy to misunderstand. Consider a class that holds an unmanaged resource:

public class UnmanagedResource { private IntPtr _handle; public UnmanagedResource() { _handle = SomeNativeAllocation(); } ~UnmanagedResource() { SomeNativeRelease(_handle); } }

The finalizer is declared with ~ClassName(). It cannot be called directly from your code; only the garbage collector invokes it. The compiler translates the finalizer into an override of Finalize on the Object base class. The exact IL is an override of System.Object.Finalize, but you never write that method directly in C#.

Because the finalizer runs on a dedicated finalizer thread, you must not rely on any thread-specific state inside it. Also, the finalizer may run after the object's fields have already been collected if those fields are themselves finalizable, so you should not access other managed objects from within a finalizer unless you understand the resurrection risk.

When Does the Garbage Collector Run a Finalizer?

A finalizer runs when the garbage collector determines that the object is unreachable. However, the exact moment is not predictable. The GC may run during a low-memory condition, when a generation threshold is hit, or when you explicitly call GC.Collect(). Even then, finalizers are not executed synchronously with collection. Instead, objects with finalizers are placed on the finalization queue, and a separate finalizer thread processes them asynchronously.

This nondeterminism means you cannot rely on a finalizer to release a resource at a specific point in your program. For example, a file handle might remain open long after the object goes out of scope. If your application is short-lived, the finalizer might never run before the process exits. This is why finalizers are considered a safety net, not a primary cleanup mechanism.

The Finalization Queue and the Finalizer Thread

When an object with a finalizer is first allocated, the GC adds a reference to it in the finalization queue. During collection, if the object is unreachable, the GC moves it from the finalization queue to the freachable queue. The finalizer thread then runs the finalizer and removes the object from the queue. Only after that does the memory become eligible for reclaiming.

This process adds overhead. Objects with finalizers require at least two GC collections to be fully reclaimed: one to detect that they are unreachable and move them to the freachable queue, and another to actually free the memory after the finalizer runs. This can increase memory pressure and prolong the lifetime of the object, especially if finalizers are used on many objects.

Why Finalizers Are Not a Replacement for IDisposable

Finalizers are not deterministic, so they cannot guarantee timely release of resources. The recommended pattern for releasing unmanaged resources is to implement IDisposable and provide a Dispose() method that can be called explicitly. The finalizer then acts as a backup in case the caller forgets to call Dispose().

The canonical pattern is:

public class ResourceHolder : IDisposable { private IntPtr _handle; private bool _disposed; public ResourceHolder() { _handle = AllocateNativeResource(); } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (_disposed) return; if (disposing) { // Release managed resources that implement IDisposable. } // Release unmanaged resources. ReleaseNativeResource(_handle); _disposed = true; } ~ResourceHolder() { Dispose(false); } }

Here, Dispose() releases the resource immediately and calls GC.SuppressFinalize(this) to prevent the finalizer from running later. The finalizer only runs if Dispose() was never called, and it calls Dispose(false) to release unmanaged resources without touching managed ones. This pattern ensures that resources are freed promptly in the common case while still providing a safety net.

The Cost of Finalizers: Performance and Memory

Finalizers have a real performance cost. Every object with a finalizer is registered in the finalization queue, which adds memory overhead and forces the GC to track it. When such an object becomes unreachable, it survives the first collection and is moved to the freachable queue, then the finalizer thread runs, and only after a second collection is the memory actually freed. This can lead to increased memory usage and longer GC times, especially if many objects have finalizers.

If you have a class that does not actually hold unmanaged resources, do not add a finalizer. A finalizer that does nothing but call Dispose(false) on managed resources is pointless and harmful. The finalizer should only be present when the class directly owns an unmanaged resource, such as a native handle or an unmanaged memory block.

Common Mistakes with C# Destructors

One common mistake is assuming that a finalizer will run when the object goes out of scope. It will not. Another is accessing other managed objects from within the finalizer. Because the finalizer thread runs after the GC has already determined which objects are dead, any other object referenced by the finalizing object may already have been finalized or may be in an indeterminate state. Accessing them can cause exceptions or undefined behavior.

Another mistake is not calling GC.SuppressFinalize in Dispose(). If you forget this, the finalizer will still run even after you have explicitly released the resource, potentially causing double-release errors if the finalizer attempts to release the same resource again.

Finally, avoid making the finalizer block or perform long-running operations. The finalizer thread is single-threaded, and if one finalizer blocks, it delays all other finalizers and can cause the finalization queue to back up, leading to memory pressure and application stalls.

Finalizers and Inheritance

The finalizer pattern is virtual by nature. When you derive from a class that has a finalizer, the derived class's finalizer will call the base class's finalizer automatically. The C# compiler generates a call to the base Finalize method after the derived finalizer body executes. This means you do not need to explicitly call base.Finalize().

However, if you implement the IDisposable pattern, you should make Dispose(bool) virtual so derived classes can extend the cleanup logic. The derived class should call base.Dispose(disposing) to ensure the base class resources are released. This is the standard pattern but it is easy to get wrong, especially when a derived class adds its own unmanaged resources.

When to Use a Finalizer in Production Code

In practice, you should rarely write a finalizer. The only scenario where a finalizer is necessary is when your class directly owns an unmanaged resource and you need a safety net for callers that fail to call Dispose(). Even then, consider using SafeHandle or a wrapper from the Microsoft.Win32.SafeHandles namespace, which already implements the finalization pattern correctly. For example, SafeFileHandle and SafeProcessHandle are designed to wrap native handles and handle finalization safely.

If you find yourself writing a finalizer, ask whether you can instead use a SafeHandle subclass or a Stream wrapper. Most unmanaged resources in .NET are already wrapped in managed classes that implement IDisposable, so you rarely need to write your own finalizer. When you do, follow the Dispose(bool) pattern exactly, suppress finalization in Dispose(), and keep the finalizer minimal.

Finalizer Behavior in Different .NET Runtimes

Finalizer behavior is consistent across .NET Framework, .NET Core, and .NET 5+ in terms of the basic model: the GC tracks finalizable objects, and a dedicated thread runs the finalizers. However, there are subtle differences in how the finalizer thread is managed and how the GC interacts with it. For example, .NET Core and later versions may run finalizers on a thread pool thread rather than a dedicated finalizer thread, but the observable behavior from your code is essentially the same. You should not rely on any specific thread identity or timing.

Also, in .NET Core and later, you can call GC.WaitForPendingFinalizers() to block the current thread until all finalizers have run. This is useful during shutdown or in tests, but it should not be used as a general cleanup mechanism because it can cause deadlocks if a finalizer is waiting on a lock that the current thread holds.

c# destructor: Practical Usage and Code Examples | RYUSLOG DEV