Finalisation queue and f-reachable queue, extra GC survival, the single finaliser thread, ordering and exit behaviour, SafeHandle, and debugging finaliser problems.
An object with a finaliser (~MyType()) is registered on the finalisation queue when it is allocated. When the GC finds it unreachable, it does not free it; it moves it to the f-reachable queue, which keeps it (and everything it references) alive, and the single finaliser thread later runs Finalize. Only a later GC can reclaim the memory. So finalisable objects cost more to allocate, survive at least one extra GC (often reaching Gen 2), run in no guaranteed order, and in modern .NET are not run at process exit. If the finaliser thread blocks, finalisable objects pile up and memory grows. Use SafeHandle instead of writing finalisers, and call GC.SuppressFinalize in Dispose.
"When you allocate an object whose type overrides
Finalize(~MyType()), the runtime registers it on the finalisation queue. When a GC finds it unreachable, it can't free it yet: it moves it to the f-reachable queue, which is a root, so the object and everything it references survive and get promoted. A single, dedicated finaliser thread later runs the finaliser, and only a later GC of that older generation reclaims the memory. So finalisers make allocation slower, extend lifetimes, often push graphs into Gen 2, run in no guaranteed order, don't run at process exit in modern .NET, and one blocked finaliser stalls them all. I avoid writing finalisers: I wrap native resources inSafeHandle, dispose deterministically and callGC.SuppressFinalize. When memory grows with finalisable types, I take a dump, runfinalizequeueand look at the finaliser thread's stack."
| Step | What happens | Cost |
|---|---|---|
| 1. Allocate | Slower allocation helper registers the object on the finalisation queue | Extra work per allocation |
| 2. Becomes unreachable | GC sees it's dead but it's on the finalisation queue | — |
| 3. Moved to f-reachable queue | Queue is a root: object + its references are marked and promoted | Extra survival, extra GC work |
4. Finaliser thread runs Finalize() |
Single thread, high priority, no ordering guarantees | Serialised work |
| 5. Next GC of its generation | Memory finally reclaimed | Often a Gen 1 or Gen 2 GC |
Calling GC.SuppressFinalize(this) in Dispose removes the object from the finalisation queue, so a properly disposed object skips steps 3–5 entirely.
| Issue | Consequence |
|---|---|
| Extra survival and promotion | Mid-life objects, more Gen 1/Gen 2 work |
| Whole graph kept alive | Everything the finalisable object references also survives |
| Single finaliser thread | One slow or blocked finaliser stalls all finalisation → memory grows |
| No ordering | A finaliser can't rely on other finalisable objects still being usable |
| Not run at process exit (.NET Core / .NET 5+) | Can't be used to flush or persist anything |
| Unhandled exception in a finaliser | Crashes the process |
| Runs even if the constructor threw | Finaliser sees a partially initialised object |
| Resurrection | Storing this somewhere in a finaliser brings it back to life |
| Runs on another thread | Thread-affine resources (UI, STA COM) can deadlock |
SafeHandleSafeHandle gives you |
Why it matters |
|---|---|
A critical finaliser (CriticalFinalizerObject) |
Release still happens as a safety net; critical finalisers run after normal ones |
Reference counting during P/Invoke (DangerousAddRef/Release) |
Handle can't be released while a native call is using it |
| Protection against handle recycling | A closed handle value can't be reused by mistake |
ReleaseHandle() override |
One small, non-blocking release method |
A class that owns a native resource should hold a SafeHandle field, implement IDisposable, and have no finaliser of its own.
Symptoms: memory grows steadily, heap snapshots show many instances of types with finalisers, GC.GetGCMemoryInfo().FinalizationPendingCount keeps rising.
Steps:
dotnet-dump collect -p <pid> (ideally two dumps a few minutes apart).dotnet-dump analyze <dump>.finalizequeue → objects registered for finalisation and ready for finalisation, grouped by type. A large and growing "ready" count means the thread isn't keeping up.clrthreads → find the thread marked (Finalizer); setthread <id>; clrstack (and dumpstack / native stack if needed).lock/Monitor, a native call, a network or database call, or a cross-apartment COM call.Dispose that made the finaliser run at all.Our [thumbnail service] wrapped a native imaging library and processed [thousands of images per minute]. Memory rose steadily until pods were restarted. A gcdump showed huge numbers of ImageHandle objects; in a full dump, finalizequeue listed [tens of thousands] ready for finalisation. The finaliser thread's clrstack showed it waiting on a lock inside the native wrapper's release method, the same lock a request thread held while doing a long native resize. I changed the code so every image was disposed with using (so SuppressFinalize ran and finalisers almost never executed), moved the native release into a SafeHandle.ReleaseHandle() that takes no locks, and removed the custom finaliser. Memory stayed flat in a 24-hour soak test, and we enabled analyser rule CA2000 as an error.
The interviewer checks deep understanding of finalisation and the ability to debug related leaks.
Green flags: finalisation and f-reachable queues, extra GC survival, single thread, ordering and exit behaviour, SuppressFinalize, SafeHandle, finalizequeue and finaliser thread stack.
Red flags: thinks finalisers run promptly; uses them for managed cleanup; unaware they do not run at exit.
Allocation of finalisable object
|
v
+--------------------+ GC finds it unreachable +----------------------+
| finalisation queue | ------------------------------> | f-reachable queue | (a root!)
| (registered objs) | | (ready to finalise) |
+--------------------+ +----------------------+
^ |
| GC.ReRegisterForFinalize v
| finaliser thread runs Finalize()
| |
GC.SuppressFinalize removes the entry v
next GC of its generation frees memory
Because the f-reachable queue is a root, everything reachable from a finalisable object is kept alive until its finaliser has run, then until the next GC that covers its (now promoted) generation.
class Foo { ~Foo() { Cleanup(); } }
becomes, in IL:
.method family hidebysig virtual instance void Finalize() cil managed
{
.try { call instance void Foo::Cleanup() leave.s END }
finally { ldarg.0 call instance void [System.Runtime]System.Object::Finalize() endfinally }
END: ret
}
The ~Foo() syntax is an override of Object.Finalize with a try/finally that calls the base finaliser. The runtime detects the override when the type loads and uses the finalisable allocation path for every instance.
public class LegacyResource : IDisposable
{
private IntPtr _native; // raw handle: prefer SafeHandle instead!
private bool _disposed;
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
// release managed disposables here
}
if (_native != IntPtr.Zero) { NativeFree(_native); _native = IntPtr.Zero; }
_disposed = true;
}
~LegacyResource() => Dispose(disposing: false); // never touch other managed objects here
}
In the finaliser path (disposing == false), other managed objects may already have been finalised, so only unmanaged state is touched.
StreamWriter and its FileStream can be finalised in either order, which is why StreamWriter doesn't flush in a finaliser.CriticalFinalizerObject (e.g. SafeHandle): among objects finalised together, critical finalisers run after normal ones. That lets a normal finaliser still use a SafeHandle field.| Runtime | Finalisers at exit |
|---|---|
| .NET Framework | Ran with time limits (and could be skipped) |
| .NET Core 3.x / .NET 5–10 | Not run |
Use IHostApplicationLifetime.ApplicationStopping, IAsyncDisposable on hosted services, AppDomain.ProcessExit or AssemblyLoadContext.Unloading for shutdown work.
| Blocker | Example |
|---|---|
| Locks | Release method takes a lock held by a busy request thread |
| STA COM | Finalising an RCW for an STA object marshals to an STA thread that isn't pumping messages (Windows) |
| I/O | Finaliser flushes to disk or network |
| Sync-over-async | .Result on a task in a finaliser |
| Expensive native release | Native free that itself waits on another thread |
GC.GetGCMemoryInfo().FinalizationPendingCount: objects ready for finalisation at the last GC.finalizequeue, clrthreads, clrstack, syncblk (to see lock owners).| Version | Note |
|---|---|
| .NET Framework 2.0 | SafeHandle, CriticalFinalizerObject |
| .NET Core 1.0+ | No finalisers at process exit |
| .NET 5 | GCMemoryInfo.FinalizationPendingCount |
| .NET 6+ | CA2000/CA2213/CA1816 analysers commonly enabled to catch dispose bugs |
| .NET 10 | Finalisation model unchanged |
Thread.Sleep(Timeout.Infinite) inside one finaliser, allocate 1 million finalisable objects, and watch FinalizationPendingCount and memory grow.finalizequeue and clrstack on the finaliser thread.using System.Runtime.CompilerServices;
using Microsoft.Win32.SafeHandles;
WeakReference leaked = Create(dispose: false);
WeakReference disposed = Create(dispose: true);
GC.Collect();
Console.WriteLine($"After 1 GC - undisposed alive: {leaked.IsAlive}, disposed alive: {disposed.IsAlive}");
GC.WaitForPendingFinalizers();
GC.Collect();
Console.WriteLine($"After 2 GCs - undisposed alive: {leaked.IsAlive}");
[MethodImpl(MethodImplOptions.NoInlining)]
static WeakReference Create(bool dispose)
{
var resource = new NativeResource();
if (dispose) resource.Dispose();
return new WeakReference(resource, trackResurrection: true);
}
// Preferred: no finaliser in your class; SafeHandle owns the native handle
public sealed class SafeBufferHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public SafeBufferHandle() : base(ownsHandle: true) =>
SetHandle(System.Runtime.InteropServices.Marshal.AllocHGlobal(1024));
protected override bool ReleaseHandle()
{
System.Runtime.InteropServices.Marshal.FreeHGlobal(handle); // never block or throw here
return true;
}
}
// Demonstration type with a finaliser (only to show the cost)
public sealed class NativeResource : IDisposable
{
private readonly SafeBufferHandle _handle = new();
public void Dispose()
{
_handle.Dispose();
GC.SuppressFinalize(this); // skip finalisation work
}
~NativeResource() { /* safety net only: _handle has its own critical finaliser */ }
}
What happens: ~MyService() added "to be safe".
Why it is wrong: slower allocation, extended lifetimes, promotion, and no benefit for managed-only resources.
Do instead: finalise only when directly owning an unmanaged resource, and prefer SafeHandle even then.
What happens: the finaliser waits on a lock, I/O or a task. Why it is wrong: the single finaliser thread stalls; finalisable objects pile up and memory grows. Do instead: keep finalisers tiny, non-blocking and lock-free.
What happens: buffers are flushed in a finaliser. Why it is wrong: modern .NET doesn't run finalisers at process exit; data is lost. Do instead: dispose explicitly or use host shutdown hooks.
GC.SuppressFinalize in DisposeWhat happens: disposed objects still go through finalisation.
Why it is wrong: all the cost remains even though cleanup already happened.
Do instead: call GC.SuppressFinalize(this) in the public Dispose() of finalisable types.
What happens: the finaliser calls _stream.Flush() or logs through a logger.
Why it is wrong: those objects may already be finalised; there's no order.
Do instead: only release unmanaged state in the disposing == false path.
What happens: a native release returns an error and the finaliser throws. Why it is wrong: an unhandled exception on the finaliser thread terminates the process. Do instead: never throw; log defensively if at all, or ignore.
What happens: the finaliser dereferences fields set late in the constructor. Why it is wrong: if the constructor threw, the finaliser still runs on a half-built object. Do instead: check for null/zero handles in cleanup code.
What happens: a finaliser adds this to a static list or logs a closure capturing it.
Why it is wrong: the object comes back to life, possibly in a broken state, and won't be finalised again without re-registration.
Do instead: don't publish this from a finaliser.
What happens: releasing UI or STA COM objects in a finaliser. Why it is wrong: the finaliser thread may deadlock waiting for the owning thread. Do instead: dispose on the owning thread deterministically.
What happens: the team hunts for static references only.
Why it is wrong: a stuck finaliser thread looks like a leak of finalisable types.
Do instead: run finalizequeue and inspect the finaliser thread's stack as part of every leak investigation.
Finalisers are a safety net, not a cleanup strategy.
| Situation | Approach |
|---|---|
| Class holds a native handle | SafeHandle field, IDisposable, no finaliser |
| Class holds only managed disposables | IDisposable, sealed, no finaliser |
| Memory grows with finalisable types | Check finalizequeue and the finaliser thread stack |
| Legacy code with finalisers | Add disposal and SuppressFinalize, then remove finalisers |
Seniors know the finaliser thread is a single point of failure and treat any blocking in it as a production bug.
Implement IDisposable when your type owns something that must be released deterministically: an unmanaged resource (usually via SafeHandle) or another IDisposable. For a sealed class, a simple Dispose() that disposes owned fields is enough. For unsealed classes use the pattern Dispose() calling protected virtual Dispose(bool disposing) and GC.SuppressFinalize(this). Make Dispose idempotent and non-throwing, and throw ObjectDisposedException (ObjectDisposedException.ThrowIf) from members used after disposal. The owner disposes: whoever creates an object disposes it, unless ownership is explicitly transferred. The DI container disposes what it creates, at the end of the scope or the app.
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.
First confirm a leak: memory keeps rising across GCs under steady load, rather than a cache warming up. Then decide managed or native: if the GC heap size (dotnet-counters) grows with the working set, it is managed; if the working set grows while the GC heap is flat, look at native memory. For managed leaks, take two dotnet-gcdump snapshots some time apart and compare which types grow, then use dotnet-dump with dumpheap -stat and gcroot to find what keeps them alive. Typical roots: static collections and caches, event subscriptions, timers, captured closures, DI root-scoped transients, and unbounded queues. Fix, then verify with the same metrics under load.
How to tell it (STAR)
In [our thumbnail service], memory grew because the finaliser thread was stuck on a lock. I found it with finalizequeue and the finaliser thread stack, then made disposal deterministic and moved native release into a SafeHandle. Memory stayed flat after that.
Last updated 31 Oct 2026.