📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is the Large Object Heap (LOH) in .NET, and how do you find and fix LOH fragmentation and churn?

The 85,000-byte threshold, LOH collection with Gen 2, no compaction by default, fragmentation, churn, GCLOHThreshold and fixes such as pooling and streaming.

Senior Debugging Advanced arraypool fragmentation gc loh memory

🎯 Key points

  • Objects of 85,000 bytes or more are allocated on the LOH
  • The LOH is collected with Gen 2 and swept rather than compacted by default
  • Churn of large temporary objects triggers frequent Gen 2 GCs
  • Fragmentation leaves free gaps too small for new large objects, growing the heap
  • Diagnose with LOH size counters, AllocLarge GC reasons and dumpheap -min
  • Fix with ArrayPool, RecyclableMemoryStream, streaming and chunking
  • CompactOnce and GCLOHThreshold are targeted, evidence-based tools

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"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, AllocLarge GC reasons and a dump filtered to large objects, and I fix it mainly by not allocating large temporary objects: pool, stream, chunk."


1. What goes on the LOH

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

2. How the LOH is managed

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

3. Churn vs fragmentation

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

4. Typical sources

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

5. Diagnosing

  1. dotnet-counters: LOH size, Gen 2 count, fragmentation.
  2. GC trace (gc-collect): count Gen 2 GCs with reason AllocLarge.
  3. GC.GetGCMemoryInfo().GenerationInfo[3]: LOH size and fragmentation before/after.
  4. dotnet-gcdump or dotnet-dump analyze → dumpheap -stat -min 85000 to list large objects by type; dumpheap -type Free -stat to see free blocks.
  5. Allocation profiling filtered to large objects (PerfView AllocationTick events, VS allocation tool) to find call sites.

6. Fixes, in order of preference

  1. Stream instead of buffer: Request.Body/BodyReader straight to storage, JsonSerializer.SerializeAsync(stream, ...), CopyToAsync.
  2. Pool: ArrayPool<T>.Shared.Rent/Return in try/finally; slice to the requested length.
  3. Pooled streams: Microsoft.IO.RecyclableMemoryStream instead of MemoryStream.
  4. Chunk: process data in pieces below 85,000 bytes where that's natural.
  5. Pre-size collections when the size is known.
  6. Raise GCLOHThreshold only with evidence (many short-lived objects just above 85 KB).
  7. CompactOnce after a known fragmenting phase, as a last resort.

Production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Why a separate heap at all

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.

2. The threshold, in bytes

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.

3. Fragmentation, visually

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.

4. MemoryStream growth is a churn generator

Writing 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.

5. ArrayPool<T> details that matter

byte[] 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.
  • The shared pool is per-core/thread-local with buckets; very large requests beyond its limits are just allocated.
  • The shared pool trims under memory pressure (it registers for Gen 2 callbacks), so pooled buffers don't hold memory forever.
  • Never use a buffer after returning it; never return it twice.

6. Compaction and thresholds

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.

7. Reading LOH numbers in-process

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.

8. Version notes

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

9. Try it yourself

  1. Run the code example and compare total allocated MB, Gen 2 GCs and LOH size for new byte[] vs ArrayPool.
  2. Allocate new byte[84_975] and new byte[84_976], then call GC.GetGeneration on each: one reports 0, the other 2.
  3. Write 10 MB into a MemoryStream and count Gen 2 GCs; repeat with RecyclableMemoryStream.
  4. Create fragmentation: allocate alternating 1 MB arrays, keep every other one, then try a 1.5 MB allocation and watch GenerationInfo[3].FragmentationAfterBytes.
  5. Use dotnet-dump collect and dumpheap -stat -min 85000 on a running sample to list large objects.
  6. Capture a gc-collect trace and count GCs with reason AllocLarge before and after your fix.

💻 Code

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}");
}

⚠ Common mistakes

1. Reading whole bodies or files into memory

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.

2. Using MemoryStream.ToArray() on large streams

What 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.

3. Base64-encoding large binaries into strings

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.

4. Forgetting to return rented arrays (or returning twice)

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.

5. Assuming the rented array is the requested size

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.

6. Thinking the LOH is compacted like Gen 0

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.

7. Calling GC.Collect() to shrink the LOH

What 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.

8. Raising GCLOHThreshold without evidence

What 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.

9. Growing collections without capacity

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.

10. Not load-testing large-payload endpoints for memory

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate recommends ArrayPool Why: Checks pooling basics
    How to answer

    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.

  2. When: Candidate mentions forgetting to return rented arrays Why: Probes ArrayPool bug debugging
    How to answer

    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.

  3. When: Candidate describes heap areas Why: Checks managed heap layout
    How to answer

    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.

🛠 Real-world problems: how you handle them

PDF upload service fragmented the LOH under load
Situation
A document service accepted PDF uploads of [1 to 20 MB] and stored them in blob storage.
Signals
LOH size grew to [several GB], Gen 2 GCs every [second], container OOM restarts under peak load.
Investigation
`dotnet-gcdump` showed many large `byte[]` and `string` objects; code read uploads into `MemoryStream`, called `ToArray()` and base64-encoded them.
Resolution
Streamed the request body directly to blob storage and removed base64 encoding.
Prevention
Load tests now track LOH size and Gen 2 rate for upload endpoints.

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.

↩ Usually asked after

More in Garbage Collection

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me