Skip to content

Allocation Hunting

Key Points

  • Most .NET perf wins come from reducing allocations, not algorithmic changes. GC pressure → pauses → tail latency.
  • Tools: BenchmarkDotNet [MemoryDiagnoser], dotnet-counters GC counters, dotnet-gcdump diff, JetBrains dotMemory, PerfView GC events.
  • Common allocation hotspots: string concat, LINQ on hot paths, params arrays, lambda closures, boxing, Task<T> for synchronous results, ToList()/ToArray().
  • Tooling: Span<T>, Memory<T>, ArrayPool<T>, ObjectPool<T>, stackalloc, ref struct, struct enumerators, ValueTask.
  • Pareto rule: 80% of allocations come from 20% of code. Profile before optimizing.

Concepts (deep dive)

Why allocations matter

Alloc → eden full → Gen 0 GC → if survives → promote
Many Gen 0 → frequent pauses (~1ms each)
Long-lived growth → Gen 2 → multi-second pauses

A high-RPS API allocating heavily can spend 5–15% of CPU on GC. Reducing this gives free throughput.

Common allocation hotspots

String concat in loops

// ❌ Allocates n*(n+1)/2 chars worth of intermediate strings
var s = "";
foreach (var w in words) s += w;

// ✅ StringBuilder
var sb = new StringBuilder();
foreach (var w in words) sb.Append(w);
return sb.ToString();

// ✅ Or string.Join when you have a collection
return string.Join("", words);

// ✅ Even faster: string.Create with span
return string.Create(totalLength, words, (span, ws) =>
{
    var pos = 0;
    foreach (var w in ws) { w.AsSpan().CopyTo(span[pos..]); pos += w.Length; }
});

LINQ on hot paths

// ❌ Creates iterators, captures, lambdas
return items.Where(x => x.Active).Select(x => x.Name).ToList();

// ✅ Foreach + manual list
var result = new List<string>(items.Length);
foreach (var x in items) if (x.Active) result.Add(x.Name);
return result;

LINQ is fine for cold paths; on hot paths, hand-rolled foreach is leaner.

Boxing

// ❌ int boxed to object
object o = 42;
List<object> items = new() { 1, 2, 3 };          // 3 boxes
Dictionary<string, object> d = new() { ["x"] = 5 };   // 1 box

// ✅ Generic
List<int> items = new() { 1, 2, 3 };

int.ToString() doesn't box. int.Equals(otherInt) doesn't. But (object)1 == (object)1 does.

Lambda closures

// ❌ Captures `prefix` → closure object alloc
public IEnumerable<string> WithPrefix(string prefix, IEnumerable<string> names)
    => names.Select(n => prefix + n);

// ✅ Pass as state to avoid capture (LINQ doesn't support; manual loop)
public IEnumerable<string> WithPrefix(string prefix, IEnumerable<string> names)
{
    foreach (var n in names) yield return prefix + n;
}

The yield-iterator pattern still allocates an enumerator, but no closure capture.

params arrays

public void M(params int[] args) { }
M(1, 2, 3);   // allocates int[3]

In .NET 9 with C# 13: params Span<T> avoids the array:

public void M(params ReadOnlySpan<int> args) { }   // stack-allocated
M(1, 2, 3);   // no alloc

Task<T> for sync results

// ❌ Allocates Task wrapping the int
public Task<int> GetAsync() => Task.FromResult(42);

// ✅ ValueTask — struct
public ValueTask<int> GetAsync() => new(42);

ToList() / ToArray() unnecessarily

// ❌ Allocates list just to iterate
foreach (var x in items.Where(x => x.Active).ToList()) ...

// ✅ Skip materialization
foreach (var x in items.Where(x => x.Active)) ...

Default ctor on struct vs class

record class C(int X);   // allocates per instance
record struct S(int X);  // no alloc; copies

For ephemeral data, struct over class.

Tools

BenchmarkDotNet [MemoryDiagnoser]

| Method | Allocated |
|--------|----------:|
| A      |   1.2 KB  |
| B      |   0.0 B   |   ← target

Always include [MemoryDiagnoser].

dotnet-gcdump

dotnet-gcdump collect -p $(pgrep myapp)   # snapshot 1
# wait
dotnet-gcdump collect -p $(pgrep myapp)   # snapshot 2
# diff in VS to find growing types

dotnet-counters

gen-0-gc-count           50/sec   ← high churn
allocation-rate          200 MB/sec

High alloc rate + high Gen 0 → reduce per-request allocations.

dotMemory / PerfView

Object retention graphs; allocation call stacks. Ground truth for what's allocating.

Tooling for low-alloc code

Span<T> / Memory<T>

// Slice without allocation
ReadOnlySpan<char> s = "hello world".AsSpan(6, 5);

ArrayPool<T>

var pool = ArrayPool<byte>.Shared;
byte[] buf = pool.Rent(8192);
try { /* use buf */ }
finally { pool.Return(buf, clearArray: false); }

Reuses buffers across calls. Crucial for byte[] heavy code (serialization, networking).

ObjectPool<T>

builder.Services.AddSingleton<ObjectPoolProvider, DefaultObjectPoolProvider>();
builder.Services.AddSingleton(sp =>
    sp.GetRequiredService<ObjectPoolProvider>().Create(new StringBuilderPooledObjectPolicy()));

public class C(ObjectPool<StringBuilder> pool)
{
    public string Build()
    {
        var sb = pool.Get();
        try { /* use sb */; return sb.ToString(); }
        finally { pool.Return(sb); }
    }
}

Built into ASP.NET Core; pools StringBuilders, paths, etc.

stackalloc

Span<int> tmp = stackalloc int[16];

Stack-allocated; auto-cleaned on return. Limited size (~1KB safe).

ref struct

public ref struct StackOnlyContext
{
    public Span<byte> Buffer;
}

Can't be heap-allocated. Span<T> is one. Use for stack-only handles to memory.

Struct enumerators

// List<T>'s GetEnumerator returns a struct → no alloc on foreach
foreach (var x in list) { /* no GetEnumerator() heap alloc */ }

Most BCL types do this. Custom collections should too.

string.Create

string s = string.Create(n, state, (span, st) =>
{
    // fill span directly
});

Avoids intermediate buffer; the most efficient way to build complex strings.

Interpolated string handlers

public void Log(LogLevel level, [InterpolatedStringHandlerArgument("level")] DefaultInterpolatedStringHandler handler)
{ /* no formatting unless level enabled */ }

[LoggerMessage] source generator handles this for you. The pattern: don't format if the consumer doesn't need it.

Utf8Formatter and Utf8JsonWriter

For network code, write UTF-8 directly:

Utf8Formatter.TryFormat(value, dest, out var written);

Avoids string → byte conversion.

Source generators eliminate allocations

  • [LoggerMessage] — no string formatting alloc when level filtered.
  • JsonSerializerContext — generated code; no reflection alloc.
  • [GeneratedRegex] — compiled regex; no setup alloc.

Profile before optimizing

70% of allocations: 5 hot methods

Optimize those. The rest is rarely worth it.


Code: correct vs wrong

❌ Wrong: per-request StringBuilder

public string Build(...) { var sb = new StringBuilder(); /* ... */; return sb.ToString(); }

Allocates per call.

✅ Correct: pool

public string Build(...) { var sb = _pool.Get(); try { /* ... */; return sb.ToString(); } finally { _pool.Return(sb); } }

❌ Wrong: byte[] Read(int n) allocating each time

public byte[] Read(int n) { var b = new byte[n]; /* fill */; return b; }

✅ Correct: ArrayPool

public int Read(Memory<byte> dest) { /* fill into dest */; return n; }
// caller rents from ArrayPool

❌ Wrong: Task.FromResult for hot cache hit

public Task<int> GetCachedAsync(int id) => Task.FromResult(_cache[id]);

✅ Correct: ValueTask

public ValueTask<int> GetCachedAsync(int id) => new(_cache[id]);

Design patterns for this topic

Pattern 1 — "Pool over allocate"

  • Intent: ArrayPool / ObjectPool for hot buffers/objects.

Pattern 2 — "Span/Memory for slicing"

  • Intent: zero-allocation views.

Pattern 3 — "ValueTask for sync hot paths"

  • Intent: no Task allocation when result sync.

Pattern 4 — "Source generators for log/JSON/regex"

  • Intent: eliminate runtime alloc.

Pattern 5 — "Profile + optimize the 5 hot spots"

  • Intent: Pareto principle.

Pros & cons / trade-offs

Tool Pros Cons
ArrayPool Reuse Manual return; clearing concerns
ObjectPool Stateful objects Reset logic
Span Zero-alloc slice Stack-only
ValueTask No alloc on sync Constraints
Source-gen Compile-time Dep on .NET version

When to use / when to avoid

  • Use allocation reduction in hot paths.
  • Use profiling first.
  • Avoid premature low-alloc gymnastics on cold paths — readability matters.

Interview Q&A

Q1. Why do allocations hurt perf? GC pressure → pauses → tail latency. CPU also spent on GC instead of work.

Q2. Top sources of allocation in typical code? String concat, LINQ on hot paths, lambda closures, boxing, Task.FromResult, params arrays.

Q3. ArrayPool vs new byte[]? ArrayPool reuses buffers across calls. Critical for high-throughput byte[] code.

Q4. ValueTask vs Task — when? Hot paths returning sync most of the time. Avoids Task allocation on cache hits.

Q5. Span vs Memory? Span: stack-only ref struct; can't cross async. Memory: heap-friendly; can be awaited.

Q6. How spot allocations? BenchmarkDotNet [MemoryDiagnoser]; dotnet-gcdump diff; PerfView GC events.

Q7. Boxing example? object o = 42; boxes int to heap. Avoid in hot paths; use generics.

Q8. Why is params int[] allocating? Implicit array. C# 13 params Span<T> stack-allocates.

Q9. ObjectPool reset semantics? Pool policy returns object to pool; you decide whether/how to reset state. StringBuilder pool clears on return.

Q10. Allocations vs algorithmic improvements? Algorithmic first (big-O), then allocations. But allocations often dominate at micro-scale.

Q11. Pooling overhead? Renting/returning isn't free. Pool wins when objects are large or frequently used.

Q12. ref struct restrictions? Can't be heap-allocated; can't cross async boundaries; can't be in collections. Stack-only.


Gotchas / common mistakes

  • ⚠️ Pool but forget Return — leak.
  • ⚠️ String concat in loops.
  • ⚠️ LINQ on tight inner loops.
  • ⚠️ Boxing via object collections.
  • ⚠️ new byte[] per request for buffers.
  • ⚠️ Premature optimization without profiling.

Further reading