📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How does finalisation work in .NET, why are finalisers expensive, and how do you diagnose a blocked finaliser thread?

Finalisation queue and f-reachable queue, extra GC survival, the single finaliser thread, ordering and exit behaviour, SafeHandle, and debugging finaliser problems.

Senior Debugging Advanced diagnostics finalisation gc safehandle

🎯 Key points

  • Finalisable objects are tracked from allocation on the finalisation queue
  • When unreachable they move to the f-reachable queue and survive at least one more GC
  • A single finaliser thread runs finalisers in no guaranteed order
  • Modern .NET does not run finalisers at process exit
  • GC.SuppressFinalize in Dispose removes the finalisation cost
  • Prefer SafeHandle over writing finalisers
  • Diagnose with dotnet-dump finalizequeue and the finaliser thread's stack

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"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 in SafeHandle, dispose deterministically and call GC.SuppressFinalize. When memory grows with finalisable types, I take a dump, run finalizequeue and look at the finaliser thread's stack."


1. Lifecycle of a finalisable object

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.

2. Why finalisers are expensive and risky

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

3. The right tool: SafeHandle

SafeHandle 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.

4. Diagnosing a blocked finaliser thread

Symptoms: memory grows steadily, heap snapshots show many instances of types with finalisers, GC.GetGCMemoryInfo().FinalizationPendingCount keeps rising.

Steps:

  1. dotnet-dump collect -p <pid> (ideally two dumps a few minutes apart).
  2. dotnet-dump analyze <dump>.
  3. 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.
  4. clrthreads → find the thread marked (Finalizer); setthread <id>; clrstack (and dumpstack / native stack if needed).
  5. Read what it's waiting on: a lock/Monitor, a native call, a network or database call, or a cross-apartment COM call.
  6. Fix the finaliser (remove blocking work) and fix the missing Dispose that made the finaliser run at all.

Production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. The queues inside the GC

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.

2. What the compiler generates

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.

3. The dispose pattern with a finaliser (only if you must own a raw handle)

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.

4. Ordering and critical finalisers

  • Among normal finalisers: no order. A 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.

5. Process exit

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.

6. Classic finaliser-thread blockers

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

7. Observability

  • GC.GetGCMemoryInfo().FinalizationPendingCount: objects ready for finalisation at the last GC.
  • GC heap stats events in traces report finalisation-promoted counts and sizes per GC.
  • SOS: finalizequeue, clrthreads, clrstack, syncblk (to see lock owners).

8. Version notes

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

9. Try it yourself

  1. Run the code example: the undisposed object survives the first GC; the disposed one doesn't.
  2. Add Thread.Sleep(Timeout.Infinite) inside one finaliser, allocate 1 million finalisable objects, and watch FinalizationPendingCount and memory grow.
  3. Take a dump of that process and run finalizequeue and clrstack on the finaliser thread.
  4. Throw from a constructor of a type with a finaliser and log in the finaliser: it still runs on a partially built object.
  5. Throw an exception from a finaliser and observe the process crash.
  6. Benchmark allocating 10 million objects with and without a finaliser using BenchmarkDotNet.

💻 Code

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 */ }
}

⚠ Common mistakes

1. Adding a finaliser to every disposable class

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.

2. Blocking or locking inside a finaliser

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.

3. Relying on finalisers at shutdown

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.

4. Forgetting GC.SuppressFinalize in Dispose

What 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.

5. Touching other managed objects in a finaliser

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.

6. Throwing from a finaliser

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.

7. Assuming the constructor completed

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.

8. Resurrecting objects accidentally

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.

9. Using finalisers for thread-affine resources

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.

10. Diagnosing a leak without checking the finaliser thread

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate recommends SuppressFinalize in Dispose Why: Checks the dispose pattern
    How to answer

    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.

  2. When: Candidate contrasts finalisers and Dispose Why: Checks the OOP view of finalisers
    How to answer

    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.

  3. When: Candidate mentions memory growth from finalisable objects Why: Checks leak investigation
    How to answer

    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.

🛠 Real-world problems: how you handle them

Native imaging leak caused by a blocked finaliser thread
Situation
A thumbnail service wrapped a native imaging library and processed [thousands of images per minute].
Signals
Memory grew steadily until restarts; heap snapshots showed many `ImageHandle` objects.
Investigation
`dotnet-dump` `finalizequeue` showed [tens of thousands] of objects ready for finalisation; `clrstack` on the finaliser thread showed it waiting for a lock held by a request thread.
Resolution
Disposed every image deterministically with `using`, moved release into a `SafeHandle` that does not lock, and removed custom finalisers.
Prevention
Analyser rule CA2000 enabled as error and a soak test watching finalisation counts.

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.

More in Garbage Collection

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me