Kill the Allocations: Tactics to Keep the GC Quiet in C#

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.

AllocationHotSpots.cs
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.

ObjectPooling.cs
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.

ValueTypeOptimization.cs
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.

StringOptimization.cs
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

  1. dotnet new console -n GCBench
  2. cd GCBench
  3. Update Program.cs and .csproj
  4. dotnet run -c Release
Program.cs
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();
    }
}
GCBench.csproj
<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.

Reader Questions

How do I know if GC is my bottleneck?

Profile your application under realistic load. Check GC metrics for pause frequency and duration. If Gen0 collections happen frequently and pauses correlate with latency spikes, GC is likely your bottleneck. Use diagnostic tools to identify allocation hot spots.

Should I always use ValueTask instead of Task?

No, use ValueTask only when operations frequently complete synchronously (cache hits, pre-completed work). For truly async operations that always await, Task is simpler and performs identically. ValueTask adds complexity that only pays off with frequent synchronous completion.

When should I pool objects?

Pool expensive-to-create objects used frequently in hot paths. ArrayPool works for byte arrays and buffers. Custom pools help for types with expensive constructors or large initialization overhead. Don't pool simple types that create cheaply.

Does struct always reduce allocations?

Small structs avoid heap allocation but create copy overhead. Large structs passed by value copy expensively. Use structs for small value types (under 16 bytes). Pass larger structs by ref. Make structs readonly to prevent defensive copies.

Back to Articles