GC Tuning & Diagnosing
Key Points
- First, measure.
dotnet-countersanddotnet-trace(with the GC provider) are the front-line tools. - Finalizers are not your friend. They run on a single, low-priority thread, in undefined order, with full gen2 promotion. Implement
IDisposable/IAsyncDisposableandGC.SuppressFinalize(this). SafeHandlebeats raw finalizers for unmanaged resources. The runtime guarantees release even if your code throws.WeakReference<T>/ConditionalWeakTable<TKey, TValue>for caches that should not extend object lifetime.dotnet-gcdumpcaptures heap snapshots without a process dump. Smaller, safer for prod analysis.GC.GetGCMemoryInfo()is the modern way to inspect heap state (per-generation sizes, fragmentation, pause times).GC.GetTotalMemory()is a coarse legacy API.- Latency tuning (
GCSettings.LatencyMode,GC.TryStartNoGCRegion) is a narrow tool. Don't reach for it before profiling.
Concepts (deep dive)
The diagnostic workflow
┌──────────────────────────────────────────────────────────────┐
│ Symptom: latency spikes / memory growth / OOM in container │
└──────────────────────────────┬───────────────────────────────┘
│
▼
┌────────────────────────┐
│ dotnet-counters │ ← live counters
│ gen-{0,1,2}-gc-count │ (cheap, run for minutes)
│ gen-{0,1,2}-size │
│ loh-size, poh-size │
│ gc-fragmentation │
│ alloc-rate │
└───────────┬────────────┘
│ pattern identified?
▼
┌────────────────────────┐
│ dotnet-trace │ ← event-level trace
│ --providers │ of GC + allocations
│ Microsoft-Windows- │
│ DotNETRuntime:0x1 │ (GC keyword)
└───────────┬────────────┘
│ heap retention?
▼
┌────────────────────────┐
│ dotnet-gcdump │ ← heap snapshot;
│ collect │ analyze in PerfView
└────────────────────────┘ or VS heap diff
You almost always start from counters. Don't gcdump cold — too much heap to chew on.
Finalizers: the rules
When a class declares ~ClassName(), the C# compiler emits a Finalize method override. The runtime puts every instance of that type on the finalization queue at allocation. When such an object becomes unreachable, the GC moves it to the f-reachable queue, where the finalizer thread picks it up and calls the finalizer. Only after the finalizer runs can the object actually be collected — on the next gen2 cycle.
Consequences:
- Finalizable objects always survive at least one collection. They're inherently gen2-bound.
- Order is unspecified. Finalizable A holding a reference to finalizable B does not guarantee A finalizes before B.
- The finalizer thread is single. A long-running or stuck finalizer backs up the queue → memory leak.
- Finalizers run during runtime shutdown only if
Environment.ExitCode != 0and within a 2-second budget. Don't depend on them for "guaranteed cleanup".
The dispose pattern threads the needle:
public sealed class HostedFile : IDisposable
{
private IntPtr _handle;
private bool _disposed;
public HostedFile(string path)
=> _handle = NativeApi.Open(path);
public void Dispose()
{
if (_disposed) return;
NativeApi.Close(_handle);
_disposed = true;
GC.SuppressFinalize(this); // tell GC: skip my finalizer
}
~HostedFile() => NativeApi.Close(_handle); // safety net only
}
GC.SuppressFinalize(this) removes the object from the finalization queue, avoiding the gen2 promotion penalty for properly disposed instances. It's free if you've already disposed; it's the difference between a clean object lifecycle and a finalizer-queued zombie.
SafeHandle — the modern way
internal sealed class FileSafeHandle : SafeHandle
{
public FileSafeHandle() : base(IntPtr.Zero, ownsHandle: true) { }
public override bool IsInvalid => handle == IntPtr.Zero;
protected override bool ReleaseHandle()
{
return NativeApi.Close(handle); // runtime guarantees this runs
}
}
SafeHandle is the unmanaged-resource owner the runtime treats specially:
- The handle is released even if the AppDomain is unloaded or the process is unwinding due to an unhandled exception (within a constrained execution region).
- P/Invoke marshalers cooperate: a
SafeHandleargument is reference-counted across the call, so it can't be released mid-call by another thread. - You don't need to write a finalizer in your domain types — wrap the
SafeHandlein ausingand you're done.
Use SafeHandle for every native handle you wrap. Always.
Weak references
WeakReference<MyObject> weak = new(myObj);
// later...
if (weak.TryGetTarget(out var resurrected))
Use(resurrected);
WeakReference<T> does not keep its target alive. Useful for caches: "if the value is still around, give it to me; otherwise I'll re-create it."
ConditionalWeakTable<TKey, TValue> is the strict-stronger sibling: keys are weakly referenced, and the value is kept alive only as long as the key is alive. Used for attaching state to objects you don't own (e.g., Roslyn associates analyzer state with syntax nodes).
Counters & memory info
var info = GC.GetGCMemoryInfo();
Console.WriteLine($"Heap size: {info.HeapSizeBytes / 1024.0 / 1024.0:N1} MB");
Console.WriteLine($"Fragmented: {info.FragmentedBytes / 1024.0 / 1024.0:N1} MB");
Console.WriteLine($"Last pause: {info.PauseDurations[0].TotalMilliseconds:N1} ms");
Console.WriteLine($"Compacted: {info.Compacted}");
Console.WriteLine($"Concurrent: {info.Concurrent}");
foreach (var gi in info.GenerationInfo)
{
Console.WriteLine($" Gen {Array.IndexOf(info.GenerationInfo, gi)}:");
Console.WriteLine($" Size before: {gi.SizeBeforeBytes:N0}");
Console.WriteLine($" Size after: {gi.SizeAfterBytes:N0}");
Console.WriteLine($" Fragmented: {gi.FragmentationAfterBytes:N0}");
}
GC.GetTotalAllocatedBytes(precise: false) gives you a cheap running total of bytes ever allocated — handy as an allocation rate proxy in custom metrics.
dotnet-counters cheat sheet
# Live monitor a process by name
dotnet-counters monitor --name MyApp --counters System.Runtime,Microsoft.AspNetCore.Hosting
# Just GC counters
dotnet-counters monitor --name MyApp --counters System.Runtime[gen-0-gc-count,gen-1-gc-count,gen-2-gc-count,gen-2-size,loh-size,poh-size,gc-fragmentation,alloc-rate]
# Save to file for offline analysis
dotnet-counters collect --name MyApp --output traces.csv --duration 00:05:00
Key counters:
| Counter | Tells you |
|---|---|
gen-{0,1,2}-gc-count | Frequency of each-generation collections |
gen-{0,1,2}-size | Heap occupancy after the last collection |
loh-size, poh-size | Large/pinned heap sizes |
gc-fragmentation | Fragmentation as a percentage |
alloc-rate | Bytes/sec allocated |
time-in-gc | % of recent CPU spent in GC |
Rules of thumb: - time-in-gc > 5% under steady-state load → start investigating. - gen-2-gc-count rising linearly → retention. Heap dump time. - gc-fragmentation > 30% → LOH churn or long-lived large objects coming and going.
dotnet-trace for GC events
dotnet-trace collect --name MyApp \
--providers Microsoft-Windows-DotNETRuntime:0x1:5 \
--duration 00:01:00
Provider keyword 0x1 is the GC keyword; :5 is verbose level. Open the trace in PerfView (or convert to speedscope) and look at:
- GC events — every collection, with type (gen0/gen1/gen2/foreground/background) and duration
- Allocation tick events — sampled allocations by type
- GC heap stats — periodic heap counts
dotnet-gcdump for retention analysis
⚠️ Warning:
dotnet-gcdumptriggers a full gen2 collection in the target process. Plan for the pause.
Open the .gcdump in PerfView (File > Open → select the .gcdump) and use the Heap Snapshot view. The killer feature is Compare Heap Snapshots — diff two captures taken minutes apart to find what's growing.
Latency tuning (rare, last-resort)
GCSettings.LatencyMode was covered in GC Fundamentals. Two more advanced tools:
GC.TryStartNoGCRegion(totalSize) — guarantees no GC for the next totalSize bytes of allocation. Use only for the single hottest critical section you have. Allocate beyond budget → InvalidOperationException on EndNoGCRegion. The runtime pre-commits memory; if it can't, the call returns false.
const long Budget = 8 * 1024 * 1024; // 8 MB
if (GC.TryStartNoGCRegion(Budget))
{
try
{
ProcessLatencyCriticalRequest(); // measured: ~3 MB allocations
}
finally
{
GC.EndNoGCRegion();
}
}
else
{
// GC couldn't reserve memory; fall back to normal path
ProcessLatencyCriticalRequest();
}
GC.RegisterForFullGCNotification(maxGenThreshold, largeObjectHeapThreshold) — get a notification before a full GC kicks in. Lets you stop accepting traffic, drain a queue, then trigger the collection yourself when it's safe. Niche but real for trading/streaming systems.
Code: correct vs wrong
❌ Wrong: a finalizer doing real work
public class CacheEntry
{
private static readonly List<CacheEntry> _all = new();
public CacheEntry() { lock (_all) _all.Add(this); }
~CacheEntry()
{
// ❌ runs on finalizer thread, takes a lock, may block, may throw
lock (_all) _all.Remove(this);
Telemetry.Log($"Removed {this}"); // ❌ may throw → process crash
}
}
Three problems: (1) finalizer thread is a serialization point — make this slow and you back up the queue; (2) taking a lock from a finalizer can deadlock if any other code holds it on the application thread; (3) an unhandled exception from a finalizer terminates the process in modern .NET.
✅ Correct: explicit lifecycle + dispose pattern
public sealed class CacheEntry : IDisposable
{
private static readonly ConcurrentDictionary<int, CacheEntry> _all = new();
private readonly int _id;
private bool _disposed;
public CacheEntry(int id)
{
_id = id;
_all[id] = this;
}
public void Dispose()
{
if (_disposed) return;
_all.TryRemove(_id, out _);
_disposed = true;
// No finalizer at all — no need for SuppressFinalize.
}
}
❌ Wrong: assuming GC.GetTotalMemory(false) gives a useful number
var before = GC.GetTotalMemory(forceFullCollection: false);
DoWork();
var after = GC.GetTotalMemory(forceFullCollection: false);
Console.WriteLine($"Used: {after - before} bytes");
// Almost certainly wrong: the GC may have collected during DoWork(),
// the number doesn't include all heaps, and you don't know whether
// gen0/1 had been collected.
✅ Correct: use GC.GetGCMemoryInfo() for current state, GC.GetTotalAllocatedBytes() for cumulative allocation
var beforeAlloc = GC.GetTotalAllocatedBytes(precise: false);
DoWork();
var afterAlloc = GC.GetTotalAllocatedBytes(precise: false);
Console.WriteLine($"Allocated during DoWork: {afterAlloc - beforeAlloc:N0} bytes");
// For point-in-time heap state:
var info = GC.GetGCMemoryInfo();
Console.WriteLine($"Heap size: {info.HeapSizeBytes:N0}, fragmented: {info.FragmentedBytes:N0}");
❌ Wrong: cache that prevents collection
private static readonly Dictionary<string, byte[]> _cache = new();
// _cache holds strong refs forever
✅ Correct: weak-ref-backed cache
private static readonly ConditionalWeakTable<string, byte[]> _cache = new();
// Or, if you key by the buffer-owning object:
private static readonly ConditionalWeakTable<MyOwner, MyDerived> _attached = new();
For a real production cache, prefer MemoryCache from Microsoft.Extensions.Caching.Memory with size limits and eviction.
Design patterns for this topic
Pattern 1 — "SafeHandle, not finalizer"
- Intent: own an unmanaged resource without writing a finalizer.
- When to use it: any time you'd otherwise write
~MyType()to release native state. - When NOT to use it: managed resources (use
IDisposable). - Code sketch: see "SafeHandle" section above.
- Trade-offs:
SafeHandleadds a small reference-counting overhead per P/Invoke call. Worth it for correctness.
Pattern 2 — "Dispose with GC.SuppressFinalize only when you have a finalizer"
- Intent: don't pay the gen2 promotion cost for objects you've explicitly disposed.
- When to use it: classes that do declare a finalizer (legacy, must-have for correctness).
- When NOT to use it: classes without a finalizer.
SuppressFinalizeis a no-op there but signals intent confusingly.
Pattern 3 — "Weak caches with ConditionalWeakTable"
- Intent: attach extra state to objects you don't own without extending their lifetime.
- When to use it: Roslyn-style analyzers, decorators, debug instrumentation.
- When NOT to use it: real caches that should outlive their keys (use a real cache library).
Pattern 4 — "GC notification → drain → collect on idle"
- Intent: absorb GC pressure outside steady-state traffic.
- When to use it: trading platforms, real-time message processors with a separate "idle" channel.
- Code sketch:
GC.RegisterForFullGCNotification(maxGenThreshold: 10, largeObjectHeapThreshold: 10);
_ = Task.Run(async () =>
{
while (!stopping.IsCancellationRequested)
{
var status = GC.WaitForFullGCApproach(timeoutMilliseconds: 10_000);
if (status == GCNotificationStatus.Succeeded)
{
await StopAcceptingTrafficAndDrain();
GC.Collect(); // accept the cost while idle
await ResumeAcceptingTraffic();
GC.WaitForFullGCComplete();
}
}
});
- Trade-offs: real-time-only. For ordinary services, not worth the complexity.
Pattern 5 — "Bounded MemoryCache with eviction callbacks"
- Intent: caches with predictable memory footprint.
- Code sketch:
var options = new MemoryCacheOptions { SizeLimit = 100_000_000 }; // 100 MB approx
var cache = new MemoryCache(options);
cache.Set("key", value, new MemoryCacheEntryOptions {
Size = sizeOfValue,
SlidingExpiration = TimeSpan.FromMinutes(5)
});
Pros & cons / trade-offs
| Approach | Pros | Cons |
|---|---|---|
| Finalizer | "Safety net" cleanup | Gen2 promotion; single thread; unspecified order; can't throw |
IDisposable | Deterministic | Caller must call (use using) |
SafeHandle | Runtime-guaranteed release; P/Invoke aware | Slight per-call overhead |
WeakReference<T> | No lifetime extension | Caller must handle "gone" case |
ConditionalWeakTable | Tied to key lifetime | Not thread-safe enumeration |
MemoryCache | Bounded, with eviction | Size accounting is approximate |
When to use / when to avoid
- Use
IDisposable+usingfor every class holding any non-trivial resource — file handle, DB connection, lock, native buffer. - Use
SafeHandlefor native handles. Always. - Avoid finalizers unless you specifically need a backstop and
SafeHandledoesn't fit. - Avoid
GC.Collect()in app code. The exceptions: at end of a load test or benchmark setup, before sampling memory; or after a large batch you know is done. And even then, prefer counters. - Avoid
GC.GetTotalMemory(true)— forcing a full collection just to read a number is expensive and pollutes the GC's pacing.
Interview Q&A
Q1. What's the cost of having a finalizer? The object is automatically promoted to gen2 (because it must survive at least until the finalizer runs). The finalizer thread is a single serialization point. Order across instances is unspecified. Failure to release a Dispose-able resource until finalization is essentially a "memory hold" — the object lingers across multiple GC cycles.
Q2. When does GC.SuppressFinalize(this) help? When you have a finalizer and the user disposed properly. SuppressFinalize removes the instance from the finalization queue, so it can be collected normally (no gen2 promotion). Calling it on a class without a finalizer is a no-op.
Q3. Why is SafeHandle better than a finalizer for native resources? The runtime treats SafeHandle specially: P/Invoke marshalers reference-count it across the call (so a release on another thread can't happen mid-call), and the runtime guarantees ReleaseHandle() runs even during AppDomain unload or process tear-down within a constrained execution region.
Q4. Difference between WeakReference<T> and ConditionalWeakTable<K,V>? WeakReference<T> weakly references one target; if the target is collected, TryGetTarget returns false. ConditionalWeakTable<K,V> is a table whose keys are weak: each value is kept alive only as long as its specific key is. Crucially, the value's reachability does not extend the key's lifetime.
Q5. dotnet-counters shows time-in-gc at 12% under load. What do you do? That's high. Pull a dotnet-trace with the GC keyword and look at the GC event distribution: are these foreground gen2s? If so, allocation rate is exceeding what background GC can handle. Profile allocations (BenchmarkDotNet [MemoryDiagnoser] or dotnet-trace allocation events) and reduce the hot allocation paths.
Q6. How would you find a leak that's slowly growing gen2 over hours? 1) dotnet-gcdump two snapshots, ~10 minutes apart. 2) Open in PerfView, Compare Heap Snapshots. 3) Sort by "size delta" descending. 4) The top of the list is what's growing. Common offenders: unbounded caches, event-handler subscriptions never removed, captured closures in long-lived services.
Q7. Walk through the lifetime of a finalizable object. 1) Allocated; placed on finalization queue. 2) Becomes unreachable. 3) GC moves it to the f-reachable queue. 4) Finalizer thread pulls it, runs the finalizer. 5) On the next gen2 collection, it's actually collected. Total: at least one extra full collection of latency.
Q8. What's a constrained execution region (CER)? A region of code in which the runtime guarantees critical operations run to completion (e.g., handle release). Largely deprecated in modern .NET — SafeHandle.ReleaseHandle() is the user-facing equivalent today.
Q9. How do you measure allocation rate in a running app? GC.GetTotalAllocatedBytes(precise: false) gives a cumulative byte count. Sample once a second; subtract previous; divide. Or use dotnet-counters alloc-rate. For per-allocation type breakdown, dotnet-trace with the GC provider plus a sample-based allocation event.
Q10. Why might dotnet-gcdump produce a tiny dump while my process uses gigabytes? dotnet-gcdump reports managed heap. If your large memory is unmanaged (P/Invoke buffers, MemoryMappedFile, native allocations), it doesn't show. Use dotnet-dump for full process dumps or vmmap for the OS view.
Q11. What does GC.TryStartNoGCRegion actually do under the hood? The runtime tries to commit totalSize bytes ahead of time. If it succeeds, GC is suppressed for the next totalSize bytes of allocation. Allocations beyond budget cause EndNoGCRegion to throw. If commit fails, it returns false — your code must handle both paths.
Q12. You see gc-fragmentation at 45%. Where do you look? LOH first (allocations near 85 KB churn). After that: long-lived large arrays where some are released and some retained. Use GC.GetGCMemoryInfo().GenerationInfo[3].FragmentationAfterBytes (LOH) and [4] (POH) for per-heap fragmentation.
Q13. What's the performance impact of [ThreadStatic] vs AsyncLocal<T> from a GC perspective? [ThreadStatic] lives in the thread's TLS — no allocation on read/write, no GC cost. AsyncLocal<T> flows through ExecutionContext, which means each captured value is part of the EC and survives across awaits — heavier and persists longer, which can pin object graphs in gen2.
Q14. Practical advice if a customer reports "memory leak" in your service? 1) Capture two dotnet-gcdumps 5–10 minutes apart at moderate load. 2) Compare in PerfView. 3) If managed heap isn't growing, look at unmanaged (PInvoke / native libs / handle leaks via lsof / Sysinternals Handle). 4) If managed is growing, identify the type from the diff. 5) Trace ownership: who roots it? Static collection? Event handler? AsyncLocal?
Gotchas / common mistakes
- ⚠️ Finalizer that throws — modern .NET treats it as a process-killing exception. Don't.
- ⚠️ Forgetting
SuppressFinalizein your dispose pattern when you have a finalizer — extra gen2 promotion for every disposed instance. - ⚠️ Subscribing event handlers in long-lived services without unsubscribing — the publisher keeps a strong reference to the subscriber, including its closure.
- ⚠️ Putting heavy state in
AsyncLocal<T>— it flows everywhere; small leaks compound. - ⚠️ Calling
dotnet-gcdumprepeatedly in production — each call forces a gen2 + full mark. Preferdotnet-countersfor ongoing observability. - ⚠️ Using
WeakReference<T>for primary cache without a regeneration path — collections will happen, you'll get cache misses, you may not handle the miss correctly. - ⚠️ Setting
GCSettings.LatencyMode = LowLatencyand forgetting to reset it — long-term suppression of gen2 → eventual OOM.