Memory, the Heap and Disposal

Article · 15:00

Values live in two places: a stack frame per call, and a heap a garbage collector manages. Generations and why most collections are cheap, the leaks a collector cannot prevent, what IDisposable is for, and where Span avoids a copy.

Memory, the Heap and Disposal

.NET has two places values live. The stack holds a frame per call: its locals, and for a reference-typed local the reference itself. The heap holds every object, and is managed by a garbage collector. A value type declared as a local lives in the frame; the same value type as a field lives wherever its owner does.

var owner = new Owner(new Point(1, 2));
Point copy = owner.Position;       // a struct: this is a COPY
copy = copy with { X = 99 };
Console.WriteLine($"{owner.Position} is unchanged; the copy is {copy}");

var shared = new Box(1);
Box alias = shared;                // a class: two names, one object
alias.Value = 99;
Console.WriteLine($"shared.Value is now {shared.Value}");

readonly record struct Point(int X, int Y);
sealed class Box(int value) { public int Value { get; set; } = value; }
sealed class Owner(Point position) { public Point Position { get; } = position; }

The collector is generational

Most objects die young, and the collector is built around that. New objects go in gen 0, collected often and cheaply; survivors are promoted to gen 1 and then gen 2, which is collected rarely and expensively. Anything over about 85KB goes straight to the Large Object Heap, which is not compacted by default.

Console.WriteLine($"gen 0 collections so far: {GC.CollectionCount(0)}");

long before = GC.GetTotalMemory(false);
long checksum = 0;

for (int i = 0; i < 2_000_000; i++)
{
    var temporary = new Box(i);          // dies immediately: the cheap case
    checksum += temporary.Value;
}

Console.WriteLine($"checksum {checksum}");
Console.WriteLine($"gen 0 collections now:   {GC.CollectionCount(0)}");
Console.WriteLine($"gen 2 collections:       {GC.CollectionCount(2)}");
Console.WriteLine($"heap grew by about {(GC.GetTotalMemory(false) - before) / 1_000_000}MB");
Console.WriteLine("(indicative: the collector runs when it chooses)");

sealed class Box(int value) { public int Value { get; } = value; }

Two consequences. Short-lived garbage is nearly free, so contorting code to avoid an allocation is usually not worth it. And an object that survives long enough to reach gen 2 and then dies is the expensive case - which is exactly what a cache with the wrong lifetime produces.

What leaks in a language with a collector

An object is collectable when nothing can reach it. "Unreachable", not "unused", is the rule - and every .NET leak is something still holding a reference.

var publisher = new Publisher();

for (int i = 0; i < 3; i++)
{
    var subscriber = new Subscriber(i);
    publisher.Changed += subscriber.OnChanged;     // the publisher now holds it
}

publisher.Raise();
Console.WriteLine("three subscribers are unreachable from here - and still alive");
Console.WriteLine("because the event's delegate list references them");

// The fix is to unsubscribe, which is what IDisposable is usually for.
var temporary = new Subscriber(99);
publisher.Changed += temporary.OnChanged;
publisher.Changed -= temporary.OnChanged;
publisher.Raise();

sealed class Publisher
{
    public event Action<string>? Changed;
    public void Raise() => Changed?.Invoke("event");
}

sealed class Subscriber(int id)
{
    public void OnChanged(string message) => Console.WriteLine($"  subscriber {id}: {message}");
}

The four classic sources: an event subscription never removed, a static collection that only grows, a cached object with no eviction, and a Timer or long-running task holding a closure over something large.

IDisposable is for what the collector does not manage

The collector frees memory. It does not close a file handle, a socket, or a database connection - those are operating-system resources with their own limits, and the IDisposable pattern is how .NET releases them deterministically.

using (var connection = new Connection("db"))
{
    connection.Use();
}
Console.WriteLine("closed at the end of the using block, not when the GC feels like it");

await using (var asyncConnection = new AsyncConnection("cache"))
{
    await asyncConnection.UseAsync();
}

sealed class Connection(string name) : IDisposable
{
    public void Use() => Console.WriteLine($"using {name}");
    public void Dispose() => Console.WriteLine($"disposed {name}");
}

sealed class AsyncConnection(string name) : IAsyncDisposable
{
    public async Task UseAsync()
    {
        await Task.Delay(1);
        Console.WriteLine($"using {name}");
    }

    public async ValueTask DisposeAsync()
    {
        await Task.Delay(1);
        Console.WriteLine($"disposed {name} asynchronously");
    }
}

A class that owns an IDisposable field should itself be IDisposable and dispose it - the obligation travels up the ownership chain. And never write a finaliser unless you own an unmanaged handle directly: it delays collection by a whole generation and runs on a thread you do not control.

Span: working with memory without copying it

string csv = "ada,36,uk";

// Substring allocates a new string each time. AsSpan does not.
ReadOnlySpan<char> span = csv.AsSpan();
int firstComma = span.IndexOf(',');
ReadOnlySpan<char> name = span[..firstComma];
Console.WriteLine($"name is {name} ({name.Length} chars, no allocation)");

// stackalloc puts a small buffer in the frame, so it is never collected at all.
Span<int> buffer = stackalloc int[8];
for (int i = 0; i < buffer.Length; i++) buffer[i] = i * i;
Console.WriteLine(string.Join(",", buffer.ToArray()));

// The restriction that makes it safe: a Span cannot be stored on the heap, so
// it cannot outlive the memory it points at. It cannot be a field of a class,
// and cannot be used across an await.
Console.WriteLine("Span is a stack-only type, and the compiler enforces it");

What to take away

  • Value types copy on assignment; reference types alias. That decides more than syntax does.
  • The collector is generational: young garbage is cheap, promoted-then-dead is not.
  • A leak is a reference you forgot: events, static collections, caches, timers.
  • IDisposable/using is for OS resources - the collector does not close handles.
  • Ownership of an IDisposable travels up: dispose what you own.
  • Span<T> slices memory without allocating, and is stack-only so it cannot dangle.