Finalisers in .NET: syntax, the finalisation queue, costs and risks, and why IDisposable and SafeHandle are the real tools for clean-up.
C# has finalisers, written with destructor syntax ~MyType(), but they are not C++ destructors. The compiler turns them into an override of Object.Finalize. The GC calls them at some unknown time after the object becomes unreachable, on a single finaliser thread, in no guaranteed order, and possibly never (for example at process exit in .NET Core and later). Objects with finalisers cost more: they are tracked from allocation, survive at least one extra GC and get promoted. For deterministic clean-up use IDisposable with using. Only classes that directly own unmanaged resources need a finaliser, and even then wrap the handle in a SafeHandle, which has the finaliser for you. The full dispose pattern (Dispose(bool) plus GC.SuppressFinalize) is for unsealed classes that may hold unmanaged state.
C# has a destructor syntax (~MyType()), but it compiles to a finaliser (Finalize() override) — not a C++-style destructor that runs when an object goes out of scope. Finalisers run non-deterministically on a background thread after the GC finds the object unreachable. They're a last-resort safety net for unmanaged resources. Deterministic cleanup is IDisposable + using, and in modern .NET the right way to own an OS handle is a SafeHandle, which means you almost never write a finaliser yourself.
public class NativeBuffer
{
private IntPtr _ptr = Marshal.AllocHGlobal(1024);
~NativeBuffer() => Marshal.FreeHGlobal(_ptr); // compiles to protected override void Finalize()
}
try { ... } finally { base.Finalize(); }.Finalize().this somewhere), which is confusing and rarely intended.IDisposable and the dispose patternIDisposable.Dispose() releases resources deterministically, called by using/await using:
public class ResourceHolder : IDisposable
{
private SafeFileHandle? _handle; // unmanaged resource wrapped in SafeHandle
private Stream? _stream; // managed disposable
private bool _disposed;
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this); // no finaliser work needed any more
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing) // called from Dispose(): safe to touch managed objects
{
_stream?.Dispose();
_handle?.Dispose();
}
// (unmanaged cleanup without SafeHandle would go here - but use SafeHandle instead)
_disposed = true;
}
}
Dispose(bool) is the classic pattern for unsealed classes so derived types can extend cleanup. For sealed classes, a simple Dispose() is enough.GC.SuppressFinalize(this) removes the object from the finalisation queue if a finaliser exists in the hierarchy.Dispose should be idempotent and not throw.ObjectDisposedException (ObjectDisposedException.ThrowIf(_disposed, this)).IAsyncDisposable (DisposeAsync) for resources whose cleanup involves I/O (flushing streams, closing network connections).SafeHandleSafeHandle (e.g. SafeFileHandle, SafeWaitHandle, or a custom subclass overriding ReleaseHandle) wraps an OS handle and:
Dispose;So your class holds a SafeHandle, implements IDisposable, and doesn't need a finaliser. Microsoft's guidance: write a finaliser only if you directly own an unmanaged resource that no SafeHandle can wrap — which is rare.
A document-conversion service wrapped a native library with a class that freed memory in a finaliser but didn't implement IDisposable. Under load, native memory grew by gigabytes while the managed heap looked small — the GC had no reason to run often, so finalisers ran rarely, and the finaliser thread fell behind. Wrapping the native pointer in a custom SafeHandle, implementing IDisposable, and using using in the conversion path made memory flat. dotnet-counters showed the finalisation queue length dropping to near zero.
Checks whether the candidate understands GC behaviour and resource management, a common source of production leaks.
IDisposable and SafeHandle, knows when the full dispose pattern is needed and why SuppressFinalize exists.GC.SuppressFinalize clears a header bit so the GC ignores it.Finalize() methods one at a time.GC.ReRegisterForFinalize(obj) re-adds an object (used in resurrection scenarios). GC.WaitForPendingFinalizers() blocks until the f-reachable queue drains — handy in tests, dangerous in production.
| Aspect | Normal object | Finalisable object |
|---|---|---|
| Allocation | Bump pointer | Bump pointer + registration (slower path) |
| First GC when dead | Freed | Survives, queued, promoted |
| Memory reclaimed | Immediately at that GC | After finaliser runs + another GC |
| Objects it references | Freed | Kept alive too |
Allocating many finalisable objects (e.g. thousands of wrappers per second) noticeably increases GC cost.
SafeHandle derives from CriticalFinalizerObject, whose finalisers run after normal finalisers and are prepared ahead of time so they can run even in low-resource situations. Internally it tracks state (closed, disposed) and a reference count:
DangerousAddRef before the native call and DangerousRelease after, so a concurrent Dispose can't free the handle mid-call.ReleaseHandle() is called exactly once when the count hits zero and the handle is closed.This fixes the classic IntPtr handle-recycling race, where a closed handle number is reused by the OS for another resource and the stale IntPtr operates on the wrong object.
.NET Framework ran finalisers on shutdown (with a timeout). .NET Core/.NET 5+ does not: on process exit, pending finalisers are not guaranteed to run. Never rely on finalisers for flushing files, sending telemetry, or releasing external locks — use IHostApplicationLifetime.ApplicationStopping, IAsyncDisposable on DI services, or AppDomain.ProcessExit for best-effort work.
The ASP.NET Core DI container disposes services it created when their scope ends (scoped services at the end of the request, singletons at shutdown), calling DisposeAsync if available. Services you register as instances (AddSingleton(myObj)) are not disposed by the container. Transient disposables resolved from the root provider are tracked until the app stops — a known memory-leak trap.
dotnet-counters: watch gc-heap-size, gen-2-gc-count; .NET exposes finalisation-related events via EventPipe.dotnet-dump analyze → finalizequeue shows registered and f-reachable objects by type; threads/clrstack on the finaliser thread shows if it's blocked.[MemoryDiagnoser].dotnet-dump.SafeHandle for a native allocation and verify it's released without a finaliser in your class.using Microsoft.Win32.SafeHandles;
using System.Runtime.InteropServices;
// 1. Most classes: managed disposables only, sealed, no finaliser
public sealed class ReportWriter(Stream output) : IDisposable
{
private readonly StreamWriter _writer = new(output);
private bool _disposed;
public void Write(string line)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_writer.WriteLine(line);
}
public void Dispose()
{
if (_disposed) return;
_writer.Dispose();
_disposed = true;
}
}
// 2. Unmanaged handle: let SafeHandle own the finaliser
internal sealed class NativeBufferHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public NativeBufferHandle() : base(ownsHandle: true) { }
protected override bool ReleaseHandle() => NativeMethods.FreeBuffer(handle);
}
// 3. Unsealed base class: the dispose pattern
public class ImageProcessor : IDisposable
{
private readonly NativeBufferHandle _buffer = NativeMethods.AllocBuffer(1 << 20);
private bool _disposed;
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this); // harmless here, needed if a subclass adds a finaliser
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
_buffer.Dispose(); // managed objects only when disposing
_disposed = true;
}
}
internal static partial class NativeMethods
{
[LibraryImport("imaging")]
internal static partial NativeBufferHandle AllocBuffer(int size);
[LibraryImport("imaging")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool FreeBuffer(nint buffer);
}
// Usage: deterministic clean-up
using var processor = new ImageProcessor();
What happens: expecting ~MyType() to run when a variable goes out of scope.
Why it is wrong: finalisers run at an unpredictable time on the finaliser thread, or not at all.
Do instead: implement IDisposable and use using for deterministic cleanup.
What happens: "just in case" finalisers that dispose managed members.
Why it is wrong: costs every allocation and GC, and touching other managed objects in a finaliser is unsafe (they may be finalised already).
Do instead: no finaliser; implement IDisposable only.
IntPtr handlesWhat happens: native handles stored as IntPtr and freed in a finaliser.
Why it is wrong: handle-recycling races and leaks when exceptions occur.
Do instead: wrap handles in a SafeHandle subclass.
GC.SuppressFinalizeWhat happens: Dispose() cleans up but the object still sits in the finalisation queue.
Why it is wrong: unnecessary survival, promotion and finaliser work.
Do instead: call GC.SuppressFinalize(this) in Dispose() (CA1816).
DisposeWhat happens: cleanup code throws on already-closed resources.
Why it is wrong: an exception in a finaliser crashes the process; in Dispose it can mask the original exception.
Do instead: make cleanup idempotent and non-throwing.
What happens: finalisers expected to flush logs or release external locks.
Why it is wrong: .NET Core doesn't run pending finalisers on exit.
Do instead: dispose deterministically and handle ApplicationStopping.
What happens: finalisers wait on locks, network or Task.Result.
Why it is wrong: blocks the single finaliser thread, stalling all finalisation and leaking memory.
Do instead: keep finalisers minimal and non-blocking (ideally nonexistent).
What happens: a class owns a Stream or HttpClient field but doesn't implement IDisposable.
Why it is wrong: resources leak until GC (if ever) — sockets, file handles, memory.
Do instead: implement IDisposable and dispose owned fields (CA2213), or let DI own the lifetime.
Seniors almost never write finalisers; they design types so that resource ownership is explicit and disposal is deterministic.
| Concern | Senior practice |
|---|---|
| Unmanaged handles | SafeHandle subclass, never raw IntPtr fields |
| Resource ownership | the creator disposes; document transfer of ownership |
| Async resources | IAsyncDisposable + await using |
| DI-created disposables | let the container dispose them; do not dispose injected services |
| Diagnostics | watch finalisation queue length and Gen 2 growth |
| Analyzers | CA1063 (implement IDisposable correctly), CA2000 (dispose objects), CA1816 (SuppressFinalize) |
The senior insight is that the GC manages memory, not resources. Treating finalisers as a safety net, not a mechanism, keeps services predictable under load.
Each thread allocates from its own allocation context, a small chunk of gen 0 where new is just a pointer bump plus zeroing; when it runs out, the thread asks the GC for another chunk, and when the gen 0 budget is used up, a gen 0 GC runs. The GC is generational because most objects die young: gen 0 collections are cheap, survivors are promoted to gen 1 and then gen 2, which is collected rarely (and mostly in the background). Objects of 85,000 bytes or more go to the large object heap (LOH), which is collected only with gen 2 and isn't compacted by default, so it can fragment. Pinned objects can't be moved, which blocks compaction; the pinned object heap (.NET 5+) keeps long-lived pinned buffers separate. In hot paths I reduce the allocation rate rather than tuning the GC.
A file descriptor (Linux, a small integer) or handle (Windows, an opaque value) is the process's reference to a kernel object: an open file, socket, pipe, event, mutex, process or thread. The kernel owns the object, and the process holds an index into its own table.
They are limited (e.g. ulimit -n often 1,024 soft by default on Linux shells, much higher in many containers and under systemd), so leaks eventually break the app.
In .NET, SafeHandle wraps the raw value: it releases it in a critical finaliser if you forget, guarantees release even under thread aborts or async exceptions, and prevents handle recycling bugs by reference counting during P/Invoke calls. You still call Dispose (using) to release deterministically.
Code that might fail goes in try. When an exception is thrown, the runtime looks for the first matching catch, top to bottom, so put specific types before general ones (the compiler rejects the wrong order). finally runs when control leaves the try by any normal path: completing, return, break, or an exception that is caught here or further up the stack. It is for clean-up such as releasing locks or files. It does not run if the process dies: Environment.FailFast, a stack overflow, the process being killed, or, in practice, an exception that nothing catches. A using statement or declaration compiles to try { ... } finally { x.Dispose(); }.
How to tell it (STAR)
**Situation:** At [company] our image service kept getting killed for running out of memory, but the managed heap looked fine. **Task:** I was asked to find the leak. **Action:** The gap between process memory and GC heap pointed to native memory. A dump showed thousands of objects waiting in the finalisation queue: our wrapper freed native buffers only in a finaliser, and the single finaliser thread could not keep up. I wrapped the native pointer in a `SafeHandle`, made the wrapper disposable, updated callers to use `using`, and enabled the dispose analyzers. **Result:** Memory stayed flat at [X MB] under the same load, the out-of-memory kills stopped, and we added a working set alert so native leaks are visible.
Last updated 30 Oct 2026.