How the call stack and the managed heap work, what goes in each (locals, frames, objects, boxed values, captured variables, async state), why "value types live on the stack" is a myth, and the performance and lifetime consequences.
The stack is a per-thread region that holds method frames: locals, arguments, return addresses and saved registers. Allocating is just moving the stack pointer, and everything is freed when the method returns. The heap is shared memory for objects whose lifetime is not tied to one method; the generational garbage collector frees them once they are unreachable.
The common line "value types live on the stack, reference types on the heap" is only half true. Storage follows the declaration: a value-type local usually lives on the stack (or in a register), but a value-type field lives inside its object on the heap, and boxing, lambdas that capture locals, iterators and async methods move data to the heap. Reference-type objects are conceptually heap objects (only the reference sits on the stack), although the current JIT's escape analysis can place a small object that provably never leaves the method on the stack as an optimisation. The language guarantees lifetime and copy semantics, not a location.
stackalloc) kills the process with StackOverflowException.| Thing | Location |
|---|---|
int x = 5; local in a normal method |
Stack frame or a CPU register |
var p = new Person(); |
Person object on the heap; reference p on the stack or in a register |
struct Point field inside a class |
Inline inside the class instance, on the heap |
Point[] |
Array object on the heap, with the structs stored inline |
object o = 42; |
Boxed int on the heap |
| Local captured by a lambda | Hoisted into a compiler-generated closure object on the heap |
Locals in an async method that live across an await |
Fields of the state machine, which is boxed to the heap if the method actually suspends |
Locals in an iterator (yield return) |
Fields of the iterator object on the heap |
Span<byte> buf = stackalloc byte[256]; |
Stack |
static fields |
Referenced from the type's statics, effectively heap |
Span<T>, stackalloc, structs and pooling reduce them.ref struct types like Span<T> can't be captured by lambdas or used across await.A high-traffic API allocated about 2 GB per minute, and gen 0 collections ran several times a second. Most allocations came from LINQ closures and boxing in a logging helper that took params object[]. Switching to source-generated logging ([LoggerMessage]) and removing a closure in the hot path cut allocations by 70% and reduced p99 latency noticeably.
A classic fundamentals question that quickly separates memorised slogans from understanding.
Green flags: explains frames and GC, corrects the value-type myth with examples (fields, boxing, closures, async), knows the size limits, connects to performance and Span<T>.
Red flags: "structs go on the stack, classes go on the heap" as the whole answer; can't say when stack memory is freed; unaware of boxing or closures.
Thread stack (grows down) Managed heap
+-----------------------------+
| Main frame |
| orders (ref) ------------+-----------> List<Order> object
| total (decimal) | _items (ref) --------> Order[] array
+-----------------------------+ [ref][ref][ref]
| ProcessOrders frame | |
| i (int) | v
| current (ref) ------------+-----------------------------------> Order object
| return address | Id (int) <- value-type field,
+-----------------------------+ Total (decimal) inline in object
The rule is: storage location follows the declaration.
Eric Lippert's summary still holds: the stack is an implementation detail; what matters is lifetime and copying semantics.
| Code | Hidden allocation |
|---|---|
object o = 5;, string.Format("{0}", 5) on older APIs, non-generic collections |
Boxing |
list.Where(x => x > threshold) |
Closure object for threshold, delegate, iterator |
async Task method that awaits something incomplete |
State machine box (and the Task itself, unless cached) |
IEnumerable<T> with yield return |
Iterator object |
foreach over an IEnumerable<T> typed variable whose enumerator is a struct |
Boxed enumerator |
| String concatenation in a loop | New string per iteration |
Use [MemoryDiagnoser] in BenchmarkDotNet or the Visual Studio allocation tracker to find them.
ref struct types (Span<T>, ReadOnlySpan<T>, Utf8JsonReader) can never be on the heap: they can't be fields of classes, boxed, captured by lambdas, or used across await. The compiler enforces this, which is what makes them safe to point at stack memory.stackalloc gives a stack buffer; keep it small (a few hundred bytes to a few KB) and use ArrayPool<T> for larger buffers.| Stack | Heap | |
|---|---|---|
| Allocate | Adjust stack pointer | Bump pointer in allocation context, occasionally slow path |
| Free | Automatic on return | GC: mark, possibly compact, possibly promote |
| Cache behaviour | Almost always hot | Depends on access pattern |
| Size limit | ~1 MB per thread | Gigabytes |
| Thread sharing | Private to the thread | Shared; needs synchronisation |
List<int> with foreach, then the same via IEnumerable<int>. Measure both with [MemoryDiagnoser] and explain the difference.async method that awaits Task.CompletedTask and another that awaits Task.Delay(1), and compare allocations.stackalloc byte[256] in a method and try to return it as a Span<byte>. Read the compiler error.namespace StackAndHeap;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
public readonly record struct Point(int X, int Y); // value type
public sealed class Shape { public Point Origin; } // Origin stored inline in the Shape object
[MemoryDiagnoser]
public class AllocationBenchmarks
{
private readonly List<int> _numbers = Enumerable.Range(0, 1_000).ToList();
private readonly int _threshold = 500;
[Benchmark(Baseline = true)]
public int StackOnly()
{
int count = 0; // local: stack or register
foreach (var n in _numbers) // List<T>.Enumerator is a struct: no allocation
{
if (n > _threshold) count++;
}
return count;
}
[Benchmark]
public int ViaInterface()
{
IEnumerable<int> source = _numbers; // enumerator gets boxed
int count = 0;
foreach (var n in source)
{
if (n > _threshold) count++;
}
return count;
}
[Benchmark]
public int Linq() => _numbers.Count(n => n > _threshold); // delegate (closure over this), boxed enumerator
[Benchmark]
public object Boxing() => 42; // boxed int on the heap
[Benchmark]
public int StackBuffer()
{
Span<byte> buffer = stackalloc byte[256]; // stack memory, no GC
buffer.Fill(1);
int sum = 0;
foreach (var b in buffer) sum += b;
return sum;
}
}
public static class Program
{
public static void Main() => BenchmarkRunner.Run<AllocationBenchmarks>();
}
Mistake: Saying "value types always live on the stack". Why it is wrong: value-type fields live inside heap objects, array elements live in the array, and captured or async locals are hoisted to the heap. Instead: say storage follows the declaration (local, field or element) and lifetime.
Mistake: Saying "every new of a class is a heap allocation" as an absolute rule.
Why it is wrong: the current JIT performs escape analysis and can stack-allocate small objects that never leave the method. It is an optimisation you cannot rely on, but it means the slogan is not the language rule.
Instead: say objects have heap semantics (they can outlive the method if referenced), and measure allocations rather than guessing from source code.
Mistake: Thinking stack versus heap decides struct versus class. Why it is wrong: the real differences are copy semantics, identity, nullability and size; location is a consequence. Instead: choose structs for small, immutable, value-like data, and classes for entities with identity or larger state.
Mistake: Ignoring hidden allocations.
Why it is wrong: boxing, closures, interface enumerators, params object[] logging and async state machines in hot paths quietly create GC pressure.
Instead: measure with [MemoryDiagnoser], dotnet-counters (allocation rate) or an allocation profiler, and use source-generated logging, spans and struct enumerators where it matters.
Mistake: Using large or input-sized stackalloc buffers.
Why it is wrong: the stack is small and fixed; a large or attacker-controlled stackalloc can overflow it and kill the process (a denial-of-service risk).
Instead: cap stackalloc at a small constant (for example 256-1024 bytes) and fall back to ArrayPool<T>.Shared for larger buffers.
Mistake: Believing the stack is "faster memory". Why it is wrong: it is the same RAM; it is fast because allocation is trivial, it is freed automatically and it is almost always in cache. Instead: explain the cost difference in terms of allocation, GC work and locality.
Seniors move the conversation from "where" to "how long and how many". The stack/heap split matters because heap allocations cost GC time later, and because lifetimes that escape a method force heap allocation.
| Concern | Stack | Heap |
|---|---|---|
| Allocation cost | Move stack pointer | Bump pointer in allocation context, occasional slow path |
| Freeing | Automatic on return | Generational GC: cheap for short-lived, expensive for survivors and gen 2 |
| Lifetime | Ends with the method | As long as reachable |
| Thread safety | Private to the thread | Shared; needs synchronisation |
| Size | Small, fixed per thread | Gigabytes, bounded by container memory limits |
| Failure mode | StackOverflowException kills the process |
OutOfMemoryException, GC pauses, container OOM kill |
In practice this means reducing allocations on hot paths (spans, pooling, source-generated logging, avoiding closures and boxing) and accepting allocations everywhere else, because the GC handles short-lived garbage very well. Seniors also know the compiler and JIT rules (ref struct, escape analysis, async state machines) that make stack-based techniques safe, and they treat any stack size or allocation claim as something to measure on the target runtime.
Each thread allocates from its own allocation context, a small chunk of gen 0 where new is just a pointer bump plus zeroing; when it runs out, the thread asks the GC for another chunk, and when the gen 0 budget is used up, a gen 0 GC runs. The GC is generational because most objects die young: gen 0 collections are cheap, survivors are promoted to gen 1 and then gen 2, which is collected rarely (and mostly in the background). Objects of 85,000 bytes or more go to the large object heap (LOH), which is collected only with gen 2 and isn't compacted by default, so it can fragment. Pinned objects can't be moved, which blocks compaction; the pinned object heap (.NET 5+) keeps long-lived pinned buffers separate. In hot paths I reduce the allocation rate rather than tuning the GC.
Each thread has a fixed-size stack (about 1 MB on Windows), and every method call pushes a frame. Unbounded or very deep recursion exhausts it. Since .NET 2.0 the runtime terminates the process on a stack overflow instead of throwing a catchable exception, because with no stack left it can't safely run handlers or finally blocks. To diagnose it, I read the crash output (recent .NET versions print the repeating frames to standard error), check container logs or the Windows Event Log, and capture a crash dump with DOTNET_DbgEnableMiniDump=1 to inspect the stack. Typical causes are a missing base case, a property or ToString that calls itself, cycles in object graphs (serialisation, Equals), and deeply nested input. Fixes: correct the recursion, switch to iteration with an explicit stack, and add depth limits and tests.
A pointer is a raw memory address. In .NET, an object reference is a pointer the GC tracks: when the GC compacts the heap and moves an object, it updates every reference, so you never see addresses. ref, in and out pass a managed pointer to a variable instead of copying its value, and ref locals, ref returns and ref fields let you hold one; managed pointers can point inside objects, arrays or stack frames, and the GC tracks those too. Span<T> is a ref struct holding a managed pointer plus a length, giving safe views over arrays, stackalloc memory or native memory. Unsafe pointers (int*) are untracked addresses: they need an unsafe context and fixed or pinning, because the GC might otherwise move the object underneath them.
Last updated 23 Oct 2026.