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/usingis for OS resources - the collector does not close handles.- Ownership of an
IDisposabletravels up: dispose what you own. Span<T>slices memory without allocating, and is stack-only so it cannot dangle.