Memory, Allocation and the GC

What an allocation really costs, how generational collection works, server versus workstation GC, pooling, and how to find a leak.

Syntaxbyte[] buffer = ArrayPool<byte>.Shared.Rent(1024); try { /* use buffer */ } finally { ArrayPool<byte>.Shared.Return(buffer); }

In .NET you never free memory yourself. The garbage collector, or GC, finds objects nothing can reach any more and reclaims them. Knowing roughly how it does that explains most .NET performance work.

Allocation is cheap, survival is not

Creating an object usually just moves a pointer forward in a block of memory. The cost arrives later. To reclaim anything the collector must first work out what is still reachable, and that work is proportional to what survives, not to what you allocated.

Generations

New objects go into generation 0. A gen 0 collection looks only at that small region, which is why it is quick, and most objects die there. Survivors are promoted to generation 1, then to generation 2 for long-lived data. A gen 2 collection examines everything and is the expensive one. Objects of 85,000 bytes or more go straight to the large object heap, which is collected with gen 2.

Two flavours

Workstation GC is the runtime default: fewer threads, shorter pauses, less memory. Server GC uses a heap and a collection thread per core and gives far more throughput under concurrent load; turn it on with the MSBuild property ServerGarbageCollection. Since .NET 9, server GC also enables DATAS by default, which grows and shrinks the heap count with demand instead of sizing for the machine.

Pooling

For large, short-lived buffers, reuse beats allocation. ArrayPool<T>.Shared lends arrays and takes them back.

Leaks

A .NET leak is always an unintended reference: a static collection that only grows, an event handler never unsubscribed, or scoped data captured by a singleton. Watch managed memory with dotnet-counters, then take two dotnet-gcdump snapshots and compare which type's count grew.

Shorthand

The MSBuild property <ServerGarbageCollection>true</ServerGarbageCollection> and the runtimeconfig.json setting "System.GC.Server": true are two spellings of the same switch; the first generates the second.

Example

Example · csharp
using System.Buffers;

// Allocating moves a pointer in generation 0, which is cheap. The cost
// arrives later, when the collector walks whatever is still reachable.
byte[][] kept = new byte[1000][];
long before = GC.GetTotalAllocatedBytes(precise: true);
for (int i = 0; i < kept.Length; i++)
{
    kept[i] = new byte[1024];
}
long after = GC.GetTotalAllocatedBytes(precise: true);
Console.WriteLine($"1000 buffers cost at least a megabyte: {after - before >= 1_024_000}");

// A pool lends arrays out again instead of allocating new ones.
byte[] first = ArrayPool<byte>.Shared.Rent(1024);
first[0] = 7;
ArrayPool<byte>.Shared.Return(first);

byte[] second = ArrayPool<byte>.Shared.Rent(1024);
Console.WriteLine($"same array came back: {ReferenceEquals(first, second)}");

// Return did not wipe it, so the earlier byte is still in place.
Console.WriteLine($"stale byte still there: {second[0]}");
ArrayPool<byte>.Shared.Return(second, clearArray: true);

// Output:
// 1000 buffers cost at least a megabyte: True
// same array came back: True
// stale byte still there: 7

When to use it

  • Cut allocations on a hot request path that is spending its time in garbage collection.
  • Decide whether a service should run server or workstation GC inside a small container.
  • Find why a process's memory climbs all week and never comes back down.
  • Reuse large buffers in a file or socket loop instead of allocating one per iteration.

More examples

Choosing the GC flavour in the project file

These three properties cover almost every GC decision a service needs. They matter most in a container, where the runtime sizes itself from the container's memory limit rather than the host's: server GC on a one-core, 256 MB container will use more memory than it buys you. Change one at a time and confirm with a load test, since the right answer depends on your core count and request pattern.

Example · xml
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>

    <!-- A heap and a collector thread per core. Higher throughput,
         more memory. The runtime default is false, meaning workstation. -->
    <ServerGarbageCollection>true</ServerGarbageCollection>

    <!-- Background collection of generation 2. Default is true already;
         turning it off trades shorter total time for longer pauses. -->
    <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>

    <!-- DATAS, on by default with server GC since .NET 9. Set 0 to pin
         the heap count instead of letting it follow demand. -->
    <GarbageCollectionAdaptationMode>1</GarbageCollectionAdaptationMode>
  </PropertyGroup>

</Project>

The most common leak: a subscription never cancelled

Subscribing makes the publisher point at the subscriber, which is the opposite direction from the one you were thinking about. If the publisher outlives the subscriber and nobody unsubscribes, every subscriber is kept alive forever. This is what a gcdump comparison usually turns up: thousands of instances of one short-lived type, all held by one event.

Example · csharp
public sealed class PriceFeed
{
    public event EventHandler<decimal>? PriceChanged;
}

// A long-lived singleton holding the event.
public sealed class Dashboard : IDisposable
{
    private readonly PriceFeed _feed;

    public Dashboard(PriceFeed feed)
    {
        _feed = feed;
        // The feed now holds a reference back to this Dashboard.
        _feed.PriceChanged += OnPriceChanged;
    }

    private void OnPriceChanged(object? sender, decimal price)
    {
    }

    // Without this, every Dashboard ever created stays reachable
    // through the feed and is never collected.
    public void Dispose() => _feed.PriceChanged -= OnPriceChanged;
}

Pooling an object, not just an array

ObjectPool<T> is for objects that are expensive to construct and safe to reuse once reset; StringBuilderPooledObjectPolicy both clears the builder on return and discards any that have grown too large to keep. Reach for pooling only after a measurement shows allocation is the problem, because a pooled object that is used after being returned is a far worse bug than the allocation you saved.

Example · csharp
// Package: Microsoft.Extensions.ObjectPool
using System.Text;
using Microsoft.Extensions.ObjectPool;

var pool = new DefaultObjectPool<StringBuilder>(
    new StringBuilderPooledObjectPolicy());

StringBuilder builder = pool.Get();
try
{
    builder.Append("order ").Append(4711);
    Console.WriteLine(builder.ToString());
}
finally
{
    // The policy clears it before the next caller sees it.
    pool.Return(builder);
}

// Output:
// order 4711

Discussion

  • Be the first to comment on this lesson.