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

📚 Interview question

Does C# have destructors? Explain finalisers, how the garbage collector runs them, why they are non-deterministic and costly, and how they relate to IDisposable, the dispose pattern and SafeHandle.

Finalisers in .NET: syntax, the finalisation queue, costs and risks, and why IDisposable and SafeHandle are the real tools for clean-up.

Senior Theory Advanced finalizer garbage-collection idisposable oop safehandle

🎯 Key points

  • `~Type()` is a finaliser compiled to `Finalize`, not a deterministic destructor
  • Finalisable objects are queued at allocation and survive at least one extra GC
  • Finalisers run on one finaliser thread, in no order, at an unknown time, and not at process exit in .NET Core+
  • Blocking or throwing in a finaliser stalls finalisation or crashes the process
  • `IDisposable` with `using` is the deterministic clean-up mechanism
  • Only directly owned unmanaged resources justify a finaliser; wrap them in `SafeHandle`
  • Unsealed types use `Dispose(bool)` and call `GC.SuppressFinalize(this)` in `Dispose()`
  • In the finaliser path release only unmanaged state; never touch other managed objects

⏱ 30-second 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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. Finaliser basics

public class NativeBuffer
{
    private IntPtr _ptr = Marshal.AllocHGlobal(1024);
    ~NativeBuffer() => Marshal.FreeHGlobal(_ptr);   // compiles to protected override void Finalize()
}
  • You cannot call a finaliser directly, and you can't control when or on which thread it runs.
  • Only classes can have finalisers (not structs).
  • The compiler wraps your code in try { ... } finally { base.Finalize(); }.

2. How the GC runs finalisers

  1. When an object with a finaliser is allocated, the runtime registers it in the finalisation queue (an extra cost per allocation).
  2. When a GC finds it unreachable, it is not freed. Instead it moves to the f-reachable queue, which is a root — so the object (and everything it references) survives this GC and is promoted to an older generation.
  3. The single finaliser thread dequeues objects and calls Finalize().
  4. Only at a later GC can the memory finally be reclaimed.

3. Why they're non-deterministic and costly

  • Timing: depends on GC timing — seconds, minutes, or never if the process exits first (.NET Core doesn't run finalisers on process exit).
  • Promotion: finalisable objects survive at least one extra GC, often getting promoted to Gen1/Gen2, increasing memory and GC cost ("mid-life crisis").
  • Single thread: one slow or blocking finaliser delays all others; a deadlocked finaliser causes an ever-growing queue — a memory leak.
  • Order is undefined: objects referenced by your finaliser may already be finalised.
  • Exceptions thrown in a finaliser crash the process.
  • Resurrection is possible (a finaliser stores this somewhere), which is confusing and rarely intended.

4. IDisposable and the dispose pattern

IDisposable.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.
  • After disposal, members should throw ObjectDisposedException (ObjectDisposedException.ThrowIf(_disposed, this)).
  • IAsyncDisposable (DisposeAsync) for resources whose cleanup involves I/O (flushing streams, closing network connections).

5. SafeHandle

SafeHandle (e.g. SafeFileHandle, SafeWaitHandle, or a custom subclass overriding ReleaseHandle) wraps an OS handle and:

  • has its own critical finaliser, so the handle is released even if you forget Dispose;
  • uses reference counting to prevent the handle being closed while a P/Invoke call is using it (avoids handle-recycling security bugs);
  • integrates with P/Invoke marshalling.

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.

6. A production example

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.

🔍 What the interviewer is checking

Checks whether the candidate understands GC behaviour and resource management, a common source of production leaks.

  • Green flags: says finalisers are non-deterministic and costly, explains the finalisation queue and extra GC, prefers IDisposable and SafeHandle, knows when the full dispose pattern is needed and why SuppressFinalize exists.
  • Red flags: equates C# finalisers with C++ destructors, adds finalisers everywhere, or accesses managed objects in finalisers.

📚 Learn in depth

1. Finalisation queues in the runtime

  • Finalisation queue (registration list): every finalisable object is added at allocation. GC.SuppressFinalize clears a header bit so the GC ignores it.
  • F-reachable queue: objects found dead but needing finalisation. This queue is a GC root.
  • Finaliser thread: a high-priority thread running 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.

2. Cost model

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.

3. Critical finalisers and SafeHandle internals

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:

  • P/Invoke marshalling calls 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.

4. Finalisers and process exit

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

5. DI and disposal

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.

6. Diagnosing finaliser problems

  • 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.
  • Analysers: CA1063 (implement IDisposable correctly), CA1816 (call SuppressFinalize), CA2000 (dispose objects before losing scope), CA2213 (disposable fields should be disposed).

Try it yourself

  1. Allocate 100,000 objects with and without a finaliser and compare GC counts and promotion with BenchmarkDotNet's [MemoryDiagnoser].
  2. Write a finaliser that sleeps for a second and watch the finalisation queue grow in dotnet-dump.
  3. Implement a custom SafeHandle for a native allocation and verify it's released without a finaliser in your class.
  4. Show that a finaliser doesn't run on process exit in .NET 10.

💻 Code

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();

⚠ Common mistakes

1. Treating finalisers like C++ destructors

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.

2. Adding finalisers to classes without unmanaged resources

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.

3. Owning raw IntPtr handles

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

4. Forgetting GC.SuppressFinalize

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

5. Throwing from finalisers or Dispose

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

6. Relying on finalisers at shutdown

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.

7. Blocking in finalisers

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

8. Not disposing disposable fields

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says finalisable objects survive an extra GC Why: Checks their model of generations and the managed heap
    How to answer

    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.

  2. When: Candidate recommends SafeHandle Why: Probes OS handles and handle leaks
    How to answer

    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.

  3. When: Candidate says using compiles to try/finally Why: Checks they understand finally guarantees and their limits
    How to answer

    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(); }.

🛠 Real-world problems: how you handle them

Native memory growth because clean-up relied on finalisers
Situation
An image processing service wrapped a native imaging library. The wrapper freed native buffers in a finaliser and had no `Dispose` method.
Signals
Process working set grew to [several GB] under load while managed heap size stayed modest; occasional out-of-memory kills of the container; finalisation queue length in the thousands.
Investigation
`dotnet-counters` showed low GC heap but high process memory, pointing to native allocations. `dotnet-dump` `finalizequeue` listed thousands of wrapper objects waiting for finalisation; the finaliser thread could not keep up with allocation.
Resolution
Introduced a `SafeHandle` subclass for the native buffer, implemented `IDisposable` on the wrapper, and changed callers to `using`. Removed the hand-written finaliser.
Prevention
Enabled CA2000 and CA1063, added a load test with a memory ceiling, and added a memory alert on process working set, not just GC heap.

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.

↩ Usually asked after

More in Core OOP

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me