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,
paramsarrays, 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
In .NET 9 with C# 13: params Span<T> avoids the array:
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
For ephemeral data, struct over class.
Tools
BenchmarkDotNet [MemoryDiagnoser]
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
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>
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
Stack-allocated; auto-cleaned on return. Limited size (~1KB safe).
ref struct
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
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:
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
Optimize those. The rest is rarely worth it.
Code: correct vs wrong
❌ Wrong: per-request StringBuilder
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
✅ Correct: ArrayPool
public int Read(Memory<byte> dest) { /* fill into dest */; return n; }
// caller rents from ArrayPool
❌ Wrong: Task.FromResult for hot cache hit
✅ Correct: ValueTask
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
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.