๐ Lesson 4.3: Allocations, GC, and Benchmarking
The garbage collector frees you from manual memory management โ but allocations aren't free. This lesson explains how the GC works, how to reduce allocation pressure, and how to measure performance rigorously instead of guessing.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Explain the generational garbage collector and why allocations cost
- Identify common hidden allocation sources
- Reduce allocations with pooling,
StringBuilder, and spans - Write benchmarks with
BenchmarkDotNet - Read benchmark output and optimize based on data, not guesses
Estimated Time: 75 minutes
Project: Benchmark two implementations and prove which allocates less.
In This Lesson
How the GC Works
The garbage collector automatically reclaims heap objects that are no longer referenced. It's generational, based on a key observation: most objects die young (temporary variables, short-lived results), while a few live long (caches, singletons).
๐ The three generations
- Gen 0 โ brand-new, small objects. Collected very frequently and very fast.
- Gen 1 โ survived one collection; a buffer between short- and long-lived.
- Gen 2 โ long-lived objects. Collected rarely, but a Gen 2 collection is the most expensive.
Objects that survive a collection are promoted to the next generation. There's also the Large Object Heap (LOH) for big allocations (โฅ 85,000 bytes), collected with Gen 2.
(collected often, cheap)"] G0 -->|"survives"| G1["Gen 1"] G1 -->|"survives"| G2["Gen 2
(collected rarely, costly)"] G0 -->|"most die here"| X["Reclaimed"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style X fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style G2 fill:#fff3cd,stroke:#ffc107,stroke-width:2px
Because Gen 0 is cheap, short-lived garbage is inexpensive to collect. The trouble comes from volume and from objects that live just long enough to be promoted.
Why Allocations Cost
Allocating memory itself is fast (basically bumping a pointer). The cost is what comes after: every allocation brings a future collection closer. When the GC runs, it does work proportional to the live objects โ and may briefly pause your threads.
โ ๏ธ The real problem is allocation rate
A high allocation rate in a hot path means frequent Gen 0 collections, more promotion to Gen 1/2, and more (and longer) GC pauses. In throughput-sensitive servers or latency-sensitive apps (games, trading), this GC overhead โ not your logic โ can become the bottleneck. Reducing allocations reduces GC work.
๐ก Keep perspective: for typical apps, the GC is excellent and you should not fight it. This lesson's techniques are for measured hot paths. The goal isn't "zero allocations everywhere" โ it's "no wasteful allocations where it counts."
Hidden Allocation Sources
Many allocations are invisible in the source. Learn to spot these on hot paths:
| Source | Allocates |
|---|---|
Boxing (value type โ object) | a heap object per box (Lesson 4.1) |
| String concatenation in a loop | a new string per + |
| LINQ in a hot loop | iterators, delegates, and closures per call |
| Closures capturing variables | a hidden class holding the captured state |
params arrays | a new array per call |
| Substring / Split | new strings/arrays (Lesson 4.2) |
// Allocation-heavy: a new string every iteration (strings are immutable)
string result = "";
for (int i = 0; i < 10_000; i++)
{
result += i + ","; // each += allocates a brand-new string
}
๐ก Closures are sneaky
A lambda that captures a local variable (Intermediate, delegates) compiles to a hidden class instance holding that variable โ an allocation. In a tight loop, capturing lambdas can allocate a lot. Passing state as parameters, or using static lambdas (static x => ..., which can't capture), avoids it.
Reducing Allocations
A toolkit, roughly from most to least commonly useful:
StringBuilder for string building
var sb = new System.Text.StringBuilder();
for (int i = 0; i < 10_000; i++)
{
sb.Append(i).Append(','); // mutates one buffer instead of 10,000 strings
}
string result = sb.ToString(); // one allocation at the end
Span & stackalloc (Lesson 4.2)
Slice and parse without copying; use small stack buffers for scratch work.
ArrayPool<T> โ rent instead of allocate
For temporary arrays in hot paths, rent a buffer from a shared pool and return it, instead of allocating a fresh array each time:
using System.Buffers;
int[] buffer = ArrayPool<int>.Shared.Rent(1024); // reuse a pooled array
try
{
// ... use buffer (it may be larger than requested) ...
}
finally
{
ArrayPool<int>.Shared.Return(buffer); // give it back for reuse
}
Other techniques
- Avoid boxing โ use generics and generic APIs (Lesson 4.1).
- Hoist allocations out of loops โ allocate once, reuse inside.
- Prefer
structfor tiny, short-lived values where value semantics fit (Lesson 4.1). - Cache immutable results instead of recomputing/reallocating them.
โ ๏ธ Optimize the measured hot path โ not everything
Every technique here adds complexity. Applying them blindly makes code harder to read for no real gain. First measure to find the true hot spot; then optimize that; then measure again to confirm it helped. Which is exactly what benchmarking is for.
Benchmarking with BenchmarkDotNet
Timing code with Stopwatch (as we did in Module 2) is fine for a rough comparison, but it's easily misled by JIT warm-up, GC timing, and CPU scaling. BenchmarkDotNet is the industry-standard library that handles all of that โ warm-up, many iterations, statistics, and allocation measurement โ automatically.
# Add the package to a Release-mode console project
dotnet add package BenchmarkDotNet
Mark methods with [Benchmark] and add [MemoryDiagnoser] to report allocations:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser] // also report allocations & GC
public class StringBenchmarks
{
private readonly int[] _numbers = Enumerable.Range(0, 1000).ToArray();
[Benchmark(Baseline = true)]
public string Concatenation()
{
string result = "";
foreach (int n in _numbers) result += n + ",";
return result;
}
[Benchmark]
public string StringBuilder()
{
var sb = new System.Text.StringBuilder();
foreach (int n in _numbers) sb.Append(n).Append(',');
return sb.ToString();
}
}
// In Main:
BenchmarkRunner.Run<StringBenchmarks>();
โ ๏ธ Always benchmark in Release, never under the debugger
Debug builds disable optimizations and the debugger perturbs timing โ results would be meaningless. Run with dotnet run -c Release. BenchmarkDotNet even refuses to run a Debug build to protect you from this mistake.
Reading the Results
BenchmarkDotNet prints a table. A simplified example for the two methods above:
| Method | Mean | Ratio | Allocated |
|---|---|---|---|
| Concatenation | ~52 ยตs | 1.00 | ~2.9 MB |
| StringBuilder | ~9 ยตs | 0.17 | ~16 KB |
How to read it:
- Mean โ average time per operation (lower is better).
- Ratio โ relative to the baseline;
0.17means ~6ร faster. - Allocated โ memory allocated per operation (from
[MemoryDiagnoser]). Here theStringBuilderversion allocates ~180ร less โ the concatenation created thousands of throwaway strings.
โ Data settles debates
Now the choice isn't opinion โ the numbers show StringBuilder is both dramatically faster and far lighter on memory for this workload. That's the value of benchmarking: it turns "I think this is faster" into "this is measurably 6ร faster and allocates 180ร less."
๐ก The optimization loop: measure โ find the hot spot โ optimize it โ measure again. Never skip the final measurement โ some "optimizations" make things slower, and only data will tell you.
Exercise & Quiz
๐๏ธ Exercise: Benchmark Sum Implementations
Objective: Use BenchmarkDotNet with [MemoryDiagnoser] to compare allocations of two approaches.
Instructions:
- Create a Release console project called
Benchand add theBenchmarkDotNetpackage. - Prepare an
int[]of 1,000,000 values as a field. - Write two
[Benchmark]methods that sum only the even numbers: (a)LinqSumusingnumbers.Where(n => n % 2 == 0).Sum(); (b)LoopSumusing a plainforeachwith an accumulator. - Add
[MemoryDiagnoser]and markLinqSumas the baseline. - Run with
dotnet run -c Releaseand compare Mean and Allocated.
Starter Code:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<SumBenchmarks>();
[MemoryDiagnoser]
public class SumBenchmarks
{
private readonly int[] _numbers = Enumerable.Range(0, 1_000_000).ToArray();
// TODO: [Benchmark(Baseline = true)] LinqSum
// TODO: [Benchmark] LoopSum
}
๐ก Hint
LINQ version: return _numbers.Where(n => n % 2 == 0).Sum(); (the lambda/iterator add small allocations). Loop version: long total = 0; foreach (var n in _numbers) if (n % 2 == 0) total += n; return total; (should allocate ~0).
โ Solution
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<SumBenchmarks>();
[MemoryDiagnoser]
public class SumBenchmarks
{
private readonly int[] _numbers = Enumerable.Range(0, 1_000_000).ToArray();
[Benchmark(Baseline = true)]
public long LinqSum() => _numbers.Where(n => n % 2 == 0).Sum();
[Benchmark]
public long LoopSum()
{
long total = 0;
foreach (int n in _numbers)
{
if (n % 2 == 0) total += n;
}
return total;
}
}
Typical result: LoopSum is faster and allocates essentially nothing, while LinqSum is more readable but allocates a little and runs somewhat slower. The lesson isn't "never use LINQ" โ it's "on a measured hot path, a plain loop can win; elsewhere, prefer LINQ's clarity."
๐ฏ Quick Quiz
Question 1: Why is the GC generational?
Question 2: In a hot loop building a big string, what reduces allocations most?
Question 3: Before optimizing for performance, you shouldโฆ
Summary
๐ Key Takeaways
- The GC is generational (Gen 0/1/2 + LOH): most objects die young, so Gen 0 collections are cheap; Gen 2 is costly.
- Allocating is fast, but a high allocation rate drives frequent GC pauses โ the real performance concern on hot paths.
- Watch for hidden allocations: boxing, string
+=, LINQ/closures in tight loops,params,Substring/Split. - Reduce them with
StringBuilder, spans/stackalloc,ArrayPool<T>, avoiding boxing/closures, and hoisting allocations out of loops. - Measure with BenchmarkDotNet (
[Benchmark],[MemoryDiagnoser], Release mode) โ optimize the measured hot path, then confirm with data.
๐ Additional Resources
- Garbage collection fundamentals โ Microsoft Docs
- BenchmarkDotNet โ official site
- ArrayPool<T> โ API reference
๐ What's Next?
That completes Module 4 โ you can reason about memory and prove performance! In the final module, we make code adaptable and well-architected. First: Lesson 5.1: Attributes and Reflection, inspecting and using types at runtime.
๐ Module 4 complete!
You understand the GC, cut allocations, and measure like a pro. Next: metaprogramming and architecture.