.NET & C# Development · Lesson 67 of 92
Zero-Allocation Code — Span<T>, ArrayPool & Object Pooling
How the .NET GC Works
The GC divides the managed heap into generations:
- Gen 0 — short-lived objects (most allocations). Collected frequently, very fast.
- Gen 1 — objects that survived Gen 0. Buffer between Gen 0 and Gen 2.
- Gen 2 — long-lived objects (statics, caches). Collected infrequently, expensive.
- LOH (Large Object Heap) — objects >= 85,000 bytes. Collected only with Gen 2. Never compacted by default.
The GC is generational because most objects die young. If you create lots of large, short-lived objects they land on the LOH, cause Gen 2 collections, and fragment the heap. That's expensive.
What Causes Allocations
String concatenation in loops:
// Bad — creates a new string on every iteration
string result = "";
foreach (var item in items)
result += item.Name + ", ";LINQ chains: every Select, Where, and ToList allocates an enumerator and potentially a new collection.
Boxing: converting value types to object or interface types allocates on the heap.
int x = 42;
object boxed = x; // heap allocation
IComparable c = x; // also boxing
var list = new List<object> { x }; // boxing on each addParams arrays:
void Log(string format, params object[] args) { ... }
Log("Value: {0}", 42); // allocates object[] and boxes 42Span<T> and Memory<T>
Span<T> is a stack-allocated view over a contiguous region of memory — an array, a string, stack memory, or native memory. No heap allocation.
// Slicing without allocating a new string
ReadOnlySpan<char> input = "Hello, World!".AsSpan();
ReadOnlySpan<char> hello = input.Slice(0, 5); // no allocation
// Parsing without substring
bool TryParseIntFast(ReadOnlySpan<char> input, out int result) =>
int.TryParse(input, out result);
// Zero-copy CSV column read
void ProcessCsvLine(ReadOnlySpan<char> line)
{
int comma = line.IndexOf(',');
var name = line.Slice(0, comma);
var value = line.Slice(comma + 1);
// work with name and value without allocating
}Memory<T> is the async-compatible version of Span<T> — use it when you need to pass a slice across await boundaries.
public async Task WriteChunkAsync(Memory<byte> buffer, CancellationToken ct)
{
await stream.WriteAsync(buffer, ct); // no copy, no allocation
}ArrayPool<T> to Avoid LOH Allocations
Don't allocate large temporary byte arrays — rent and return them.
// Bad — 1MB array on the LOH, never compacted
byte[] buffer = new byte[1024 * 1024];
// Good — rent from the pool
byte[] buffer = ArrayPool<byte>.Shared.Rent(1024 * 1024);
try
{
int read = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), ct);
Process(buffer.AsSpan(0, read));
}
finally
{
ArrayPool<byte>.Shared.Return(buffer, clearArray: false);
}The pool reuses arrays, eliminating Gen 2 collections from large temporary allocations. clearArray: false skips zeroing for performance — only use true if the buffer held sensitive data.
string.Create for Hot-Path String Building
String interpolation allocates an intermediate DefaultInterpolatedStringHandler. For one-off strings it's fine. For hot paths:
// Interpolation — readable but allocates
string id = $"{prefix}-{sequence:D6}";
// string.Create — zero-intermediate-allocation
string id = string.Create(prefix.Length + 7, (prefix, sequence), static (span, state) =>
{
state.prefix.AsSpan().CopyTo(span);
span[state.prefix.Length] = '-';
state.sequence.TryFormat(span.Slice(state.prefix.Length + 1), out _, "D6");
});For general-purpose string building in a loop, StringBuilder is still the right tool — it batches allocations internally.
IDisposable and Using Patterns
Objects wrapping unmanaged resources (file handles, DB connections, HTTP connections, streams) implement IDisposable. Not disposing them leaks the resource.
// Always use using — compiler calls Dispose() even on exception
using var stream = new FileStream(path, FileMode.Open);
using var reader = new StreamReader(stream);
var content = await reader.ReadToEndAsync();
// Explicit for scoped lifetimes
using (var scope = serviceScopeFactory.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.SaveChangesAsync();
} // scope.Dispose() called here, releasing db connectionImplement IDisposable yourself when your class holds disposable fields:
public class ReportGenerator : IDisposable
{
private readonly HttpClient _client;
private bool _disposed;
public ReportGenerator(HttpClient client) => _client = client;
public void Dispose()
{
if (_disposed) return;
_client.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
}Detecting Memory Leaks
dotnet-counters — live process metrics:
dotnet-counters monitor --process-id <PID> System.RuntimeWatch gen-2-gc-count, loh-size, and working-set. Steady growth in gen-2-gc-count with rising working-set is the leak signature.
dotnet-dump — take a heap snapshot and inspect retained objects:
dotnet-dump collect --process-id <PID>
dotnet-dump analyze <dump-file>
# inside the REPL:
> dumpheap -stat # top types by instance count
> dumpheap -type EventHandler # look for event handler leaks
> gcroot <address> # who's holding a reference?Common Leak Patterns
Static event subscriptions:
// AppDomain.CurrentDomain.UnhandledException never goes out of scope
// Every closure captured here lives forever
AppDomain.CurrentDomain.UnhandledException += (_, e) => Log(e);
// Fix: unsubscribe in Dispose
public void Dispose() => AppDomain.CurrentDomain.UnhandledException -= _handler;Forgotten IDisposable from DI:
// This creates a DbContext that never gets disposed
var db = serviceProvider.GetService<AppDbContext>();
// Fix: GetRequiredService inside a scope, then dispose the scopeCaptured closures holding large objects:
byte[] largeBuffer = new byte[10 * 1024 * 1024];
Timer timer = new Timer(_ => Process(largeBuffer), null, 0, 1000);
// largeBuffer is kept alive as long as the timer livesSet largeBuffer = null after you're done if the timer outlives the buffer's usefulness, or restructure so the closure doesn't capture it.