The 85,000-byte threshold, LOH collection with Gen 2, no compaction by default, fragmentation, churn, GCLOHThreshold and fixes such as pooling and streaming.
Objects of 85,000 bytes or more (in practice large arrays and strings) are allocated on the LOH. It is collected only during Gen 2 GCs and, by default, is swept, not compacted, so freed space becomes free-list gaps. Two problems follow: churn (frequent large temporary allocations exceed the LOH budget and trigger Gen 2 GCs) and fragmentation (free gaps too small for new large objects, so the heap grows). Fix with `ArrayPool<T>`, RecyclableMemoryStream, streaming instead of buffering, chunked processing, and sizing buffers to stay under the threshold. One-off compaction is available through GCSettings.LargeObjectHeapCompactionMode.
"Any object of 85,000 bytes or more, in practice big arrays and strings, is allocated on the Large Object Heap. The LOH is collected only with Gen 2 and by default it's swept, not compacted, because copying huge objects is expensive. That gives two distinct problems: churn, where many short-lived large allocations keep exhausting the LOH budget and trigger Gen 2 GCs, and fragmentation, where freed holes are too small for the next large object so the heap keeps growing. I find it from LOH size counters,
AllocLargeGC reasons and a dump filtered to large objects, and I fix it mainly by not allocating large temporary objects: pool, stream, chunk."
| Rule | Detail |
|---|---|
| Threshold | Total object size >= 85,000 bytes (including header) |
byte[] on 64-bit |
~84,976 elements or more |
string |
~42,500 characters or more (UTF-16) |
double[] on 32-bit |
Historically placed on the LOH at 1,000 elements (alignment quirk) |
| Configurable | GCLOHThreshold can raise the threshold, never lower it |
| Aspect | Behaviour |
|---|---|
| Collection | Only during Gen 2 GCs (background or blocking) |
| Budget | Separate LOH allocation budget; exceeding it triggers a Gen 2 GC with reason AllocLarge |
| Free space | Swept into a free list, not compacted by default |
| Compaction | On request (CompactOnce) or when the GC is close to a hard limit |
| Zeroing | Large allocations must be zeroed, which costs memory bandwidth |
| Regions (.NET 7+) | LOH uses larger regions than the SOH; free regions can be returned to the OS |
| Churn | Fragmentation | |
|---|---|---|
| What it is | Many large temporaries allocated and dropped | Free holes between live large objects |
| Symptom | Frequent Gen 2 GCs, reason AllocLarge |
LOH size grows while free bytes stay high |
| Cost | CPU in GC, latency | Memory growth, OOM in containers |
| Fix | Stop allocating large temporaries | Same, plus pooling stable buffers; compaction as last resort |
| Source | Example |
|---|---|
| Buffering request/response bodies | await Request.Body.CopyToAsync(ms); ms.ToArray() |
MemoryStream growth |
Capacity doubles into larger arrays, old ones discarded |
| Large strings | Serialising a big object to string, base64 of a file |
StringBuilder.ToString() |
Final big string |
| Growing collections | List<T> / Dictionary internal arrays above the threshold |
File.ReadAllBytes / ReadAllText |
Whole file in memory |
dotnet-counters: LOH size, Gen 2 count, fragmentation.gc-collect): count Gen 2 GCs with reason AllocLarge.GC.GetGCMemoryInfo().GenerationInfo[3]: LOH size and fragmentation before/after.dotnet-gcdump or dotnet-dump analyze → dumpheap -stat -min 85000 to list large objects by type; dumpheap -type Free -stat to see free blocks.AllocationTick events, VS allocation tool) to find call sites.Request.Body/BodyReader straight to storage, JsonSerializer.SerializeAsync(stream, ...), CopyToAsync.ArrayPool<T>.Shared.Rent/Return in try/finally; slice to the requested length.Microsoft.IO.RecyclableMemoryStream instead of MemoryStream.GCLOHThreshold only with evidence (many short-lived objects just above 85 KB).CompactOnce after a known fragmenting phase, as a last resort.Our [document service] accepted PDF uploads of [1 to 20 MB]. The code copied the body into a MemoryStream, called ToArray(), base64-encoded the bytes into a string and sent that to storage: three large objects per upload. Under load, the LOH reached [several GB] with heavy fragmentation, Gen 2 GCs fired [every second] with reason AllocLarge, and pods were OOM-killed at peak. A dotnet-gcdump showed hundreds of large byte[] and string instances. I changed the endpoint to stream Request.Body directly into the blob client's upload stream and removed base64 entirely. LOH allocations from that endpoint went to roughly zero, Gen 2 GCs dropped to [a few per hour], and the OOM restarts stopped.
The interviewer checks practical knowledge of a common .NET memory problem.
Green flags: 85,000-byte threshold, collected with Gen 2, not compacted by default, churn versus fragmentation, diagnostic tools, pooling and streaming fixes, compaction as last resort.
Red flags: thinks the LOH is compacted like Gen 0; reads everything into memory; uses GC.Collect to fix LOH growth.
Compaction cost is proportional to bytes copied. Moving a 50 MB array to close a gap costs far more than the gap is worth. So the GC treats large objects differently: allocate them in their own space, collect them with Gen 2 (large objects are assumed long-lived), and avoid moving them.
The assumption "large objects live long" is exactly what churn violates.
Array layout on 64-bit:
+------------------+------------------+------------------+-------------------------+
| object header 8B | MethodTable* 8B | length 8B | elements ... |
+------------------+------------------+------------------+-------------------------+
new byte[84_976] → 24 + 84,976 = 85,000 bytes → LOH. One element fewer stays in Gen 0.
LOH after some churn:
[ A 1MB ][ free 300KB ][ B 1MB ][ free 300KB ][ C 2MB ][ free 400KB ]
Request: new byte[500_000]
-> no single free block is big enough
-> the GC takes a new region / grows the LOH
-> 1 MB free but unusable; process memory grows
Pooling keeps the same buffers alive and reused, so holes aren't created in the first place.
MemoryStream growth is a churn generatorWriting 10 MB into a default MemoryStream:
capacity: 256 → 512 → ... → 128 KB (LOH) → 256 KB → 512 KB → 1 MB → 2 MB → 4 MB → 8 MB → 16 MB
Every step above 85 KB is a new LOH array; the previous one becomes garbage. Then ToArray() copies 10 MB again. Total LOH allocation is roughly 3x the payload. RecyclableMemoryStream uses pooled blocks and pooled large buffers to avoid this.
ArrayPool<T> details that matterbyte[] buffer = ArrayPool<byte>.Shared.Rent(1_000_000);
try
{
int read = await source.ReadAsync(buffer.AsMemory(0, 1_000_000), ct);
await destination.WriteAsync(buffer.AsMemory(0, read), ct);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer, clearArray: containsSensitiveData);
}
Rent may return a larger array (rounded up to a bucket size); always slice.using System.Runtime;
// After a known bulk import that fragmented the LOH:
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect(); // blocking Gen 2 that compacts the LOH; the setting resets to Default
runtimeconfig.json:
{
"configProperties": {
"System.GC.LOHThreshold": 120000
}
}
Raising the threshold puts objects between 85,000 and 120,000 bytes into the SOH, where they can be compacted and collected in young GCs. It also means they're copied when compacted. Only do this when measurements show many short-lived objects in that range.
GCMemoryInfo info = GC.GetGCMemoryInfo();
GCGenerationInfo loh = info.GenerationInfo[3]; // 0,1,2 = SOH gens, 3 = LOH, 4 = POH
Console.WriteLine($"LOH: {loh.SizeAfterBytes:N0} bytes, fragmented {loh.FragmentationAfterBytes:N0}");
The indices 3 and 4 are runtime bookkeeping; logically LOH and POH belong to Gen 2.
| Version | Change |
|---|---|
| .NET Framework 4.5.1 | GCSettings.LargeObjectHeapCompactionMode |
| .NET Core 3.0 | GCLOHThreshold; hard limit can trigger LOH compaction |
| .NET 5 | POH separates pinned buffers from the LOH |
| .NET 7 | Regions: LOH in its own regions, freed regions returned to OS more easily |
| .NET 8+ | More BCL APIs stream or use pooled buffers; System.Text.Json streaming serialisation is the norm |
new byte[] vs ArrayPool.new byte[84_975] and new byte[84_976], then call GC.GetGeneration on each: one reports 0, the other 2.MemoryStream and count Gen 2 GCs; repeat with RecyclableMemoryStream.GenerationInfo[3].FragmentationAfterBytes.dotnet-dump collect and dumpheap -stat -min 85000 on a running sample to list large objects.gc-collect trace and count GCs with reason AllocLarge before and after your fix.using System.Buffers;
const int Size = 1024 * 1024; // 1 MB: LOH sized
Measure("new byte[] per call", () =>
{
byte[] buffer = new byte[Size];
return Fill(buffer);
});
Measure("ArrayPool rent/return", () =>
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(Size);
try { return Fill(buffer.AsSpan(0, Size)); }
finally { ArrayPool<byte>.Shared.Return(buffer); }
});
static int Fill(Span<byte> span)
{
span[0] = 1; span[^1] = 2;
return span[0] + span[^1];
}
static void Measure(string name, Func<int> work)
{
GC.Collect(); // demo only
int gen2 = GC.CollectionCount(2);
long before = GC.GetTotalAllocatedBytes();
long sum = 0;
for (int i = 0; i < 2_000; i++) sum += work();
long allocated = GC.GetTotalAllocatedBytes() - before;
GCGenerationInfo loh = GC.GetGCMemoryInfo().GenerationInfo[3];
Console.WriteLine($"{name,-22} allocated {allocated / (1024 * 1024),6:N0} MB, Gen 2 GCs {GC.CollectionCount(2) - gen2,4}, " +
$"LOH size after {loh.SizeAfterBytes / (1024 * 1024):N0} MB, sum {sum}");
}
What happens: File.ReadAllBytes, ms.ToArray(), ReadAsStringAsync() on big payloads.
Why it is wrong: every request creates LOH objects → churn and fragmentation.
Do instead: stream with Stream, PipeReader/PipeWriter, CopyToAsync, async serialisation.
MemoryStream.ToArray() on large streamsWhat happens: a full copy into yet another large array.
Why it is wrong: doubles LOH allocation for the same data.
Do instead: TryGetBuffer / GetBuffer() with Length, or RecyclableMemoryStream; avoid the intermediate stream altogether when possible.
What happens: a 10 MB file becomes a ~13 MB byte array plus a ~27 MB UTF-16 string. Why it is wrong: multiple huge LOH objects per request. Do instead: send binary content as streams; if base64 is required, encode in chunks to a stream.
What happens: Rent without finally, or two code paths both Return.
Why it is wrong: leaks defeat pooling; double returns hand the same buffer to two requests, corrupting or leaking data.
Do instead: single owner, try/finally, clear sensitive data on return.
What happens: code uses buffer.Length.
Why it is wrong: the pool can return a larger array; extra bytes contain stale data.
Do instead: track the requested length and slice.
What happens: developers expect fragmentation to fix itself.
Why it is wrong: the LOH is swept by default; holes remain.
Do instead: remove churn; use CompactOnce only as a targeted last resort.
GC.Collect() to shrink the LOHWhat happens: a forced GC after each big operation. Why it is wrong: it doesn't compact the LOH by default and adds full blocking GCs. Do instead: fix allocation patterns; measure fragmentation.
GCLOHThreshold without evidenceWhat happens: the threshold is set very high "to avoid the LOH". Why it is wrong: large objects then get copied during SOH compaction, and Gen 0/1 GCs get more expensive. Do instead: raise it only when data shows many short-lived objects just above 85 KB.
What happens: a List<T> grows to millions of items by doubling.
Why it is wrong: each growth step allocates a new large array and discards the old.
Do instead: set capacity when known, or stream instead of collecting.
What happens: functional tests pass; production OOMs under concurrency. Why it is wrong: LOH problems only show with concurrent large requests. Do instead: include LOH size, Gen 2 rate and peak memory in load-test pass criteria.
LOH problems are usually architecture problems: buffering where streaming was possible.
| Pattern | Better approach |
|---|---|
Upload → MemoryStream → storage |
Stream request body to storage |
| Build big JSON string, then write | SerializeAsync to the response stream |
| Large temporary buffers per call | `ArrayPool<T>` with try/finally |
Growing `List<T>` of known size |
Set capacity up front or stream |
Seniors measure LOH size and fragmentation as part of load tests for any endpoint that handles files or large payloads.
First I measure: dotnet-counters for allocation rate and LOH size, then an allocation profiler to see which arrays dominate. Short-lived large arrays are the worst case, because anything of 85,000 bytes or more lands on the Large Object Heap, which is only collected in gen 2. The fixes are to reuse buffers with ArrayPool<T>.Shared (always return them, use only the requested length, clear sensitive data), use spans instead of copying slices, and stream data instead of building giant arrays. For speed I keep data in contiguous arrays of structs for cache locality and use the vectorised CopyTo/Fill/TensorPrimitives APIs instead of hand-written loops. Then I prove it with BenchmarkDotNet and compare production counters.
Pooling bugs are lifetime bugs: the pool gives a returned array to the next renter, so any code still using it reads or writes someone else's data. The usual causes are use after return (returning in finally while an async continuation, a queued Memory<T> or a lazy enumerable still uses the array), double return (two renters then share one array), treating array.Length as the data length (Rent returns an array at least as large as requested, with old contents), not clearing buffers that held secrets (Return(array, clearArray: true)), and returning arrays not rented from that pool. To debug: reproduce under concurrency, add canaries or a poisoning wrapper pool in tests that fills returned arrays with a marker and detects double returns, trace System.Buffers.ArrayPoolEventSource events, and review every rent for a single owner and a single return in finally.
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.
How to tell it (STAR)
Our [document service] created three large objects per upload. Under load the LOH fragmented and pods were OOM-killed. I switched to streaming uploads straight to storage, which removed the LOH allocations and stopped the restarts.
Last updated 31 Oct 2026.