Engineering Notes
Home/Languages/C#/The CLR and Garbage Collection
Languages · C#

The CLR and Garbage Collection

How .NET actually runs your code: IL and the JIT, the generational garbage collector, Server vs Workstation GC, deterministic cleanup with IDisposable, and how memory leaks happen in a managed language.

💻 Languages● Advanced⏱ 4 min read🛠️ 2 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • IL, JIT, tiered compilation, ReadyToRun and Native AOT
  • Generations, LOH and how the GC finds live objects
  • Server GC, background GC and container limits
  • IDisposable, finalizers and real-world memory leaks

📚 Before you start

1What the CLR Does

The Common Language Runtime runs .NET code. C# compiles to IL (intermediate language) plus metadata in an assembly; at runtime the JIT compiler turns IL into machine code method by method (with tiered compilation: a quick tier-0 version first, then an optimized tier-1 version for hot methods, guided by profiling). The CLR also provides type safety, exception handling, threads, and automatic memory management.

text
C# source → Roslyn compiler → IL + metadata (.dll) → JIT (tier 0 → tier 1, PGO) → machine code
                                                    └ or ReadyToRun / Native AOT: compiled ahead of time

ReadyToRun precompiles IL to native code to speed up startup (still JITs hot paths later). Native AOT compiles everything ahead of time into a self-contained executable: very fast startup and small memory, but no runtime code generation, so reflection-heavy code needs care.

2The Managed Heap and Generations

Reference-type objects live on the managed heap. The GC is generational, based on the observation that most objects die young:

GenerationContainsCollected
Gen 0Newly allocated objectsVery often, very cheaply
Gen 1Survivors of one gen-0 collection (a buffer)Often
Gen 2Long-lived objects (caches, singletons)Rarely; a full (expensive) collection
LOH (Large Object Heap)Objects ≥ 85,000 bytesOnly with gen 2; not compacted by default
POH (Pinned Object Heap)Objects allocated as pinnedAvoids fragmenting other heaps

Allocation is just bumping a pointer, so it's very fast. A collection marks objects reachable from roots (static fields, locals on thread stacks, CPU registers, GC handles), then sweeps/compacts the rest. Survivors get promoted to the next generation.

3Workstation vs Server GC, Concurrent Collection

  • Workstation GC: one heap, tuned for responsiveness of desktop apps.
  • Server GC: one heap and GC thread per core, much higher throughput; the default for ASP.NET Core.
  • Background (concurrent) GC collects gen 2 while application threads keep running, with only short pauses.
  • DATAS (dynamic adaptation, .NET 8+) lets Server GC shrink and grow the number of heaps to save memory in containers.

Settings live in the project file or runtimeconfig.json (<ServerGarbageCollector>, <ConcurrentGarbageCollection>), and the GC respects container memory limits.

4Deterministic Cleanup: IDisposable

The GC only manages memory. Unmanaged resources (file handles, sockets, database connections) must be released explicitly:

csharp
using var stream = File.OpenRead(path);   // Dispose() called at end of scope, even on exceptions
await using var conn = new NpgsqlConnection(cs);  // IAsyncDisposable
  • Implement IDisposable when your class owns a disposable resource; dispose what you own, not what you were given.
  • Finalizers (~MyType()) run on a finalizer thread at some unknown time after an object becomes unreachable; they delay collection by a generation and should only exist as a safety net for raw unmanaged handles. Prefer SafeHandle.
  • GC.SuppressFinalize(this) in Dispose() skips the finalizer when cleanup already happened.

5Memory Leaks in a Managed Language

The GC can't free objects that are still reachable. Typical leaks:

  • Event handlers: a long-lived publisher's event holds references to every subscriber that never unsubscribes.
  • Static collections and caches that only grow (use bounded caches like MemoryCache with size limits/expiry).
  • Captured closures and timers that keep objects alive.
  • Captive dependencies in DI: a singleton holding scoped objects.

Diagnose with dotnet-counters (heap size, gen counts, allocation rate), dotnet-gcdump/dotnet-dump and heap analysis in Visual Studio, Rider or PerfView: compare snapshots and look at retention paths.

⚠️ Common Pitfalls

  1. Calling GC.Collect() to "fix" memory: it usually makes things slower; find the leak instead.
  2. Not disposing HttpResponseMessage, streams, DB connections, CancellationTokenSource with timers.
  3. Creating HttpClient per request (socket exhaustion); use IHttpClientFactory.
  4. Allocating large buffers repeatedly (LOH churn); pool them with ArrayPool<T>.
  5. Finalizers on ordinary classes: they slow the GC for no benefit.

📋 Quick Reference

text
C# → IL → JIT (tiered, PGO) · ReadyToRun = faster startup · Native AOT = no JIT, limited reflection
Gen0 (young, cheap) → Gen1 → Gen2 (full GC) · LOH ≥ 85,000 B · POH for pinned
Server GC (ASP.NET default) · background GC for gen2 · DATAS adapts heap count
IDisposable/using for unmanaged resources · finalizers only as a safety net (SafeHandle)
leaks = still reachable: events, static caches, closures, captive DI deps · dotnet-counters / gcdump

🧠 Key takeaways

  • C# compiles to IL; the JIT (tiered, PGO) produces machine code; Native AOT skips the JIT.
  • The GC is generational: gen 0 is cheap, gen 2 is a full collection; objects ≥ 85 KB go to the LOH.
  • ASP.NET Core uses Server GC; the GC respects container limits.
  • Use IDisposable/using for unmanaged resources; managed leaks are objects that stay reachable.

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1Explain how .NET garbage collection works.core▾

The GC tracks objects on the managed heap in generations 0, 1 and 2 (plus LOH/POH). A collection marks objects reachable from roots (statics, stack locals, registers, handles), then sweeps and compacts unreachable ones, promoting survivors. Gen 0 collections are frequent and cheap; gen 2 are full and expensive; background GC collects gen 2 concurrently.

Follow-ups interviewers ask
  • What's a GC root? A starting reference: static fields, locals/registers of running methods, GC handles.
Q2Workstation vs Server GC?core▾

Workstation: one heap, optimized for UI responsiveness. Server: a heap and GC thread per core, optimized for throughput, default in ASP.NET Core; uses more memory, which DATAS (dynamic adaptation) mitigates in .NET 8+.

Follow-ups interviewers ask
  • Where do you configure it? <ServerGarbageCollector> in the csproj or runtimeconfig.json.
Q3Dispose vs finalizer?core▾

Dispose is deterministic cleanup called by your code (using); a finalizer runs non-deterministically on the finalizer thread after the object becomes unreachable and delays its collection. Use finalizers only as a safety net for raw unmanaged handles (prefer SafeHandle) and call GC.SuppressFinalize in Dispose.

Follow-ups interviewers ask
  • Do most classes need a finalizer? No.
Q4How can a .NET app leak memory?senior▾

Objects stay reachable: event subscriptions not removed, unbounded static caches, closures/timers capturing objects, captive DI dependencies, or unmanaged resources never disposed. Diagnose with dotnet-counters, gcdump snapshots and retention paths.

Follow-ups interviewers ask
  • Is calling GC.Collect a fix? No; it hides symptoms and hurts performance.
Q5What are tiered compilation, ReadyToRun and Native AOT?core▾

Tiered compilation JITs a quick version first and recompiles hot methods with optimizations (and dynamic PGO). ReadyToRun ships precompiled code to speed startup. Native AOT compiles fully ahead of time: fast startup, small memory, no JIT, limited dynamic code/reflection.

Follow-ups interviewers ask
  • When choose Native AOT? Serverless, CLIs and containers where startup and memory matter.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.

✅ Check yourself

Answer the questions — feedback is instant.

1. Where does a 100 KB byte array go?
≥ 85,000 bytes → LOH.
2. Why is gen 0 collection cheap?
Generational hypothesis.
3. A long-lived publisher holds event handlers of disposed widgets. What is it?
Unsubscribe.
4. When should a class implement IDisposable?
Ownership.