Memory & Garbage Collection · Lesson 24

LOH allocation, fragmentation and compaction evidence

Separate payload length, object size, free gaps and committed memory before tuning large allocations.

Explore the interactive Memory Lab

Change owners, step through generations, pinning and finalization, or compare memory under load. The model explains concepts; the SDK examples measure real runtime observations.

Open the Memory Lab ↗
.NET 10 SDK / desktop CoreCLR

Read or download the complete SDK project, then follow its setup on your computer. The separate C# companion practice below runs in SharpRune’s .NET 9 console environment.

Large means object size, not just payload

CoreCLR normally routes allocations at or above its large-object threshold to the LOH. The familiar threshold is 85,000 bytes, but it refers to the whole object allocation rather than only an array's visible payload length. Headers, element size, alignment, architecture and runtime configuration matter. A byte array with a payload slightly below that number can still cross the boundary. Do not turn one x64 observation into a portable formula for every type and runtime.

Three separately allocated 30,000-byte arrays do not become one large object merely because their payloads sum to 90,000 bytes. The container holding their references has its own allocation. Chunking can change allocation shape, but it also changes indexing, metadata, copying and I/O behavior. Evaluate it against the actual consumer contract instead of optimizing a threshold in isolation.

Free gaps differ from live memory

When a large object becomes unreachable, reclaiming its space can leave a free region between live objects. A later allocation needs a suitable region or additional memory; a sum of scattered free bytes does not by itself establish that a sufficiently large contiguous object can fit. Fragmentation is therefore about the layout and distribution of available space, not simply a count of unreachable application values.

LOH participates in Gen 2 collection. Sweeping and compaction are distinct operations: reclaiming dead objects need not move every survivor, while compaction may relocate movable objects to change the layout. CoreCLR can support explicit LOH compaction requests, but pinned objects and runtime policy constrain movement. Neither operation changes a live application's semantic ownership. A strongly rooted large array remains live after a compacting full collection.

Committed memory is another measurement

A heap can retain committed space ready for future allocations after some objects have died. Process resident memory additionally includes memory outside the managed heap, such as native allocations, stacks and runtime structures. It is consequently normal for application membership, managed live bytes, heap size including fragmentation, committed GC memory and working set to move differently. State which quantity a chart represents before interpreting it.

The desktop experiment prints GCMemoryInfo snapshots around controlled requests. Those fields describe the latest completed relevant collection rather than a continuous synchronized census of every process allocation. Calling the snapshot before a new collection can show an older GC index. The experiment records that index so comparisons can be qualified. It requests collection to make an isolated mechanism observable; production tuning must be assessed with normal workload behavior and latency evidence.

Trace the portable chunk program

The portable example allocates three independent byte arrays and stores them in a list. chunks:3 is the number of entries. Summing Length returns 90,000 payload bytes. Removing the middle entry leaves two listed arrays and a sum of 60,000. The final line explicitly states heap-location:not-measured because no region inspection or collection observation occurred.

List bookkeeping, array headers, spare list capacity and other process memory are absent from that payload sum. Even the removed array's actual collection time is not established by the output. A compiler or runtime can keep temporary references around, and a future collection must include the relevant area. Use the example to reason about application payload ownership, then use the SDK lab to observe actual CoreCLR counters.

Run the fragmentation experiment

The SDK project creates twenty-four 100,000-byte arrays, removes alternating references, requests a noncompacting full collection, then requests one LOH compaction. It keeps the surviving array container alive through the measurement boundary. The invariant is that twelve entries remain nonnull; exact heap and fragmentation values depend on runtime decisions, other allocations and platform details.

Predict the direction you expect before running, but do not require a specific byte delta as a correctness assertion. Some layouts already have useful free space, and another runtime can make different decisions. Record heap size, fragmentation, committed bytes and GC index together. Repeat a fixed workload in a new process to distinguish a stable effect from one run's incidental allocation history.

Design large-buffer changes carefully

Compare reuse, streaming and bounded chunking against peak concurrency. A pool can reduce repeated large allocations while retaining a high-water mark indefinitely. Streaming can reduce payload residency if downstream code does not immediately materialize the whole stream again. Chunking can increase metadata and copies. The right experiment measures throughput, allocation rate, retained capacity and tail latency under the same consumer behavior.

For an interview, explain why a full GC cannot fix a cache that intentionally retains every large response. Then discuss a workload with many dead variable-sized arrays: layout and allocation shape may matter after ownership is corrected. A strong answer names the measured symptom first, chooses an intervention second and specifies a repeatable comparison. It does not prescribe periodic CompactOnce calls as a universal cure.

Practice and review

Change the portable chunk size while preserving the total logical payload. Compare number of objects, list entries and payload bytes, without guessing the resulting physical memory from payload alone. Add a consumer that requires one contiguous buffer and account for the final concatenation copy. The optimization may simply move the large allocation later.

In the region lab, remove the large-array root and run Gen 0 first. The LOH object remains outside that collection's reclamation set. Run Gen 2 and inspect the difference. Finish by writing separate definitions for live payload, fragmentation, committed GC bytes and resident process memory, then identify which metric would answer each question in a real memory incident.

.NET 10 SDK / desktop CoreCLR

LOH allocation, fragmentation and compaction evidence — CoreCLR experiment

Set up the SDK project

  1. Install .NET 10 SDK and unzip the project into a new directory.
  2. Read README.md and run dotnet run -c Release --project MemoryLab.csproj in this isolated process.
  3. Record the runtime, effective configuration and full output. Repeat the same workload; numeric observations are not universal expected values.

Download complete SDK project ↗

Project files

MemoryLab.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup></Project>
Program.csDownload file ↗
using System.Runtime;
using System.Runtime.CompilerServices;
var buffers = Make();
Snapshot("allocated");
for (int i = 0; i < buffers.Length; i += 2) buffers[i] = null;
GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: false);
Snapshot("after-sweep");
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true);
Snapshot("after-compact-request");
Console.WriteLine($"kept:{buffers.Count(b => b is not null)}");
GC.KeepAlive(buffers);
[MethodImpl(MethodImplOptions.NoInlining)] static byte[]?[] Make() => Enumerable.Range(0, 24).Select(_ => (byte[]?)new byte[100_000]).ToArray();
static void Snapshot(string label) { var i = GC.GetGCMemoryInfo(); Console.WriteLine($"{label}:index={i.Index};heap={i.HeapSizeBytes};fragmented={i.FragmentedBytes};committed={i.TotalCommittedBytes}"); }
README.mdDownload file ↗
# LOH allocation, fragmentation and compaction evidence

Environment: .NET 10 SDK / desktop CoreCLR

Run `dotnet run -c Release --project MemoryLab.csproj` with .NET 10 SDK.

This is an isolated desktop CoreCLR experiment. It may deliberately request bounded collections for observation. Never add these forced collection calls to a production request path. Numeric counters, generations, addresses and RSS vary. The portable SharpRune program is separate.

Primary references:
https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/large-object-heap
https://learn.microsoft.com/en-us/dotnet/api/system.runtime.gcsettings.largeobjectheapcompactionmode?view=net-10.0
https://learn.microsoft.com/en-us/dotnet/api/system.gc.getgcmemoryinfo?view=net-10.0

Expected result

The bounded program exits successfully and prints genuine CoreCLR observations. Counts, generations, heap sizes and timings vary; compare ownership invariants and matching observation windows rather than hardcoded measurements.

Use the listed SDK and dependencies for this project. Open C# practice runs the separate portable exercise.

C# companion practice

var chunks = new List<byte[]> { new byte[30000], new byte[30000], new byte[30000] };
Console.WriteLine($"chunks:{chunks.Count}");
Console.WriteLine($"payload:{chunks.Sum(c => c.Length)}");
chunks.RemoveAt(1);
Console.WriteLine($"remaining-payload:{chunks.Sum(c => c.Length)}");
Console.WriteLine("heap-location:not-measured");
Open C# practice

C# practice output

chunks:3
payload:90000
remaining-payload:60000
heap-location:not-measured
Try this
Trace ownership before inferring collection. Expected portable output: chunks:3 payload:90000 remaining-payload:60000 heap-location:not-measured

Further reading

Interview questions for this lesson