Silencing the Garbage Collector
Garbage collection pauses interrupt execution to reclaim memory. Frequent allocations trigger frequent collections, causing latency spikes and reduced throughput. Services processing thousands of requests per second need allocation discipline to maintain consistent performance.
You'll learn patterns that eliminate allocation hot spots. The comparison shows allocation-heavy code versus optimized variants using pooling, stack allocation, and object reuse. Production services see Gen0 collection frequencies drop 90%+ with systematic allocation reduction.
Not every allocation matters. Profile first to identify hot allocation sites where GC pressure impacts performance. Apply optimizations selectively where measurements prove the benefit. Premature optimization adds complexity without delivering value.
Identifying Allocation Hot Spots
Use profilers to find allocation hot spots. Visual Studio's diagnostic tools, dotMemory, or PerfView show which code paths allocate most frequently. Look for methods called thousands of times per second that allocate on every invocation.
Common allocation sources include string concatenation, LINQ operations on hot paths, boxing value types, and creating temporary arrays or lists. Once identified, target these hot spots with allocation-reduction patterns.
using System;
using System.Collections.Generic;
using System.Linq;
// Hot spot: allocates on every call
public class AllocationHeavy
{
public string FormatUser(int id, string name)
{
return $"User {id}: {name}"; // Allocates string
}
public List<int> FilterData(int[] data)
{
return data.Where(x => x > 0).ToList(); // Allocates List + LINQ enumerator
}
public object BoxValue(int value)
{
return value; // Boxing allocation
}
}
// Optimized: reduces allocations
public class AllocationOptimized
{
public string FormatUser(int id, string name)
{
Span<char> buffer = stackalloc char[256];
if (TryFormatUser(buffer, id, name, out int written))
{
return new string(buffer.Slice(0, written));
}
return $"User {id}: {name}";
}
private bool TryFormatUser(Span<char> buffer, int id, string name, out int written)
{
written = 0;
if (!TryWrite(buffer, "User ", ref written)) return false;
if (!id.TryFormat(buffer.Slice(written), out int n)) return false;
written += n;
if (!TryWrite(buffer, ": ", ref written)) return false;
if (!TryWrite(buffer, name, ref written)) return false;
return true;
}
private bool TryWrite(Span<char> buffer, string text, ref int pos)
{
if (pos + text.Length > buffer.Length) return false;
text.AsSpan().CopyTo(buffer.Slice(pos));
pos += text.Length;
return true;
}
public int FilterData(int[] data, int[] result)
{
int count = 0;
for (int i = 0; i < data.Length && count < result.Length; i++)
{
if (data[i] > 0) result[count++] = data[i];
}
return count;
}
public int AvoidBoxing(int value) => value; // No boxing
}
The optimized version uses stack buffers for formatting, pre-allocated arrays instead of LINQ, and avoids boxing. These changes eliminate allocations on the hot path while maintaining functionality.
Object Pooling Patterns
Object pools maintain collections of reusable instances. Instead of creating and discarding objects repeatedly, rent from the pool, use the object, then return it. This works for any expensive-to-create type used frequently.
ArrayPool provides built-in pooling for arrays. For custom types, implement pooling with ObjectPool<T> from Microsoft.Extensions.ObjectPool or create custom pools for specific needs.
using System;
using System.Buffers;
using System.Collections.Generic;
using Microsoft.Extensions.ObjectPool;
public class PoolingExample
{
private static ObjectPool<List<int>> _listPool =
ObjectPool.Create<List<int>>();
// Array pooling
public static void ProcessWithArrayPool(int size)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(size);
try
{
ProcessBuffer(buffer, size);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
}
}
// Object pooling
public static List<int> ProcessWithObjectPool(int[] data)
{
List<int> list = _listPool.Get();
try
{
foreach (int item in data)
{
if (item > 0) list.Add(item);
}
List<int> result = new(list);
return result;
}
finally
{
list.Clear();
_listPool.Return(list);
}
}
private static void ProcessBuffer(byte[] buffer, int count)
{
for (int i = 0; i < count; i++)
buffer[i] = (byte)(i % 256);
}
}
Always return pooled objects in finally blocks. Clear sensitive data and reset state before returning. The pool eliminates allocation overhead after warmup, dramatically reducing GC pressure in high-throughput scenarios.
Struct and ValueTask Optimization
Structs avoid heap allocation when used correctly. Small structs (under 16 bytes) copy cheaply. Use readonly structs to prevent defensive copies. Pass large structs by ref to avoid copy overhead.
ValueTask<T> reduces allocation for frequently-completed async operations. When async methods complete synchronously most of the time, ValueTask avoids Task allocation overhead.
using System;
using System.Threading.Tasks;
public readonly struct Result
{
public readonly bool Success;
public readonly int Value;
public Result(bool success, int value)
{
Success = success;
Value = value;
}
}
public class ValueTaskExample
{
private readonly Dictionary<int, int> _cache = new();
// Task allocation on every call
public async Task<int> GetValueAsync_Task(int key)
{
if (_cache.TryGetValue(key, out int cached))
return cached; // Still allocates Task
await Task.Delay(10);
int value = key * 2;
_cache[key] = value;
return value;
}
// No allocation when cached
public ValueTask<int> GetValueAsync_ValueTask(int key)
{
if (_cache.TryGetValue(key, out int cached))
return new ValueTask<int>(cached); // No allocation
return LoadValueAsync(key);
}
private async ValueTask<int> LoadValueAsync(int key)
{
await Task.Delay(10);
int value = key * 2;
_cache[key] = value;
return value;
}
}
ValueTask eliminates Task allocation when operations complete synchronously. This matters for cache hits, already-completed I/O, or any scenario where async methods frequently return without awaiting. Check cache hits or early returns first before creating the async state machine.
String Allocation Reduction
String operations allocate heavily. Concatenation, formatting, and substring operations all create new string objects. Use StringBuilder for multiple concatenations. Use span-based formatting to avoid intermediate strings. Consider string interning for frequently-used identical strings.
For high-frequency string building, consider pooled StringBuilders or span-based formatters that write directly to buffers.
using System;
using System.Text;
using Microsoft.Extensions.ObjectPool;
public class StringOps
{
private static ObjectPool<StringBuilder> _builderPool =
new DefaultObjectPoolProvider().CreateStringBuilderPool();
// Heavy allocation
public static string BuildMessage_Heavy(int count)
{
string msg = "";
for (int i = 0; i < count; i++)
{
msg += $"Item {i}, "; // Allocates every iteration
}
return msg;
}
// Pooled StringBuilder
public static string BuildMessage_Pooled(int count)
{
StringBuilder sb = _builderPool.Get();
try
{
for (int i = 0; i < count; i++)
{
sb.Append("Item ").Append(i).Append(", ");
}
return sb.ToString();
}
finally
{
_builderPool.Return(sb);
}
}
// Span-based formatting
public static string FormatValue_Span(int value)
{
Span<char> buffer = stackalloc char[32];
if (value.TryFormat(buffer, out int written))
{
return new string(buffer.Slice(0, written));
}
return value.ToString();
}
}
Pooled StringBuilders eliminate allocation for the builder itself. Span-based formatting avoids intermediate allocations. These patterns work well in hot paths that format or build strings thousands of times per second.
Performance Notes
Allocation reduction matters when GC pauses impact latency or throughput. High-frequency allocations trigger Gen0 collections that pause execution. Services processing thousands of operations per second see measurable improvements from allocation discipline.
Monitor GC metrics before and after optimization. Track Gen0 collection frequency, pause times, and allocation rates. Successful optimization shows as reduced collection frequency and improved tail latencies. If metrics don't improve, the allocation wasn't your bottleneck.
Apply patterns selectively. Use pooling for expensive object creation. Use ValueTask when cache hits are common. Use spans for string operations. Use structs for small value types. Each pattern targets specific allocation sources.
Benchmark Playground
Steps
- dotnet new console -n GCBench
- cd GCBench
- Update Program.cs and .csproj
- dotnet run -c Release
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text;
BenchmarkRunner.Run<GCBench>();
[MemoryDiagnoser]
public class GCBench
{
[Benchmark(Baseline = true)]
public string StringConcat()
{
string s = "";
for (int i = 0; i < 100; i++)
s += i.ToString();
return s;
}
[Benchmark]
public string StringBuilder()
{
StringBuilder sb = new();
for (int i = 0; i < 100; i++)
sb.Append(i);
return sb.ToString();
}
}
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net8.0</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings></PropertyGroup><ItemGroup><PackageReference Include="BenchmarkDotNet" Version="0.13.12" /></ItemGroup></Project>
Run result
String concatenation allocates thousands of intermediate strings. StringBuilder allocates once plus the final string. Gen0 collections drop dramatically with StringBuilder.
Performance Considerations
Allocation reduction delivers when GC pressure impacts performance. Real-time systems, high-throughput services, and latency-sensitive applications all benefit from reduced collection frequency. Production metrics show Gen0 collections dropping 80-95% with disciplined allocation management.
Profile to identify hot allocation sites. Use diagnostic tools to find methods allocating frequently. Target these hot spots with pooling, stack allocation, or object reuse patterns. Cold paths can remain simple and allocation-heavy without performance impact.
Monitor production metrics. Track collection frequency, pause times, and latency percentiles. Successful optimization reduces pause frequency and improves tail latencies. If production metrics don't improve meaningfully, allocations weren't your bottleneck.
Balance complexity against gains. Allocation-free code is often more complex than straightforward implementations. Apply optimization where profiling proves GC pressure matters. Keep cold paths clear and maintainable.