How allocation works on the managed heap (bump-pointer allocation in per-thread contexts), how generations and promotion keep collections cheap, when objects go to the large object heap, why pinning matters and the pinned object heap, and how to measure and reduce allocations.
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.
new Order() moves a pointer forward by the object's size and writes the header and method table pointer. This takes a few nanoseconds, similar to stack allocation.| Generation | Holds | Collected | Cost |
|---|---|---|---|
| Gen 0 | New objects | Very often | Cheap: few survivors to copy |
| Gen 1 | Survivors of one GC (a buffer between young and old) | Often | Cheap to moderate |
| Gen 2 | Long-lived objects (caches, singletons, static data) | Rarely | Expensive: marks the whole heap; usually done as a background GC concurrently with your threads |
| LOH | Objects ≥ 85,000 bytes (mostly big arrays and strings) | With gen 2 | Not compacted by default, so it can fragment |
| POH | Objects allocated pinned (GC.AllocateArray(..., pinned: true)) |
With gen 2 | Never moved, by design |
The GC finds live objects by tracing from roots (stack slots and registers, statics, GC handles), then compacts survivors to remove gaps and updates references. A card table tracks old objects that point to young ones, so gen 0 GCs don't need to scan all of gen 2.
MemoryStream growth, File.ReadAllBytes of big files, large StringBuilder.ToString(), big JSON payloads read into strings, List<T> growing beyond about 10,000 references.ArrayPool<T>.Shared, RecyclableMemoryStream, streaming APIs (PipeReader, Stream copy, JsonSerializer.DeserializeAsync on a stream), and right-sized collections.GCSettings.LargeObjectHeapCompactionMode = CompactOnce, which you should rarely need.The GC now manages memory as many smaller regions that can be reassigned between generations, instead of a few large segments. This makes it easier to return memory to the OS and handle varied workloads; you rarely need to think about it, but it explains why memory behaviour changed between .NET 6 and later versions.
An API that returned reports read each upstream response with await response.Content.ReadAsStringAsync() and then deserialised it. Responses were 200–800 KB, so every request allocated large strings on the LOH, and gen 2 GCs ran every few seconds with visible latency spikes. Deserialising directly from the response stream (ReadFromJsonAsync) removed the large strings, and gen 2 GCs dropped to a few per hour.
Checks a solid working model of managed memory that underpins performance work.
Green flags: explains bump allocation and allocation contexts, generations and promotion, the 85,000-byte LOH threshold and why it matters, pinning and the POH, and how to measure and reduce allocations.
Red flags: "the GC is magic"; recommends GC.Collect(); doesn't know about the LOH; thinks every allocation is expensive in itself.
gen 0 region
[ T1 context: obj obj obj | free ] [ T2 context: obj obj | free ] [ unused ... ]
^ alloc pointer (bump)
budget exhausted -> gen 0 GC:
1. suspend managed threads (at safe points)
2. mark from roots: stacks, registers, statics, handles, card table (old -> young refs)
3. compact or promote survivors into gen 1
4. update references, resume threads
gen 0 is empty again -> fast bump allocation continues
Cost of a collection is roughly proportional to surviving objects, not garbage. If 95% of gen 0 objects are dead, a gen 0 GC copies 5% and is done in well under a millisecond for typical heaps. Problems start when objects live just long enough to be promoted (for example request-scoped objects held across a slow await), which is called a mid-life crisis: they're expensive to promote and then die in gen 2.
byte[] of 84,976 bytes or more crosses it on 64-bit.double[] of 1,000 elements is 8 KB (small object heap); of 10,700 elements it's on the LOH.string of about 42,500 characters is on the LOH (2 bytes per char).| Tool | Use |
|---|---|
BenchmarkDotNet [MemoryDiagnoser] |
Bytes allocated per operation, gen 0/1/2 counts |
GC.GetAllocatedBytesForCurrentThread() |
Quick in-code measurement in tests |
dotnet-counters (alloc-rate, gen-0-gc-count, loh-size) |
Live production view |
Visual Studio .NET Object Allocation tool, PerfView, dotnet-trace with the gc-verbose profile |
Which code allocates what |
| Pattern | Replace with |
|---|---|
| Substring and split for parsing | ReadOnlySpan<char>, MemoryExtensions.Split (.NET 9+), Utf8Parser |
| Temporary arrays | stackalloc (small), ArrayPool<T>.Shared.Rent/Return |
| String building in loops | StringBuilder (pooled), interpolated string handlers, string.Create |
| LINQ in hot loops | Plain loops over spans |
Boxing via object/non-generic APIs |
Generic APIs, [LoggerMessage] source-generated logging |
| Reading whole payloads into strings | Stream-based deserialisation |
| Many small objects | Structs or struct arrays where semantics allow |
| Mode | Default for | Trade-off |
|---|---|---|
| Workstation, concurrent | Desktop and console apps | Lower memory, single heap |
| Server | ASP.NET Core | Heap per core, higher throughput and memory; DATAS adapts heap count (default since .NET 9) |
| Background GC | Both (on by default) | Gen 2 marking runs concurrently, reducing pauses |
new byte[80_000] versus new byte[90_000] in a loop with [MemoryDiagnoser] and compare gen 2 counts.ArrayPool<byte>.Shared.Rent and Return, and compare again.dotnet-counters for an ASP.NET Core endpoint that reads a 500 KB response as a string, then change it to deserialise from the stream.GC.GetGeneration(obj) before and after GC.Collect() calls in a small test to see promotion.namespace ManagedHeap;
using System.Buffers;
using System.Net.Http.Json;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser]
public class LohBenchmarks
{
[Benchmark(Baseline = true)]
public int SmallObjectHeap() => new byte[80_000].Length; // below the 85,000-byte threshold
[Benchmark]
public int LargeObjectHeap() => new byte[90_000].Length; // LOH: collected with gen 2
[Benchmark]
public int Pooled()
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(90_000); // reused, no new allocation
try { return buffer.Length; }
finally { ArrayPool<byte>.Shared.Return(buffer); }
}
}
public static class Program
{
public static void Main()
{
var order = new object();
Console.WriteLine(GC.GetGeneration(order)); // 0
GC.Collect();
Console.WriteLine(GC.GetGeneration(order)); // 1 (promoted)
GC.Collect();
Console.WriteLine(GC.GetGeneration(order)); // 2
GC.KeepAlive(order);
var pinned = GC.AllocateArray<byte>(64 * 1024, pinned: true); // pinned object heap
Console.WriteLine(GC.GetGeneration(pinned));
BenchmarkRunner.Run<LohBenchmarks>();
}
}
public sealed record Report(string Id, decimal Total);
public sealed class ReportClient(HttpClient http)
{
// Deserialise from the stream instead of building a large string first.
public Task<Report?> GetAsync(string id, CancellationToken ct) =>
http.GetFromJsonAsync<Report>($"reports/{id}", ct);
}
Mistake: Thinking allocation itself is slow.
Why it is wrong: the fast path is a pointer bump; the real cost is GC work later, especially for objects that survive.
Instead: focus on allocation rate and survival in hot paths, not on avoiding every new.
Mistake: Ignoring large temporary buffers.
Why it is wrong: short-lived objects of 85,000 bytes or more land on the LOH and drive expensive gen 2 collections.
Instead: pool large buffers (ArrayPool<T>, RecyclableMemoryStream), stream data, and avoid building big strings or arrays per request.
Mistake: Adding finalizers "to be safe".
Why it is wrong: a finalizable object is put on the finalization queue, survives at least one extra collection (so it is promoted), and its cleanup runs later on a single finalizer thread. Many finalizable objects add CPU, delay memory release and can back up the finalizer thread.
Instead: wrap native resources in SafeHandle, implement IDisposable, dispose deterministically with using, and call GC.SuppressFinalize(this) in Dispose when a finalizer exists.
Mistake: Calling GC.Collect() to "free memory".
Why it is wrong: it forces expensive full collections, promotes live objects prematurely and usually makes things worse.
Instead: fix allocations and lifetimes; use explicit collection only in rare, measured cases (for example after a one-off bulk load in a batch job).
Mistake: Holding request-scoped data in long-lived places. Why it is wrong: caching per-request objects in statics or singletons promotes them to gen 2 and grows the heap. Instead: keep request data request-scoped, and bound caches with size limits and expiry.
Mistake: Pinning buffers for long periods in gen 0. Why it is wrong: long-lived pins block compaction and fragment the young generation. Instead: use the pinned object heap or pooled native memory for long-lived I/O buffers.
Mistake: Tuning GC settings before measuring. Why it is wrong: the defaults are good for most services, and random tuning often trades one problem for another. Instead: measure with counters and traces, fix the allocation pattern, and change one setting at a time.
Seniors understand the GC well enough to work with it: allocation is cheap, survival is expensive, large and pinned objects are special, and gen 2 is where pauses and memory growth come from.
| GC mode | Heaps | Best for | Trade-off |
|---|---|---|---|
| Workstation (concurrent) | One | Desktop apps, small containers | Lower memory, less throughput on many cores |
| Server | One per core (DATAS adapts the count by default in current .NET) | ASP.NET Core, high-throughput services | More memory; GC threads per heap |
| Background GC | n/a (on by default) | Both | Gen 2 marking runs concurrently, shorter pauses |
| Allocation pattern | GC effect | Habit |
|---|---|---|
| Many short-lived small objects | Cheap gen 0 GCs | Usually fine; reduce only in hot paths |
| Objects held across slow awaits | Promoted then die (mid-life crisis) | Shorten lifetimes, avoid capturing large state |
| Large temporary buffers | LOH churn, gen 2 GCs | Pool or stream |
| Unbounded caches | Gen 2 growth | Size limits, expiry |
| Finalizable objects | Extra GC cycle, finalizer backlog | SafeHandle + Dispose |
GC configuration is the last lever, not the first; dashboards for allocation rate, gen 2 count, LOH size and % Time in GC show whether a code change worked.
RAM is physical memory. Each process gets a virtual address space that the OS maps onto RAM page by page. The working set is what's resident in RAM right now; private (committed) memory is what the process owns. The GC heap is only one part: native allocations (native libraries, NativeMemory, unmanaged buffers), thread stacks, JIT code, and runtime structures add to it. In Kubernetes, the cgroup limit counts all of it, and going over gets the container OOMKilled (exit code 137), not an OutOfMemoryException. So if the GC heap is small but the pod dies, I compare GC heap size with working set in dotnet-counters. A big gap points to native memory or too many threads; a growing GC heap points to a managed leak I'd chase with dotnet-gcdump. Then I fix the cause and set limits and GC settings deliberately.
The stack is a per-thread region that holds method frames: locals, arguments, return addresses and saved registers. Allocating is just moving the stack pointer, and everything is freed when the method returns. The heap is shared memory for objects whose lifetime is not tied to one method; the generational garbage collector frees them once they are unreachable.
The common line "value types live on the stack, reference types on the heap" is only half true. Storage follows the declaration: a value-type local usually lives on the stack (or in a register), but a value-type field lives inside its object on the heap, and boxing, lambdas that capture locals, iterators and async methods move data to the heap. Reference-type objects are conceptually heap objects (only the reference sits on the stack), although the current JIT's escape analysis can place a small object that provably never leaves the method on the stack as an optimisation. The language guarantees lifetime and copy semantics, not a location.
How to tell it (STAR)
**Situation:** At [company], our reporting API had latency spikes every few seconds during business hours. **Task:** I was asked to find the cause and remove the spikes. **Action:** dotnet-counters showed gen 2 collections every few seconds and a busy large object heap. A GC trace pointed at reading [200–800 KB] upstream responses into strings before deserialising. I changed the clients to deserialise straight from the response stream and pooled the large export buffer. **Result:** Gen 2 collections fell from every few seconds to [a few per hour], p99 latency dropped by about [60%], and we added allocation metrics to the dashboard.
Last updated 23 Oct 2026.