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.
🎯 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.
C# source → Roslyn compiler → IL + metadata (.dll) → JIT (tier 0 → tier 1, PGO) → machine code
└ or ReadyToRun / Native AOT: compiled ahead of timeReadyToRun 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:
| Generation | Contains | Collected |
|---|---|---|
| Gen 0 | Newly allocated objects | Very often, very cheaply |
| Gen 1 | Survivors of one gen-0 collection (a buffer) | Often |
| Gen 2 | Long-lived objects (caches, singletons) | Rarely; a full (expensive) collection |
| LOH (Large Object Heap) | Objects ≥ 85,000 bytes | Only with gen 2; not compacted by default |
| POH (Pinned Object Heap) | Objects allocated as pinned | Avoids 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:
using var stream = File.OpenRead(path); // Dispose() called at end of scope, even on exceptions
await using var conn = new NpgsqlConnection(cs); // IAsyncDisposable- Implement
IDisposablewhen 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. PreferSafeHandle. GC.SuppressFinalize(this)inDispose()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
MemoryCachewith 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
- Calling
GC.Collect()to "fix" memory: it usually makes things slower; find the leak instead. - Not disposing
HttpResponseMessage, streams, DB connections,CancellationTokenSourcewith timers. - Creating
HttpClientper request (socket exhaustion); useIHttpClientFactory. - Allocating large buffers repeatedly (LOH churn); pool them with
ArrayPool<T>. - Finalizers on ordinary classes: they slow the GC for no benefit.
📋 Quick Reference
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
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.
- 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+.
- 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.
- 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.
- 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.
- 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.