.NET Memory & Garbage Collection

Table of contents
- TL;DR - Think “Stack first, Heap when you must.”
- 1. Memory 101 - Kitchen Counter vs. Warehouse Analogy
- 2. Value Types, Reference Types & Boxing : Why That One int Hurts
- 3. Generational Heap - Three Recycling Bins and a Bulk-Trash Corner
- 4. What Exactly Happens During a GC?
- 5. Choosing a GC Mode (and Tuning It)
- 6 .NET 8 & .NET 9 - What Changed Under the Hood?
- 7. LOH & Realistic Array Strategy
- 8. Diagnostics in Three Commands
- 9. Reality-Checked Best Practices
- Key Takeaways
- Further Reading
(covers .NET 8 → 9 optimisations, realistic stack-vs-heap guidance, boxing costs, LOH tuning, GC-mode selection, and diagnostics)
TL;DR - Think “Stack first, Heap when you must.”
The thread-stack is a lightning-fast workbench cleared automatically when a method returns, the managed heap is a huge warehouse that needs the GC’s fork-lifts to tidy up. Put quick-use tools (tiny structs, scratch buffers) on the bench, store long-term gear (shared objects, large arrays) in the warehouse. Every GC run is a warehouse-wide sweep that can stall workers, so minimise how often you trigger it.
Why the Stack Rocks | Pragmatic, real-world Actions |
Zero GC cost – pushing/popping is just pointer math. | • Use small value-type structs for immutable payloads. |
• Slice existing data with Span<T> / ReadOnlySpan<T> instead of allocating new arrays. | |
CPU-cache friendly – data lives beside your call-frame. | • Inline small structs inside parent objects when locality matters. |
Predictable latency – never pauses for GC. | • Allocate tight scratch space with stackalloc on the hottest paths. |
No fragmentation worries – whole frame vanishes on return. | • Keep each per-call buffer modest (< 16 KB) to avoid stack overflows. |
Arrays are fine - just pool or reuse large ones so they don’t keep bloating the Large-Object Heap (LOH).
1. Memory 101 - Kitchen Counter vs. Warehouse Analogy
Thread Stack = Kitchen Counter: small, fast, cleared when the cook (method) finishes.
Managed Heap = Warehouse: huge, shared across cooks, needs a night-shift cleaning crew (GC) to keep it tidy.
The CLR bumps a pointer for each new heap allocation cheap until the segment fills, then stops the world for a cleanup cycle.
Span<int> scratch = stackalloc int[4]; // goes on the “counter”
scratch[0] = 42;
2. Value Types, Reference Types & Boxing : Why That One int
Hurts
Value types (structs) live on the counter (stack) or inline in arrays.
Reference types (classes, arrays) live in the warehouse (heap) and are reached via pointers on the stack.
object boxed = 42;
sneakily wraps the int
in a heap object, creating Gen 0 garbage every loop iteration. Swap to generics (List<int>
), spans, or in
parameters to avoid accidental boxing.
3. Generational Heap - Three Recycling Bins and a Bulk-Trash Corner
Region | Typical Size* | Compaction | Use-case |
Gen 0 | 256 KB–2 MB | ✔ | “Paper scraps” - most objects die here |
Gen 1 | ≈ 4×Gen 0 | ✔ | Buffer for survivors |
Gen 2 | Up to proc limit | ✔ | Long-living singletons, caches |
LOH | ≥ 85 KB | ✖ | Bulk trash - big arrays/bitmaps |
POH (.NET 5+) | pinned | ✖ | Buffers shared with native code |
\segment sizes expand/contract with allocation rate.* Objects that endure one pickup are simply promoted - no data copy needed, so 90 % of collections touch only Gen 0.
4. What Exactly Happens During a GC?
Mark: trace from roots (stacks, statics, CPU registers) and paint live objects.
Plan/Relocate: compute new addresses for survivors.
Compact: slide Gen 0/1/2 objects together to close gaps.
Fix-up: patch every reference so pointers stay valid.
A tiny write-barrier on every ref
assignment updates a card table, letting the GC skip untouched memory pages and finish faster.
5. Choosing a GC Mode (and Tuning It)
Mode | Best-fit Scenario | Heaps per Process | Pause Pattern |
Workstation | Desktop/UI, CLI tools, memory-tight containers | 1 | Ultra-short, single-threaded |
Server | High-throughput APIs & micro-services | 1 per logical CPU | Parallel, fewer but longer |
Background | Overlay for both - adds concurrent Gen 2 collection | inherits | Micro-stalls only |
<!-- Throughput-optimised worker service -->
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
Key knobs you might touch:
Config Flag | Why Touch It |
GarbageCollectionAdaptationMode | Controls DATAS auto-shrink (0 = off). |
GCHeapHardLimitPercent | Cap heap in small containers. |
DOTNET_GCHighMemPercent | Pull full-GC forward/back based on RAM pressure. |
GCLatencyMode (API) | Switch to LowLatency during real-time bursts. |
Workstation maximises low pause/low footprint, Server maximises throughput, Background keeps Gen 2 sweeping concurrent so UI never freezes.
6 .NET 8 & .NET 9 - What Changed Under the Hood?
Release | GC / Runtime Upgrade | Practical Win |
.NET 8 | Faster concurrent marking; smarter full-GC trigger; leaner finalizer queue; Native-AOT ready | P99 latency drops ~5 % on typical APIs |
.NET 9 | DATAS ON by default (Server GC) so heaps shrink when traffic calms, cross-platform vxsort speeds Gen 2 compactio, JIT can stack-allocate provably non-escaping boxes | Up to 20 % memory drop & 15 % lower tail-latency in real load tests |
Disable DATAS only if you must keep fixed-size heaps:
<PropertyGroup>
<GarbageCollectionAdaptationMode>0</GarbageCollectionAdaptationMode>
</PropertyGroup>
7. LOH & Realistic Array Strategy
Large arrays (≥ 85 KB) skip compaction, so holes build up like Swiss cheese.
Pool big buffers with ArrayPool<T>
or keep a single reusable field. Don’t fear arrays, just avoid spraying fresh 100-KB blocks each request.
byte[] buf = ArrayPool<byte>.Shared.Rent(128 * 1024);
try { /* work */ }
finally { ArrayPool<byte>.Shared.Return(buf); }
8. Diagnostics in Three Commands
Tool | Quick Start | What You See |
dotnet-counters | dotnet-counters monitor -p 1234 | Live Gen counts, heap size |
dotnet-gcdump | dotnet-gcdump collect -p 1234 | Heap snapshot → open in VS |
PerfView | Record → view “GCStats” | Timeline of pauses vs. requests |
Generate .gcs traces to compare pause length across GC modes and catch regressions before prod.
9. Reality-Checked Best Practices
Design for short-lived allocations – most objects should die in Gen 0.
Kill boxing – generics & spans keep primitives on the stack.
Pool or reuse big buffers – keeps the LOH tidy.
Use
Span<T>
/Memory<T>
for zero-allocation slicing.Pick the right GC mode – Server + DATAS for services; Workstation for GUIs.
Profile first, tune second – data beats folklore every time.
Key Takeaways
The GC is your friend when you respect the heap*.*
Lean on the stack for high-frequency, short-lived work, let the heap host genuinely shared or long-lived objects. With .NET 9’s smarter, self-shrinking Server GC and disciplined allocation patterns, you ship apps that stay fast, memory-slim, and pause-free.
Further Reading
.NET GC fundamentals – Microsoft Docs learn.microsoft.com
DATAS overview – Microsoft Docs learn.microsoft.com
.NET 8 improvements – Justin Miller medium.com
GC configuration reference – Microsoft Docs learn.microsoft.com
Subscribe to my newsletter
Read articles from Sagar HS directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Sagar HS
Sagar HS
Software engineer with 4 + years delivering high-performance .NET APIs, polished React front-ends, and hands-off CI/CD pipelines. Hackathon quests include AgroCropProtocol, a crop-insurance DApp recognised with a World coin pool prize and ZK Memory Organ, a zk-SNARK privacy prototype highlighted by Torus at ETH-Oxford. Recent experiments like Fracture Log keep me exploring AI observability.