A finalizer is a special method on a class that the garbage collector runs right before the object's memory is reclaimed. It looks like a C++ destructor and even uses the same ~ClassName() syntax, but the meaning is very different: the timing is not under your control, the method runs on a dedicated thread, and an object that has one survives at least one extra collection cycle. This chapter covers what finalizers are for, how they actually run, the canonical Dispose plus finalizer pattern for classes that hold unmanaged resources, and why SafeHandle is the recommended replacement in modern C#.
~ClassName() SyntaxA finalizer is declared with a tilde followed by the class name, takes no parameters, has no access modifier, and has no return type. There can be at most one finalizer per class, and only classes can have finalizers; structs cannot.
That ~NativeReceiptHandle() body looks like a C++ destructor, and that resemblance is the first thing to unlearn. In C++, a destructor runs the instant the object goes out of scope or is deleted, and the timing is part of the language's contract. In C#, the only thing you're telling the runtime is "before this object's memory gets reclaimed, please run this method." The runtime decides when that happens. It might be milliseconds after the last reference disappears. It might be minutes. It might be when the process is shutting down. It might never happen at all if the process is killed.
The IntPtr field in the example is a stand-in for any handle to memory or a resource that the .NET garbage collector doesn't track: a file descriptor from a native library, a pointer returned by an interop call, an OS handle for a database connection. Those are the only resources a finalizer should ever clean up. Plain managed objects do not need finalizers.
The compiler translates ~NativeReceiptHandle() into an override of object.Finalize(). You cannot write override void Finalize() yourself; the C# compiler reserves the method and forces you to use the tilde syntax. The CLR is the only thing that calls Finalize, and it always calls the most derived class's finalizer first, walking up the inheritance chain. That's how the runtime guarantees that a derived class can clean up its own state before any base class state goes away.
The garbage collector knows how to free managed memory: every byte the runtime allocated for string instances, List<T> arrays, and your own class fields is tracked. When an object becomes unreachable, the collector reclaims its memory automatically.
What the collector does not know about is anything allocated outside the managed heap. A pointer returned from a P/Invoke call into a C library, a file handle obtained through CreateFile on Windows, a database connection opened by a native driver, a chunk of unmanaged memory acquired via Marshal.AllocHGlobal. To the GC, those are just IntPtr field values. They look like regular integers. The runtime has no way to know that one of those integers is a kernel object that has to be released before the process exits.
Finalizers exist as a safety net for that scenario. If a class owns an unmanaged resource and the caller forgot to release it explicitly (by calling Dispose() or by wrapping the object in a using block), the finalizer is the last opportunity to release the resource before the object's memory disappears.
The phrase "last opportunity" is doing a lot of work in that sentence. Finalizers are not the primary cleanup path. The primary cleanup path is IDisposable: callers call Dispose() and the resource is released right away. The finalizer is the backup that runs when that didn't happen. Designing a class so that the finalizer is the only cleanup is a mistake, because it pushes cleanup to a non-deterministic time on a dedicated thread, with all the consequences that follow.
The two paths both end at the same place (the unmanaged resource gets released) but one is fast and predictable, and the other is slow, late, and uncertain. The finalizer is there to keep a leak from becoming permanent, not to do the cleanup work in the first place.
The single most important fact about finalizers is that you do not control when they run. The runtime decides. There are three consequences that follow from this, and each one affects how you write a finalizer.
The first consequence is timing. A finalizer runs when the garbage collector decides the object is ready to be finalized, which only happens during a collection that touches the object's generation. An object that gets promoted to Gen 2 might sit there for minutes or hours before the next Gen 2 collection. The unmanaged handle it holds is wasted that whole time.
The second consequence is order. If two objects are eligible for finalization in the same collection, you cannot predict which one's finalizer runs first. If object A's finalizer needs object B to still be alive, you are out of luck: B may have already been finalized. The CLR makes one ordering guarantee, which is that within a single object's inheritance chain, the most derived finalizer runs before the base class finalizers. Across unrelated objects, there is no guarantee.
The third consequence is whether the finalizer runs at all. If the process terminates abruptly (crash, Environment.FailFast, kill -9), no finalizers run. If the AppDomain shuts down cleanly, the runtime tries to run finalizers, but there is a budget for how long it spends doing so. A finalizer that takes too long can be skipped.
Output (one possible run):
Three orders were created and dropped on the floor. After GC.Collect() and GC.WaitForPendingFinalizers(), all three finalizers eventually ran, but the order is not the order in which the objects were created. A different run might print the three finalizer lines in a different sequence. If you depend on finalization order, your code is wrong.
GC.WaitForPendingFinalizers() blocks the current thread until the finalizer queue is empty. It's useful in test harnesses and at process shutdown to make sure cleanup has actually happened, but don't sprinkle it through application code. Forcing finalization defeats the purpose of letting the GC run on its own schedule.
The mechanics behind non-deterministic execution are worth understanding, because they explain the performance characteristics of finalizers and several common mistakes.
When you allocate an object whose class declares a finalizer, the runtime does not just allocate memory and call the constructor. It also adds a pointer to that object to an internal data structure called the finalization queue. The finalization queue is essentially a list of "objects we will need to finalize eventually."
When a garbage collection runs, the collector finds unreachable objects as usual. For most unreachable objects, the memory is reclaimed and that's the end of it. But for an unreachable object that's listed in the finalization queue, the collector does something different: it moves the entry from the finalization queue to a second internal queue called the f-reachable queue. The f-reachable queue is read by a dedicated thread inside the runtime called the finalizer thread, which pops entries one at a time and calls each object's Finalize method.
Here's the part that surprises people. The object's memory is not reclaimed in the collection that decided it was unreachable. The collection moves the entry to the f-reachable queue, and the f-reachable queue keeps the object alive (the queue counts as a root). The finalizer thread eventually runs the finalizer, removes the entry from the queue, and only then is the object truly unreachable. The next collection that touches the object's generation finally reclaims the memory.
The practical consequence is that an object with a finalizer survives at least one extra GC cycle. If the object was in Gen 0 when it was found unreachable, the act of being put on the f-reachable queue promotes it to Gen 1 (or sometimes Gen 2, depending on how the collection ran). That means the object now lives in a generation that gets collected less often, and so does every object the finalizer can reach. The finalizer is forcing the runtime to keep more garbage alive for longer.
A finalizable object lives through at least two collections instead of one, and during the gap between "marked unreachable" and "finalizer runs" it pins everything it references through fields. On a heavily allocating server, finalizers are a steady source of Gen 1 and Gen 2 pressure. Adding a finalizer to a class is not free.
There is one finalizer thread per process by default. That means every finalizer in the entire process runs on the same thread, in sequence. If one finalizer takes 500 ms to run (say, by waiting on a network handle to close), the next 50 finalizers in the queue all wait 500 ms longer. And while the finalizer thread is blocked, the f-reachable queue keeps growing, which keeps more objects alive, which makes the next GC do more work.
Five objects, each taking 200 ms in their finalizer, run one after another on the single finalizer thread. The total wall-clock time is over a second, even though the work is trivial. In a real application, blocking the finalizer thread can wedge the whole process: pending finalizers stack up, memory pressure climbs, and eventually the runtime has nowhere to put new allocations.
When a class also owns an unmanaged resource, the standard pattern is to combine Dispose() (the fast path) with a finalizer (the backup) so that callers who forget to dispose still have their resources cleaned up eventually.
The pattern is named the Dispose pattern in the documentation, and it has been the textbook recommendation since the early days of .NET. The full canonical form looks like this.
Three pieces of this pattern deserve attention.
The first is the protected virtual void Dispose(bool disposing) overload. It's the single method that contains the actual cleanup logic, and the disposing parameter tells it which path led there. When disposing is true, the call came from the public Dispose() method, which means we are on a normal thread and every managed object the class references is still in a well-defined state. When disposing is false, the call came from the finalizer, which means we are on the finalizer thread and any managed objects this object references may already have been finalized. The unmanaged cleanup runs on both paths, but managed cleanup runs only when disposing is true.
The second is GC.SuppressFinalize(this) inside the public Dispose(). The call tells the runtime "this object's finalizer no longer needs to run; you can take it off the finalization queue." That's important because it means a properly disposed object skips the slow finalization path entirely. Without SuppressFinalize, the GC would still queue the object for finalization, the finalizer thread would still pick it up and run Dispose(false), and the _disposed flag would just short-circuit the body. The work done is small, but the object would still survive an extra collection cycle for nothing.
The third is that Dispose(bool) is protected virtual. That's so derived classes can extend the pattern: a subclass overrides Dispose(bool), releases its own state, and then calls base.Dispose(disposing) to let the base class do its part. The public Dispose() is sealed at the base; derived classes don't override it, they override the protected overload.
The diagram shows the two entry points (public Dispose() and the finalizer) converging on the same Dispose(bool) overload. The disposing argument is the only way that overload knows which path brought it there, and it uses that to decide whether managed cleanup is safe.
The Dispose plus finalizer pattern is correct, but it's also a lot of code to get right for every class that holds a handle. Miss the SuppressFinalize call and you pay extra GC cost. Throw from the finalizer and you crash the process. Touch a managed reference in the wrong branch and you race against other finalizers.
Microsoft introduced SafeHandle in .NET 2.0 specifically to absorb most of this complexity. A SafeHandle is an abstract class in System.Runtime.InteropServices that wraps a single unmanaged handle (an IntPtr to a kernel object, native buffer, or similar) and handles the finalization machinery for you. Your job is to subclass it and override ReleaseHandle() with the call that frees the handle. The base class takes care of everything else.
Notice what's not in that class. There is no ~NativeReceiptHandle(). There is no protected virtual void Dispose(bool disposing). There is no GC.SuppressFinalize(this). The base class implements IDisposable, has its own critical finalizer, calls ReleaseHandle() from both paths, and tracks the disposed state. Subclassing SafeHandle is the modern, recommended way to wrap an unmanaged handle in C#.
There are three concrete reasons to prefer SafeHandle over a hand-rolled Dispose pattern.
The first is reliability. SafeHandle inherits from CriticalFinalizerObject, which gives it stronger runtime guarantees than a normal class. The runtime treats critical finalizers specially: they are guaranteed to run even during constrained execution regions, AppDomain unloads, and certain abrupt shutdowns where ordinary finalizers might be skipped. For a handle whose leak means "kernel object held forever," that guarantee matters.
The second is interop safety. P/Invoke can marshal a SafeHandle directly. When you pass one as a parameter, the runtime increments a reference count for the duration of the call, so the handle cannot be released by another thread (or by a premature finalization) while the native code is using it. With a raw IntPtr, there is a real race window where the GC could finalize your wrapper class, the finalizer could call CloseHandle, and the next P/Invoke call could pass the freed handle to native code. SafeHandle closes that window.
The third is less code to get wrong. The Dispose-with-finalizer pattern has several places to make subtle mistakes: forgetting SuppressFinalize, touching managed state in the finalizer path, throwing from the finalizer. SafeHandle consolidates the correct behavior in one well-tested base class and asks the subclass for the one piece it can't write itself, the call that frees the handle.
| Concern | Hand-rolled Dispose + finalizer | SafeHandle subclass |
|---|---|---|
| Code per type | ~30 lines of pattern boilerplate | Constructor, IsInvalid, ReleaseHandle |
Forgot SuppressFinalize | Object survives extra GC cycle | Handled by base |
| Throws from finalizer | Process can crash | Base catches and logs |
| Race with P/Invoke | Possible | Ref-count protection at marshal time |
| Runs during host unload | Best effort | Critical finalizer guarantee |
The recommendation is straightforward: if you're writing new code that wraps an unmanaged handle, subclass SafeHandle. Understand the hand-rolled pattern because legacy code uses it everywhere, but for new code there is rarely a reason to repeat the boilerplate.
SafeHandle is not free either. Every native call that marshals one through P/Invoke takes a small lock to increment the ref count. For tight loops calling into native code, that overhead is measurable. For everything else, the safety is worth it by a wide margin.
You can subclass CriticalFinalizerObject directly in cases where SafeHandle doesn't fit, but in practice that's rare. The two BCL types that derive from it directly are SafeHandle and CriticalHandle, and almost any real cleanup scenario fits one of those. The reason to know CriticalFinalizerObject exists is to understand what SafeHandle inherits and to recognize the class name when reading the source of low-level BCL types.
The "constrained execution region" language in the docs refers to a now-legacy concept (CERs were removed from .NET Core/.NET 5+ as a public feature) for code that needed to run with stronger guarantees about exceptions and aborts. Critical finalizers are the part of that mechanism that survived into modern .NET, and they are the reason a SafeHandle-derived finalizer is much more likely to run during a host shutdown than an ordinary ~Ctor() finalizer.
The practical takeaway is simple: if you find yourself thinking "this resource absolutely must be released," that's a signal you want SafeHandle, not a hand-rolled finalizer.
Three mistakes show up over and over in code that uses finalizers, and each one has consequences worse than not having a finalizer at all.
The first is throwing an exception from a finalizer. On .NET Framework versions before 2.0, an exception in a finalizer was ignored. From 2.0 onward, it crashes the process. The reasoning is that a finalizer running an exception puts the runtime in an undefined state: the finalization queue may be corrupted, partially released native handles may leak, and there is no caller stack to propagate to. The fix is to wrap any code inside a finalizer that could throw in a try/catch and either swallow the exception or log it through a mechanism that itself cannot throw.
The second mistake is touching managed references from a finalizer that might have already been collected. The previous section covered this in detail. Inside a finalizer (and inside Dispose(false)), assume that every managed object the class references through a field may already have had its own finalizer run. Calling methods on those objects can fail in surprising ways: their fields may be in an inconsistent state, their finalizer may have set internal flags that change behavior, or the object may be partially zeroed out. The Dispose pattern handles this correctly by gating managed-state cleanup on disposing == true.
The third mistake is adding a finalizer to a class that doesn't need one. A class made up entirely of managed fields (strings, lists, dictionaries, other regular objects) needs no finalizer. The garbage collector knows how to clean those up. Adding ~Ctor() to such a class costs you extra GC promotion, finalizer-thread time, and the risk of all the other mistakes, and it cleans up nothing the GC wasn't going to clean up anyway. The rule of thumb: a finalizer is only justified for a class that holds a handle to something outside the managed heap, and even then, prefer SafeHandle.
Both versions produce the same output, but the noisy version will queue every single CartItem for finalization, drag every one through Gen 1 or Gen 2, and run a million no-op finalizer calls on the finalizer thread when the GC eventually gets to them. That's the cost of a finalizer that doesn't earn its place. The clean version skips all of it.
10 quizzes