The Hidden Costs of Object Allocations.
Every time you allocate an object on the heap, the .NET runtime has to manage it: track it, move it during garbage collection, and eventually free it. Most of the time this is fine — but in high-performance scenarios, excessive or hidden allocations can cause unnecessary GC pressure and performance drops.
In this post, we’ll look at common sources of allocations, how to spot them, and how to avoid them.
What Happens When You Allocate?
When you write:
var p = new Person();
the runtime:
- Reserves memory on the managed heap.
- Initializes it.
- Registers it with the GC for tracking.
This process is cheap individually, but when done millions of times per second — or inside tight loops — it adds up.
Enumerator Allocations
When you use foreach, you may get a hidden allocation depending on what you’re iterating over (you can read more about foreach loops here → Fundamentals – Part 3).
foreach (var x in myList)
{
Console.WriteLine(x);
}
For List<T>, the enumerator (List<T>.Enumerator) is a struct, meaning it lives on the stack — no heap allocation occurs.
But for many other collections (e.g., IEnumerable<T> implemented by your own class, or LINQ queries), the enumerator is a class, meaning it lives on the heap.
Example 1:
//Example 1 - hidden allocations
IEnumerable<int> query = Enumerable.Range(0, 10).Where(x => x > 5);
foreach (var x in query)
{
Console.WriteLine(x);
}
This LINQ pipeline allocates multiple objects:
- A closure for
x => x > 5 - Enumerator objects for Enumerable.Range and Where
You can often avoid these by using value-type enumerators (like in List
// Example 2 — No hidden allocations (List<T>)
var list = new List<int> { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
foreach (var x in list)
{
if (x > 5)
Console.WriteLine(x);
}
Boxing allocation
Boxing occurs when a value type is converted to a reference type. This allocates a new object on the heap.
int i = 42;
object o = i; // boxing allocation
The same happens when using interfaces with value types:
IComparable c = i; // boxed
Or when using value types in non-generic collections:
ArrayList list = new ArrayList();
list.Add(42); // boxed
To avoid boxing:
- Use generics instead of object.
- Prefer generic collections (
List<T>instead ofArrayList). - Avoid storing value types in interface-typed variables when possible.
Array and Collection Allocations
Arrays are often created implicitly — even when you don’t write new.
var items = new[] { 1, 2, 3 }; // one allocation
Console.WriteLine(string.Join(", ", items)); // allocates a new string internally
LINQ operations also allocate intermediate arrays or lists:
var filtered = numbers.Where(x => x > 10).ToList(); // creates a new List<T>
Each call to ToList(), ToArray(), or even Select() may allocate new collections.
For tight loops, consider reusing buffers (ArrayPool<T>) or processing items lazily.
Closures (Captured Variables)
Closures are one of the most common hidden allocations in C#. When you use lambdas that capture variables from the outer scope, the compiler generates a hidden class to hold those captured values. Example:
int counter = 0;
Func<int> next = () => ++counter;
Here the compiler generates a hidden class to hold counter. That means an allocation — even though you never wrote new.
To avoid unnecessary closures:
- Don’t capture variables if you can avoid it.
- Move logic into static methods.
- Use static lambdas (static () =>) when possible in C# 9+.
Func<int, int> doubleIt = static x => x * 2; // no closure
String Allocations
Strings in .NET are immutable. Every concatenation, substring, or replacement creates a new string object.
string s = "Hello";
s += " World"; // new string allocated
For repeated string manipulations, use StringBuilder:
var sb = new StringBuilder();
for (int i = 0; i < 5; i++)
sb.Append(i);
Console.WriteLine(sb.ToString());
You can also use string.Create to efficiently build strings without intermediate allocations.
Detecting Hidden Allocations
To identify where allocations occur:
- Use performance tools
- dotMemory, dotTrace, or Visual Studio Performance Profiler
- PerfView or dotnet-counters for low-level insights
- Enable allocation trackingIn .NET 8+, use
dotnet-counters monitor --counters System.Runtime[alloc-rate]. - Inspect disassembly or ILLook for
newobj,box, or compiler-generated classes (e.g.,<Main>b__0_0).
Benchmark with BenchmarkDotNet
[MemoryDiagnoser]
public class AllocationTests
{
[Benchmark]
public void WithBoxing()
{
object o = 42;
}
[Benchmark]
public void WithoutBoxing()
{
int i = 42;
}
}
This tool shows exact bytes allocated per operation — perfect for spotting unexpected allocations.
Reducing Allocations
- Reuse objects or buffers (
ArrayPool<T>,StringBuilder.Clear()). - Prefer
Span<T>andMemory<T>for slicing and temporary work. - Avoid
foreachover non-value-type enumerators in hot paths. - Use
structenumerators for performance-critical collections. - Avoid capturing variables in lambdas.
- Cache expensive objects that are repeatedly created.
Final Thoughts
Allocations are not inherently bad — but hidden or frequent allocations in tight loops or critical paths can impact latency, throughput, and GC behavior. The key is awareness:
- Understand where allocations occur.
- Measure using profiling tools.
- Optimize only when it matters.
By learning to recognize these subtle costs, you can write faster, more memory-efficient C# code that scales gracefully under pressure.


