Memory channel icon Memory · 3 published articles

Advanced .NET Garbage Collection: Measure Before You Tune

Learn how to measure and reduce allocation pressure, see what .NET 10 escape analysis can do, control object lifetimes with disposal and event cleanup, and evaluate server GC, DATAS, LOH compaction, and no-GC regions without tuning by guesswork.

Advanced .NET Garbage Collection: Measure Before You Tune
On this page

What you'll learn

The .NET garbage collector can clean up objects your program no longer uses, but your application may still appear to use a lot of memory. That is because cleaning up an object, returning memory to Windows or Linux, and closing a file or network connection are different things.

The garbage collector (GC) is the .NET runtime feature that manages memory for objects created by your C# code. It follows references from places your running program can access, such as local variables and static fields. It can reuse memory when no reachable code can get to an object, but it does not promise to return that memory to the operating system straight away.

plaintext


 STACK (current method)        MANAGED HEAP
┌──────────────────────┐      ┌──────────────────────────────┐
│ order ───────────────┼─────>│ Order ─────> Item[]          │ reachable
│ count = 3            │      │                              │
└──────────────────────┘      │ "old draft"  (no references) │ unreachable
 static field ───────────────>│ PriceCache                   │ reachable
                              └──────────────────────────────┘
 The GC starts at roots (locals, static fields) and keeps only what it
 can reach from them. The "old draft" string's memory can be reused.
            

This article shows how to check what the GC is doing before changing settings. You will compare how much memory two small pieces of code allocate, learn why large objects and GC settings matter, see how disposal, finalizers, and events affect how long objects stay alive, and see when special features such as LOH compaction or a no-GC region may help. You do not need prior experience tuning the GC; basic C# and .NET knowledge is enough.

The article moves in three stages, in the order you should work: first measure and reduce allocations, then control how long objects and operating-system resources stay alive, and only then consider GC settings such as server mode, DATAS, LOH compaction, and no-GC regions.

Why it matters

Creating many temporary objects can make an application do extra cleanup work. For example, each change to a C# string creates a new string, so repeatedly building a long string can create many temporary copies.

A GC collection may briefly pause your C# code while it checks objects. Large objects use a special area called the large object heap (LOH). Objects that stay reachable for a long time also remain in memory. Meanwhile, files and network connections need to be closed by your code. These problems can all look like “high memory use,” but each needs a different fix.

Calling GC.Collect() does not tell you what caused high memory use. It can pause your application, and it cannot remove objects your code can still reach. Measure first, then fix the specific cause.

Some collections pause your application more than others. Generation 0 and generation 1 collections are always foreground (blocking) collections: .NET pauses your application threads while it checks memory, but these collections are usually short. Background GC applies only to generation 2 collections. It does much of its work on a dedicated GC thread while your application continues running, but it still needs brief pauses.

One short pause rarely hurts. The cost comes from how often pauses happen: if a busy request path allocates so quickly that small collections start again and again, those short pauses add up to time your users can notice. That is why lowering the allocation rate often helps more than changing a GC setting. A collection count by itself does not tell you how long users waited.

Implementation

Measure allocation pressure before tuning GC settings

Start by measuring how much memory your code creates instead of forcing a collection. This console example compares creating a new 1-kilobyte buffer 500 times with creating one buffer and reusing it. It runs each method once before measuring to avoid counting the first run as the only result. Treat the numbers as a simple example, not as a formal performance test.

C#


using System;

var allocationBytes = Measure(AllocateEachTime);
var reuseBytes = Measure(ReuseBuffer);

Console.WriteLine("Strategy               Allocated bytes");
Console.WriteLine($"New buffer per item    {allocationBytes,15:N0}");
Console.WriteLine($"Reuse one buffer       {reuseBytes,15:N0}");

long Measure(Func<int> operation)
{
    _ = operation();
    var before = GC.GetAllocatedBytesForCurrentThread();
    _ = operation();
    return GC.GetAllocatedBytesForCurrentThread() - before;
}
            

Add the two strategies below the helper in the same Program.cs file. Both do the same work; only the buffer handling differs:

C#


int AllocateEachTime()
{
    var total = 0;
    for (var i = 0; i < 500; i++)
    {
        var buffer = new byte[1024];
        buffer[0] = (byte)i;
        total += buffer[0];
    }

    return total;
}

int ReuseBuffer()
{
    var buffer = new byte[1024];
    var total = 0;
    for (var i = 0; i < 500; i++)
    {
        buffer[0] = (byte)i;
        total += buffer[0];
    }

    return total;
}
            

The console prints the number of bytes allocated by each method. On a 64-bit runtime the output looks like this:

plaintext


Strategy               Allocated bytes
New buffer per item            524,000
Reuse one buffer                 1,048
            

Each byte[1024] takes 1,048 bytes on the heap: the 1,024 data bytes plus 24 bytes for the object header, type pointer, and array length. Creating 500 of them costs 500 × 1,048 = 524,000 bytes, while the reuse version pays for just one array. Your numbers can differ slightly between runtimes, but the gap is what matters.

plaintext


 New buffer per item                  Reuse one buffer
 STACK         HEAP                   STACK         HEAP
┌────────┐    ┌───────────────┐      ┌────────┐    ┌───────────────┐
│ buffer─┼─┐  │ byte[] #1   x │      │ buffer─┼───>│ byte[] (only) │
└────────┘ │  │ byte[] #2   x │      └────────┘    └───────────────┘
           │  │ ...         x │
           └─>│ byte[] #500   │       1 array  =     1,048 bytes
              └───────────────┘
               500 arrays = 524,000 bytes   x = garbage for the GC
            

GC.GetAllocatedBytesForCurrentThread() counts bytes allocated by the current thread. It does not tell you how much memory is still in use, how much memory the whole process uses, or what other threads allocated. For careful performance comparisons, use a benchmark tool such as BenchmarkDotNet.

Watch GC activity in a running app

Runtime counters show how your app behaves while it runs. Useful values include how quickly it allocates memory, the size of its managed heap, how often it runs a full (generation 2) collection, and how much time it spends doing GC work. To use these counters, install the dotnet-counters tool if needed:

bash


dotnet tool install --global dotnet-counters
            

Then start monitoring your app. Replace 1234 with your app's process ID:

bash


dotnet-counters monitor --process-id 1234 System.Runtime
            

The dotnet-counters documentation explains how to find the process ID and read each counter.

These counters show overall GC activity, not how long each pause lasts. To inspect pauses, collect a runtime trace while the app handles a representative workload. Install dotnet-trace if needed, then collect GC events with a built-in profile:

bash


dotnet tool install --global dotnet-trace
dotnet-trace collect --process-id 1234 --profile gc-collect
            

The gc-collect profile records only GC collection events, so its overhead is very low. Use --profile gc-verbose when you also want sampled allocation events and can accept more overhead. Run dotnet-trace list-profiles to see every profile your version supports.

Replace 1234 with the process ID. Open the resulting trace in PerfView or a compatible .NET trace viewer and inspect the GC pause events. The trace helps identify individual pauses; use a load test or production telemetry to understand how those pauses affect request latency.

Reduce avoidable allocations

Reducing unnecessary objects can reduce GC work, but “zero allocations” is not always the right goal. Reuse a buffer only when doing so is safe for your code. If measurement shows a hot part of your app needs improvement, stackalloc and Span<T> can work for small, short-lived buffers. ArrayPool<T> can reuse larger arrays; return a rented array in a finally block so it is returned even if an error occurs.

The next two examples show these ideas. FormatOrderCode builds a short text value in a stack buffer and allocates only the final string:

C#


using System;

Console.WriteLine(FormatOrderCode(42)); // ORD-42

static string FormatOrderCode(int id)
{
    Span<char> chars = stackalloc char[16];
    "ORD-".AsSpan().CopyTo(chars);
    id.TryFormat(chars[4..], out var written);
    return new string(chars[..(4 + written)]);
}
            

ChecksumOfLength borrows a larger array from the shared pool instead of creating a new one on every call:

C#


using System;
using System.Buffers;

Console.WriteLine(ChecksumOfLength(10_000)); // 1273080

static int ChecksumOfLength(int length)
{
    var buffer = ArrayPool<byte>.Shared.Rent(length);
    try
    {
        var data = buffer.AsSpan(0, length);
        for (var i = 0; i < data.Length; i++)
        {
            data[i] = (byte)i;
        }

        var sum = 0;
        foreach (var value in data)
        {
            sum += value;
        }

        return sum;
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(buffer);
    }
}
            

Rent can return an array larger than you asked for, so slice it to the length you need. Once you return an array to the pool, do not keep using it. Keep stackalloc buffers small because stack space is limited.

plaintext


 FormatOrderCode                ChecksumOfLength
 STACK FRAME                    STACK        HEAP (shared pool)
┌───────────────────────┐      ┌────────┐   ┌──────────────────────┐
│ chars: O R D - 4 2 ...│      │ buffer─┼──>│ byte[16384]  rented  │
│ freed when the method │      └────────┘   │ byte[...]    waiting │
│ returns               │                   └──────────────────────┘
└───────────────────────┘       Return puts the array back, so the next
 HEAP: only the final "ORD-42"  call reuses it instead of allocating
            

The same idea applies to building strings. C# strings cannot be changed after creation, so repeatedly joining strings with + can create a new intermediate string on each iteration. For repeated appends, StringBuilder reuses its internal buffer and creates the final string when you call ToString.

C#


using System.Text;

var parts = new[] { "GC", " ", "measures", " ", "memory" };
var builder = new StringBuilder();

foreach (var part in parts)
{
    builder.Append(part);
}

var message = builder.ToString();
            

StringBuilder still allocates its builder and final string. It is useful when you append repeatedly; for a small, fixed number of parts, ordinary concatenation may be clearer. Measure before changing code just to reduce allocations.

Let the JIT avoid heap allocations, but do not depend on it

Escape analysis is how the JIT compiler checks whether an object stays inside a method or is passed somewhere that may keep using it later. An object escapes when the method returns it, stores it in a field, or passes it to code the JIT cannot fully see. If the JIT can prove an object does not escape, it can put that object on the stack instead of the managed heap, so the GC never has to track it.

C#


static int Largest(int a, int b, int c)
{
    int[] candidates = { a, b, c }; // never leaves this method
    var largest = candidates[0];
    foreach (var value in candidates)
    {
        largest = Math.Max(largest, value);
    }

    return largest;
}

static int[] KeepForLater(int a, int b, int c) => new[] { a, b, c }; // escapes: the caller receives it
            

Starting with .NET 10, the JIT can stack-allocate small, fixed-size arrays such as candidates when it proves they do not escape. The array returned by KeepForLater must live on the heap because the caller can keep using it.

plaintext


 Largest (optimized, .NET 10)     KeepForLater
 STACK FRAME                      STACK (caller)      HEAP
┌──────────────────────────┐     ┌──────────────┐    ┌──────────┐
│ candidates: [ a, b, c ]  │     │ result ──────┼───>│ int[3]   │
│ largest                  │     └──────────────┘    └──────────┘
└──────────────────────────┘      The array outlives the method,
 Gone when the method returns,    so it must live on the heap.
 so the GC never sees it.
            

Treat this as a bonus, not a design tool. It applies only to optimized code, so a method's first runs and Debug builds still allocate on the heap. Large arrays, such as new byte[1024] in a loop, are still heap allocations. When a small buffer must stay off the heap, use stackalloc explicitly.

A lambda that uses a local variable from outside the lambda is called a capturing lambda. The compiler moves the captured variable into a hidden helper object (often called a closure) so the lambda can keep using it, and creating that helper is an allocation. A lambda that captures nothing does not need this helper, and the compiler can cache its delegate and reuse it. Adding the static keyword does not create that caching; it makes the compiler reject any accidental capture.

C#


int offset = 10;

Func<int, int> addOffset = value => value + offset;
Func<int, int> doubleValue = static value => value * 2;
            

addOffset captures offset; the static lambda cannot capture it. This difference matters when code creates delegates repeatedly in a performance-sensitive loop. In .NET 10 the JIT can sometimes stack-allocate a delegate that does not escape, but the closure object is still a heap allocation. Keep the delegate or move the work out of the loop when that fits the design, then check allocations with a profiler.

plaintext


 STACK              HEAP
┌───────────────┐  ┌────────────────────┐  ┌──────────────────┐
│ addOffset ────┼─>│ Func<int, int>     │─>│ closure object   │
│               │  │ (new delegate)     │  │   offset = 10    │
│ doubleValue ──┼┐ └────────────────────┘  └──────────────────┘
└───────────────┘│ ┌────────────────────┐
                 └>│ cached delegate    │  created once, reused,
                   │ (no captured data) │  no closure needed
                   └────────────────────┘
            

Separate managed objects from operating-system resources

A managed object is an object whose memory is tracked by the .NET runtime, such as a string, list, or FileStream. The stream object is managed, but the open file handle it uses belongs to the operating system. The GC can reclaim the stream object's memory when your code can no longer reach it. The handle may then be released later through finalization, but the timing is unpredictable; use Dispose when you need to close the file promptly.

plaintext


 STACK        MANAGED HEAP (tracked by the GC)        OPERATING SYSTEM
┌────────┐   ┌────────────┐   ┌────────────────┐     ┌─────────────┐
│ stream─┼──>│ FileStream │──>│ SafeFileHandle │────>│ file handle │
└────────┘   └────────────┘   └────────────────┘     └─────────────┘
              The GC reclaims these objects'          Dispose closes
              memory when they are unreachable        this right away
            

IDisposable lets an object expose a Dispose method so your code can release resources at a predictable time. Calling Dispose does not tell the GC to collect the object or remove it from memory. It releases resources the object owns; the GC reclaims the object's memory later, when your code can no longer reach it.

Use a using statement for disposable objects. C# calls Dispose when execution leaves the block, including when an exception occurs.

C#


using System.IO;

using (var stream = File.OpenRead("report.json"))
{
    // Read the file here.
}

// Dispose has run here, so the file handle can be released.
            

The compiler turns a using block into a try/finally block that calls Dispose in finally. You can also write a using declaration, such as using var stream = File.OpenRead("report.json");, which disposes the object at the end of the enclosing scope.

Clearing lists or setting fields to null inside Dispose is usually unnecessary. Once nothing references the disposed object, the GC can reclaim everything it points to. Use Dispose for things the GC cannot handle: closing handles and connections, and releasing native memory.

If your class owns another object that implements IDisposable, dispose that object when your own class is disposed. Only dispose resources your class owns; do not dispose an object that was merely passed in for temporary use unless the ownership contract says you took responsibility for it.

Implement disposal for resources your class owns

A sealed class can often implement Dispose directly. A class designed for inheritance can use a protected virtual Dispose(bool disposing) method, which gives derived classes a place to release the resources they add. Dispose managed child objects only when disposing is true; a finalizer must not rely on other managed objects still being available.

A class that only owns other disposable objects, such as streams or database connections, does not need a finalizer. Those objects already handle their own unmanaged cleanup. A finalizer is needed only when your class holds an unmanaged resource directly, which is the case in the example below.

GC.SuppressFinalize tells the runtime not to run this object's finalizer after explicit disposal; it does not trigger collection or free the object's managed memory. It matters only if this object or a derived type has a finalizer. Calling it also affects performance. When the GC finds an unreachable object that still needs finalization, it cannot free that object yet. It queues the object for the finalizer thread and keeps it, and everything it references, alive at least until a later collection. That often promotes the object to an older generation. After SuppressFinalize, the object can be reclaimed like any other object.

If a class directly owns unmanaged memory, the finalizer is its safety net when a caller forgets to call Dispose. Cleanup can start in two ways: Dispose() calls Dispose(true), while the finalizer calls Dispose(false). Release unmanaged resources in both cases, and touch other managed objects only when disposing is true.

plaintext


Your code calls Dispose()               GC finds the object unreachable
           │                                        │
           v                                        v
Dispose(disposing: true)                Finalizer runs Dispose(disposing: false)
  ├─ dispose owned managed objects        ├─ skip managed objects (they may
  ├─ free unmanaged resources             │   already be finalized)
  └─ GC.SuppressFinalize(this)            └─ free unmanaged resources
           │                                        │
           v                                        v
Reclaimed by a normal collection        Reclaimed by a later collection
            

Both paths end in one shared method, so the cleanup logic lives in one place. The class exposes the two entry points like this:

C#


using System;
using System.Runtime.InteropServices;

public class NativeScratchBuffer : IDisposable
{
    private IntPtr _memory;
    private bool _disposed;

    public NativeScratchBuffer(int sizeInBytes)
    {
        _memory = Marshal.AllocHGlobal(sizeInBytes);
    }

    ~NativeScratchBuffer() => Dispose(disposing: false);

    public void Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this);
    }

    // Dispose(bool) is shown next.
}
            

Inside the same class, the shared method checks the guard, cleans up managed objects only on the Dispose() path, and always frees the native memory:

C#


protected virtual void Dispose(bool disposing)
{
    if (_disposed)
    {
        return;
    }

    if (disposing)
    {
        // Dispose managed objects this class owns.
    }

    if (_memory != IntPtr.Zero)
    {
        Marshal.FreeHGlobal(_memory);
        _memory = IntPtr.Zero;
    }

    _disposed = true;
}
            

This example shows how the two paths fit together. In real code, prefer SafeHandle to wrap a low-level operating-system handle, because it already provides reliable cleanup if normal disposal is missed. Add a custom finalizer only when your type directly owns an unmanaged resource and cannot use SafeHandle.

The protected virtual method is what makes the pattern work with inheritance. A derived class does not implement IDisposable again or write its own finalizer. It overrides Dispose(bool), releases what it added, and then hands control back to the base class:

C#


using System.IO;

public sealed class TracedScratchBuffer : NativeScratchBuffer
{
    private readonly StreamWriter _log;
    private bool _disposed;

    public TracedScratchBuffer(int sizeInBytes, string logPath)
        : base(sizeInBytes)
    {
        _log = new StreamWriter(logPath);
    }

    protected override void Dispose(bool disposing)
    {
        if (!_disposed)
        {
            if (disposing)
            {
                _log.Dispose();
            }

            _disposed = true;
        }

        base.Dispose(disposing);
    }
}
            

Calling Dispose() on a TracedScratchBuffer runs the base class's public method, which calls the override. The override closes the log, and base.Dispose(disposing) frees the native memory. Callers can dispose the whole object with one call, without knowing what each class in the hierarchy owns. Both classes guard against repeated calls, so disposing twice is harmless.

Unsubscribe events and stop timers

Events can also keep objects alive. When one object subscribes to an event on another object, the publisher keeps a reference to the subscriber until it unsubscribes. Unsubscribe when you no longer need the event; dropping your own reference to the subscriber does not remove the publisher's reference. A good place to unsubscribe is the subscriber's Dispose method.

C#


using System;

public sealed class StockTicker
{
    public event EventHandler<decimal>? PriceChanged;

    public void Publish(decimal price) => PriceChanged?.Invoke(this, price);
}

public sealed class PriceBadge : IDisposable
{
    private readonly StockTicker _ticker;

    public PriceBadge(StockTicker ticker)
    {
        _ticker = ticker;
        _ticker.PriceChanged += OnPriceChanged;
    }

    public void Dispose() => _ticker.PriceChanged -= OnPriceChanged;

    private void OnPriceChanged(object? sender, decimal price) =>
        Console.WriteLine($"Badge shows {price}");
}
            

If a long-lived StockTicker creates and drops thousands of badges without disposing them, each badge stays alive in the event's handler list. That is a memory leak, even though your code no longer uses those badges.

Timers behave in a similar way. A started System.Timers.Timer is kept alive by the runtime's timer infrastructure, so it keeps raising Elapsed after your code loses every reference to it. Its handler also keeps the handler's target object alive. When the owner is done, stop the timer, unsubscribe from Elapsed, and dispose the timer, usually from the owner's Dispose method.

plaintext


 LONG-LIVED ROOT         HEAP
 static ticker ────────> StockTicker
                          └─ PriceChanged handler list
                               ├─> delegate ─> PriceBadge #1
                               ├─> delegate ─> PriceBadge #2
                               └─> ...
 Your code dropped every badge, but the handler list still reaches them.
 PriceBadge.Dispose() removes its delegate, so the badge becomes garbage.

 Timer:  runtime timer queue ─> Timer ─> Elapsed delegate ─> owner object
            

Use weak references only for optional data

A WeakReference<T> lets you refer to an object without keeping it alive. The GC may collect the target whenever no strong references remain. This can suit an optional cache: use the cached object if it is still available, or recreate it if the GC has collected it.

C#


public sealed class OptionalBufferCache
{
    private WeakReference<byte[]>? _cachedBuffer;

    public byte[] GetBuffer()
    {
        if (_cachedBuffer is not null
            && _cachedBuffer.TryGetTarget(out var existingBuffer))
        {
            return existingBuffer;
        }

        var buffer = new byte[1024];
        _cachedBuffer = new WeakReference<byte[]>(buffer);
        return buffer;
    }
}
            
plaintext


 HEAP
 OptionalBufferCache ─> WeakReference<byte[]> ---> byte[1024]
                        (weak: the GC ignores it)      ^
 STACK                                                 │ strong
 caller's local variable ──────────────────────────────┘
 While the caller holds the array, it stays alive. Once only the weak
 reference is left, the GC may collect it and TryGetTarget returns false.
            

The returned array is strongly referenced by the caller, but the cache does not keep it alive by itself. If nothing else needs the array, the GC may collect it and the next call creates another one. Do not use a weak reference as a substitute for stopping a timer, disposing a resource, or unsubscribing an event; use explicit ownership and cleanup for those jobs. For a deeper introduction to reachability, continue with Understanding .NET Garbage Collection: Generations, Scope, and Object Lifetimes .

Select workstation or server GC for the workload

.NET has two main GC modes: workstation and server. They make different trade-offs:

  • Workstation GC is designed for apps running on a user's computer, where the app shares the machine with many other programs. It uses smaller allocation budgets, so it collects more often and keeps memory use lower.
  • Server GC treats the app as the main workload on the machine. By default, it creates one heap and one dedicated GC thread per logical CPU and collects those heaps in parallel. It allows more allocation between collections, so it usually runs fewer collections, completes more work, and uses more memory.
plaintext


 Workstation GC              Server GC on a 4-CPU machine
┌────────────────────┐      ┌────────┐┌────────┐┌────────┐┌────────┐
│ one managed heap   │      │ heap 0 ││ heap 1 ││ heap 2 ││ heap 3 │
└────────────────────┘      └────────┘└────────┘└────────┘└────────┘
 smaller budgets,             one GC thread per heap, collected in
 more frequent GCs            parallel; DATAS starts at 1 heap and grows
            

ASP.NET Core apps use server GC by default. Console, desktop, and worker apps use workstation GC unless you change it. You can set the mode in the project file:

XML


<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
            

You can also set it without rebuilding, through System.GC.Server in runtimeconfig.json or the DOTNET_gcServer environment variable.

Neither mode is always faster. Test both with the kind of work your app does in production. Compare how much work it completes, how long pauses last, and how much memory it uses. A server running many copies of an app may need different settings from a server running one busy app.

Let DATAS adapt server GC to real memory needs

Plain server GC sizes itself mainly for throughput and the hardware it sees. That causes two problems. The same app can use very different amounts of memory on a small machine and a large one. And after a traffic spike ends, the heap may not shrink much, which matters when many containers share one node.

Dynamic Adaptation to Application Sizes (DATAS) addresses this. It aims to keep the heap roughly proportional to the data your app keeps alive for a long time. It starts with a single heap and adds or removes heaps as the load changes, so it can act anywhere between workstation-like and full server-like behavior. DATAS was introduced as an opt-in feature in .NET 8 and is enabled by default for server GC starting in .NET 9. It does not apply to workstation GC.

The trade-off is more frequent generation 0 and 1 collections and a small throughput cost, in exchange for a much smaller working set, especially for bursty workloads and memory-limited containers. If measurements show that your app needs that throughput back, you can turn DATAS off:

XML


<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
  <GarbageCollectionAdaptationMode>0</GarbageCollectionAdaptationMode>
</PropertyGroup>
            

The DOTNET_GCDynamicAdaptationMode environment variable does the same job, where 1 enables DATAS and 0 disables it. Setting a fixed heap count with GCHeapCount stops the GC from growing and shrinking the number of heaps, which is a core part of how DATAS adapts. Treat these two settings as alternatives, and change either one only after measuring.

DATAS is not a hard cap on memory. When an app runs in a container with a memory limit, .NET treats that limit as the machine's memory, and by default the GC heap hard limit is 75% of it. That hard limit bounds the GC heap's committed memory, including GC bookkeeping, but not total process memory. A container limit applies to memory charged to the container, which can include multiple processes and native memory. That budget can be exhausted and cause an out-of-memory kill without a managed OutOfMemoryException. See the GC runtime configuration reference and the DATAS documentation before configuring either limit.

Treat LOH compaction as a measured maintenance action

Objects that are 85,000 bytes or larger go on the LOH and are collected with generation 2 objects. You can check this with GC.GetGeneration, which reports the generation an object currently belongs to:

C#


var smallArray = new byte[1_024];
var largeArray = new byte[100_000];

Console.WriteLine($"Small array generation: {GC.GetGeneration(smallArray)}");
Console.WriteLine($"Large array generation: {GC.GetGeneration(largeArray)}");
            
plaintext


Small array generation: 0
Large array generation: 2
            

The large array reports generation 2 as soon as it is created, because the LOH is collected only during full collections. GC.CollectionCount(2) and GC.GetTotalMemory(false) are also useful for quick experiments. In optimized code, the JIT may treat a local variable as unused after its last read, so the GC can collect the object before the end of the method. If an experiment needs an object to stay alive until a certain line, call GC.KeepAlive(obj) there.

By default, .NET usually leaves gaps where unused large objects were instead of moving the remaining objects to fill those gaps. This avoids the cost of copying large blocks of memory. A large LOH does not automatically mean there is a problem; .NET may reuse those gaps for future allocations.

If profiling shows that gaps in the LOH are causing a problem, you can ask .NET to compact it during the next full, blocking collection. Compaction moves objects to close gaps and can pause your app. This is a one-time request, not a general memory cleanup switch. After that collection compacts the LOH, the setting returns to Default automatically.

plaintext


 SMALL OBJECT HEAP              LARGE OBJECT HEAP (85,000 bytes or more)
┌───────┬───────┬───────┐      ┌────────┬──────┬────────┬──────┐
│ gen 0 │ gen 1 │ gen 2 │      │ 100 KB │ free │ 300 KB │ free │
└───────┴───────┴───────┘      └────────┴──────┴────────┴──────┘
 survivors are usually          collected with gen 2; gaps are reused
 compacted (moved together)     for new large objects, not closed

                                After CompactOnce + a full blocking GC:
                               ┌────────┬────────┬─────────────┐
                               │ 100 KB │ 300 KB │ free space  │
                               └────────┴────────┴─────────────┘
            
C#


using System.Runtime;

GCSettings.LargeObjectHeapCompactionMode =
    GCLargeObjectHeapCompactionMode.CompactOnce;

// Optional: compact now instead of waiting for the next full blocking GC.
GC.Collect();

Console.WriteLine(GCSettings.LargeObjectHeapCompactionMode); // Default
            

Usually it is enough to set the mode and let the next full blocking collection do the work. Force it with GC.Collect() only at a controlled moment, such as after a large batch job finishes, and never as a routine step. First confirm that LOH gaps are the problem, then test the change with a workload similar to your real app.

Reserve no-GC regions only for bounded work

A no-GC region is a short period when .NET tries not to start a garbage collection. GC.TryStartNoGCRegion asks .NET to start one and reserve a memory budget for it. The request can fail if .NET cannot reserve enough memory, and it may run a collection first to make room. If it succeeds, keep allocations within the budget and call GC.EndNoGCRegion() when the work is finished.

If your app uses more memory than the reserved budget, .NET ends the region early. A collection can run, and GCSettings.LatencyMode returns to its previous value. Calling GC.EndNoGCRegion() after that throws InvalidOperationException, so check the latency mode first:

C#


using System;
using System.Runtime;

const long budgetBytes = 32 * 1024 * 1024;

if (GC.TryStartNoGCRegion(budgetBytes))
{
    try
    {
        ProcessAudioBlock();
    }
    finally
    {
        if (GCSettings.LatencyMode == GCLatencyMode.NoGCRegion)
        {
            GC.EndNoGCRegion();
        }
    }
}
else
{
    ProcessAudioBlock();
}

static void ProcessAudioBlock()
{
    // Short, bounded work that allocates far less than the budget.
}
            

The budget has an upper limit that depends on the runtime and GC mode, and asking for too much throws ArgumentOutOfRangeException. Use no-GC regions only for short, measured work where a pause would cause a real problem, such as a real-time audio block or a latency-critical calculation, and not for an ordinary request. In most apps, reducing unnecessary allocations is a simpler and safer choice.

For deeper runtime behavior, use the official large object heap documentation , workstation and server GC documentation , and garbage collector runtime configuration reference .

Summary

Work in the same order as this article: measure first, fix allocations and lifetimes next, and change GC settings last.

  • Measure allocation rates and runtime pauses before changing GC settings; frequent short pauses add up.
  • Cut avoidable allocations with buffer reuse, StringBuilder, Span<T>, stackalloc, and ArrayPool<T>; treat .NET 10 stack allocation as a bonus, not a guarantee.
  • Dispose the resources your class owns. In the dispose pattern, call GC.SuppressFinalize from Dispose() so finalizable objects are not kept alive for an extra collection.
  • Unsubscribe events and stop timers explicitly; publishers and running timers keep objects alive even after your code drops its references.
  • Use WeakReference<T> only for optional data you can re-create, never as a replacement for cleanup.
  • Choose workstation or server GC based on production measurements; with server GC, keep DATAS on unless measurements show you need the throughput back.
  • Use the LOH compaction request only when diagnostics show fragmentation and a full blocking collection is acceptable.
  • Keep no-GC regions short, bounded, and exceptional; they are not a general GC optimization.

Need a refresher on the basics? The earlier articles in this series cover Garbage Collection Fundamentals in .NET and generations, scope, and object lifetimes .