📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is the difference between the stack and the heap in .NET, and what actually lives where?

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.

Junior Theory Intermediate gc heap memory reference-types stack value-types

🎯 Key points

  • Each thread has its own stack of frames holding locals, arguments, return addresses and spilled registers; the size is fixed when the thread starts (1 MB default on Windows, typically 8 MB for the Linux main thread and 1.5 MB for .NET-created secondary threads) and can be set with the Thread constructor's maxStackSize
  • Stack allocation is a pointer bump and is freed automatically when the method returns, strictly last in, first out
  • The managed heap holds objects; the GC is generational (gen 0, 1, 2 plus LOH and POH), so short-lived objects are cheap to collect while survivors are promoted and cost more
  • Where data lives depends on where it is declared (local, field, array element) and on its lifetime, not just on value versus reference type
  • Value-type locals usually live on the stack or in registers; value-type fields and array elements live inline inside their containing object on the heap
  • Boxing, lambdas capturing locals, iterators and async methods that suspend move data to the heap
  • The JIT's escape analysis can stack-allocate small non-escaping objects (for example some boxes, delegates and small arrays) as an optimisation, so location is an implementation detail; lifetime and copy semantics are the contract
  • ref struct types such as Span<T> are compiler-enforced to stay on the stack, which makes stackalloc and pointing at stack memory safe

⏱ 30-second answer

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.

🗣 Full answer (senior)

The stack

  • One per thread, typically 1 MB on Windows (secondary threads on Linux default to 1.5 MB).
  • Each method call pushes a frame: arguments that didn't fit in registers, locals, the return address and saved registers. Returning pops it.
  • Allocation and deallocation are essentially free (adjust one register), and the memory is almost always hot in cache.
  • Lifetime is strictly last in, first out: data can't outlive the method that created it.
  • Overflowing it (deep or infinite recursion, huge stackalloc) kills the process with StackOverflowException.

The heap

  • One managed heap per process (with per-core sub-heaps under Server GC), managed by the GC.
  • Holds every object (class instances, arrays, strings, delegates, boxed values).
  • Allocation is also fast (a pointer bump in a per-thread allocation context), but every allocation adds to future GC work, and objects that survive get copied between generations.
  • Lifetime is flexible: an object lives as long as something references it.

What lives where

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

Why it matters

  • Performance: heap allocations in hot paths increase GC frequency; Span<T>, stackalloc, structs and pooling reduce them.
  • Correctness: structs are copied on assignment, so mutating a copy doesn't change the original.
  • Lifetime: only heap objects can outlive the method; that's why ref struct types like Span<T> can't be captured by lambdas or used across await.

Production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Picture a call

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

2. The value-type myth, precisely

The rule is: storage location follows the declaration.

  • A local or parameter: the stack (or a register), unless it's captured or the method is async or an iterator.
  • A field: wherever the containing object or struct is.
  • An array element: inside the array, on the heap.

Eric Lippert's summary still holds: the stack is an implementation detail; what matters is lifetime and copying semantics.

3. Hidden heap allocations

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.

4. Stack-only types

  • 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.

5. Cost comparison

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

6. Try it yourself

  1. Write a method that sums a List<int> with foreach, then the same via IEnumerable<int>. Measure both with [MemoryDiagnoser] and explain the difference.
  2. Write a lambda that captures a local, decompile with sharplab.io, and find the generated closure class.
  3. Write an async method that awaits Task.CompletedTask and another that awaits Task.Delay(1), and compare allocations.
  4. Allocate stackalloc byte[256] in a method and try to return it as a Span<byte>. Read the compiler error.

💻 Code

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>();
}

⚠ Common mistakes

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says heap allocations add GC work. Why: Goes deeper on how heap allocation and collection actually work.
    How to answer

    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.

  2. When: Candidate mentions the stack is about 1 MB per thread. Why: Checks what happens when the stack runs out.
    How to answer

    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.

  3. When: Candidate mentions Span<T> or stackalloc. Why: Probes how references, ref and Span relate to stack and heap memory.
    How to answer

    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.

↩ Usually asked after

More in Memory

Last updated 23 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me