Every time you write new int[] { 1, 2, 3 } or capture a variable in a lambda, the runtime allocates an object on the managed heap. That object eventually needs to be collected by the garbage collector, which costs CPU time and can introduce latency. For hot paths — tight loops, high-throughput API endpoints, serialisation pipelines — those small allocations compound into real performance problems.
What if the JIT compiler could prove that certain objects never outlive their parent method and skip the heap entirely? That's exactly what .NET 10's expanded escape analysis does. The JIT now automatically stack-allocates arrays, delegates, and objects referenced through struct fields when it can guarantee they don't escape. No code changes required. No stackalloc keyword. No unsafe blocks. The optimisation happens transparently during compilation, and in some benchmarks it cuts allocation by 100% and triples throughput.
This isn't a niche optimisation for runtime internals. It affects everyday patterns that every .NET developer writes — foreach loops over arrays cast to IEnumerable<T>, lambdas passed to local helper methods, temporary buffers created inline. If your application targets .NET 10, you're already benefiting from this. Understanding how it works helps you write code that stays on the fast path.
What escape analysis actually does
Escape analysis answers one question: can this object be reached after its parent method returns? An object "escapes" when any of the following happens:
- It's returned from the method.
- It's assigned to a field on another object or a static variable.
- It's passed to a method the JIT cannot inline or analyse.
If none of those conditions hold, the object's lifetime is bounded by the method's stack frame. The JIT can allocate it on the stack — a region of memory that's freed automatically when the method returns, with zero GC involvement.
.NET has had limited escape analysis since .NET 6, primarily for boxed value types. .NET 9 expanded this to simple reference types. But .NET 10 is where the feature becomes genuinely impactful, with support for arrays (both value-type and reference-type elements), delegates, struct field references, and conditional escape paths through guarded devirtualisation.
Arrays that never leave the method
The most straightforward improvement: small, fixed-size arrays that stay local to a method are now stack-allocated.
public static int CalculateTotal(int a, int b, int c)
{
int[] scores = [a, b, c];
int total = 0;
for (int i = 0; i < scores.Length; i++)
{
total += scores[i];
}
return total;
}
In .NET 9, scores is a heap allocation — three integers plus the array header, roughly 48 bytes per call. In .NET 10, the JIT sees that scores is a fixed-size array of value types, that it's never stored in a field, never returned, and never passed to an opaque method. The array moves to the stack. The generated assembly no longer contains a call to the heap allocation helper (CORINFO_HELP_NEWARR_1_VC), and the method allocates exactly zero bytes on the managed heap.
This also works for reference-type arrays. In .NET 10, the JIT can stack-allocate small arrays of reference types:
public static void PrintGreetings(string firstName, string lastName)
{
string[] parts = [firstName, lastName];
foreach (var part in parts)
{
Console.WriteLine(part);
}
}
The parts array references strings that live elsewhere on the heap, but the array itself — the container — is stack-allocated because it doesn't escape.
// TIP
The JIT imposes practical size limits on stack-allocated arrays. Arrays of value types up to roughly 512 bytes and reference-type arrays with a small, compile-time-known length are candidates. Larger or dynamically-sized arrays remain heap-allocated.
Delegates and closures
Lambda expressions compile to two objects: a closure class instance (holding captured variables) and a Func<> or Action<> delegate pointing at the closure's method. In .NET 9, both are heap-allocated. In .NET 10, the JIT can prove that a delegate's Invoke method doesn't retain the this reference, meaning the delegate itself can be stack-allocated when it doesn't escape.
public static int ComputeWeightedSum(int[] values, int weight)
{
Func<int, int> applyWeight = x => x * weight;
int sum = 0;
foreach (int value in values)
{
sum += applyWeight(value);
}
return sum;
}
In .NET 9, this method allocates approximately 88 bytes per call — 24 bytes for the closure (capturing weight) and 64 bytes for the Func<int, int> delegate. In .NET 10, the delegate is stack-allocated. The closure still lives on the heap (24 bytes), but the delegate allocation disappears entirely. Benchmarks show this pattern running roughly three times faster with 73% fewer bytes allocated.
// NOTE
The runtime team plans to expand escape analysis to support stack allocation of closures in a future release. For now, the closure object remains heap-allocated if it captures any variables, but the delegate itself is the larger of the two allocations and is the one eliminated.
Struct field references and Span
.NET 10's escape analysis now tracks objects through struct fields. Previously, assigning a locally-created array to a field on a local struct would cause the JIT to mark the array as escaping — even when the struct itself was entirely local.
public static int SumFirstElement()
{
int[] data = new int[10];
var wrapper = new ArrayWrapper { Values = data };
return wrapper.Values[0];
}
struct ArrayWrapper
{
public int[] Values;
}
In .NET 9, the JIT can't reason about data being stored in wrapper.Values — it conservatively marks data as escaping and heap-allocates it. In .NET 10, the JIT tracks the data flow through the struct field. Since wrapper is a local struct that doesn't escape, and data is only reachable through wrapper, the array is stack-allocated.
This has a cascading effect on Span<T> usage. When BitConverter.GetBytes() and .AsSpan() are inlined, the JIT sees that the underlying array never escapes the method:
public static void WriteFloat(float value, Span<byte> destination)
{
BitConverter.GetBytes(value).AsSpan(0, 4).CopyTo(destination);
}
In .NET 9, GetBytes allocates a 4-byte array on the heap. In .NET 10, the JIT inlines both GetBytes and AsSpan, proves the temporary array doesn't escape, and stack-allocates it. The benchmark drops from roughly 10 nanoseconds and 32 bytes allocated to under 1 nanosecond and zero allocations.
How dynamic PGO supercharges escape analysis
The real power of .NET 10's escape analysis emerges when combined with dynamic Profile-Guided Optimisation (PGO). Consider a method that iterates over an IEnumerable<int>:
public static int Sum(IEnumerable<int> values)
{
int sum = 0;
foreach (int value in values)
{
sum += value;
}
return sum;
}
When you call Sum with an int[], the foreach compiles to a GetEnumerator() / MoveNext() / Current pattern. Without PGO, those calls are virtual — the JIT doesn't know the concrete type and can't inline or optimise them.
With dynamic PGO enabled (the default since .NET 9), the JIT instruments the code during Tier 0 execution and learns that the concrete type is usually int[]. On recompilation at Tier 1, it inserts a type check (guarded devirtualisation): if the value is an int[], take the fast path with devirtualised, inlined calls; otherwise, fall back to the generic virtual path.
Here's where conditional escape analysis kicks in. The enumerator object only "escapes" on the slow fallback path. On the fast path — the one actually executed — the JIT can prove the enumerator doesn't escape and stack-allocates it. The result: the foreach loop over int[] through an IEnumerable<int> reference runs with zero heap allocations, roughly three times faster than in .NET 9.
| Runtime | Mean | Allocated |
|-----------|------------|-----------|
| .NET 9 | 109.86 ns | 32 B |
| .NET 10 | 35.45 ns | 0 B |
The JIT can even apply this optimisation to static fields. If a static readonly IEnumerable<int> field is always assigned an int[], the JIT can retype the field to the concrete type and eliminate the enumerator allocation entirely.
Measuring the impact
You can verify that stack allocation is happening in your own code using BenchmarkDotNet's MemoryDiagnoser:
[MemoryDiagnoser]
public class StackAllocBenchmark
{
private readonly int[] _data = Enumerable.Range(0, 100).ToArray();
[Benchmark]
public int SumViaEnumerable()
{
IEnumerable<int> values = _data;
int sum = 0;
foreach (int v in values) sum += v;
return sum;
}
[Benchmark]
public int SumWithDelegate()
{
Func<int, int> doubler = x => x * 2;
int sum = 0;
foreach (int v in _data) sum += doubler(v);
return sum;
}
}
Run with dotnet run -c Release targeting net10.0. In the results table, look at the Allocated column. If escape analysis succeeded, you'll see 0 B or a significantly reduced value compared to net9.0.
For lower-level verification, you can inspect the generated assembly by setting the DOTNET_JitDisasm environment variable:
$env:DOTNET_JitDisasm = "SumViaEnumerable"
dotnet run -c Release
Look for the absence of CORINFO_HELP_NEWSFAST or CORINFO_HELP_NEWARR_1_VC calls — those are the heap allocation helpers. If they're gone, the object has been stack-allocated or eliminated entirely.
// WARNING
Don't use GC.GetTotalAllocatedBytes() in production code to detect stack allocation. It's useful for quick experiments, but BenchmarkDotNet gives you statistically sound measurements with proper warm-up and iteration.
What prevents stack allocation
Understanding why the JIT decides not to stack-allocate is just as important as understanding when it does. These patterns will keep objects on the heap:
Returning the object. If a method returns the allocated object, it necessarily outlives the method's stack frame:
// Bad — array escapes via return
int[] CreateBuffer() => new int[4];
Storing in a field or static. Assigning to an instance field or static variable makes the object reachable beyond the method:
// Bad — escapes via field assignment
private int[] _cache;
void Prepare()
{
_cache = new int[4];
}
Passing to non-inlineable methods. If the JIT can't inline the callee, it can't prove the object won't be stashed away:
// Bad — ProcessItems might store the array
[MethodImpl(MethodImplOptions.NoInlining)]
void ProcessItems(int[] items) { /* ... */ }
void Run()
{
var items = new int[] { 1, 2, 3 };
ProcessItems(items); // prevents stack allocation
}
Objects with finalisers. Finaliser execution is managed by the GC and requires the object to live on the heap:
// Bad — finaliser prevents stack allocation
class ResourceHandle
{
~ResourceHandle() { /* cleanup */ }
}
Locking on the object. The lock statement uses the object's header for synchronisation, which requires a stable heap address:
// Bad — lock prevents stack allocation
var syncRoot = new object();
lock (syncRoot) { /* ... */ }
Size limits. Arrays larger than approximately 512 bytes for value types or with too many reference-type elements remain heap-allocated. Dynamically-sized arrays (new int[n] where n isn't a compile-time constant) are never candidates.
Loop allocations that escape the iteration. An object created inside a loop can only be stack-allocated if it doesn't escape the current loop iteration. If the object from one iteration is used in the next, it stays on the heap.
Inlining: the prerequisite the JIT needs
Escape analysis can only reason about code it can see. If a method call isn't inlined, the JIT must conservatively assume the callee might store any object passed to it. This makes inlining the critical prerequisite for stack allocation.
.NET 10 improves the inliner in several ways that directly benefit escape analysis:
- Methods with
try-finallyblocks can now be inlined. - The inliner increases its size tolerance for frequently-called methods when dynamic PGO profile data indicates a hot call site.
- Return type information is refined after inlining — if all return sites yield the same concrete type, subsequent calls on the return value can be devirtualised.
- Small methods returning fixed-size arrays are given higher inlining priority, specifically to enable stack allocation of the returned array at the call site.
The practical takeaway: keep methods small and focused. Methods that are too large to inline block escape analysis for any objects they receive as parameters. Splitting a large method into smaller, inlineable helpers can unlock stack allocation without any other changes.
Summary
- .NET 10's JIT compiler uses expanded escape analysis to automatically stack-allocate objects that don't outlive their parent method — no code changes required.
- Small arrays of both value types and reference types are now candidates for stack allocation when their size is known at compile time.
- Delegate objects are stack-allocated when their
Invokedoesn't retain thethisreference and the delegate doesn't escape. Closure objects remain heap-allocated for now. - Struct field tracking means arrays assigned to local struct fields (including
Span<T>backing arrays) can be stack-allocated. - Dynamic PGO enables conditional escape analysis: the JIT stack-allocates enumerators on the guarded fast path while falling back to heap allocation on the rare slow path.
- Patterns that prevent stack allocation include returning the object, storing it in fields, passing it to non-inlineable methods, finalisers,
lockstatements, and exceeding size limits. - Inlining is the prerequisite — the JIT can only prove an object doesn't escape if it can see the full data flow. Keep methods small and focused to maximise optimisation opportunities.