Every C# program splits its working memory into two distinct regions: a fast, automatically managed stack for short-lived bookkeeping, and a larger managed heap that the garbage collector watches over. This lesson covers what those two regions actually look like, where value types and reference types end up, what boxing costs, how the heap is internally split into the Small Object Heap, Large Object Heap, and Pinned Object Heap, and just enough about roots and reachability to set up everything that follows about garbage collection.
A running .NET program uses many memory regions under the covers (image memory for loaded assemblies, native memory for the runtime itself, thread-local storage), but only two of them matter for day-to-day C# work: the stack and the managed heap.
The stack is a per-thread region the runtime hands out when a thread starts. Every method call carves off a slice of the stack for its local variables, its parameters, and the bookkeeping the CPU needs to return to the caller. When the method returns, the slice is released by simply moving a pointer. There's no scanning, no collection, no "freeing" in the heap sense. The slice just stops being in use.
The managed heap is shared across the whole process. It's where the runtime puts every object you allocate with new (for reference types) and every value type instance that ends up boxed or stored inside a reference-type field. Memory on the heap is not released when a method returns; it's released later, by the garbage collector, once nothing in the program can still reach it.
The diagram is deliberately simple. Each thread has its own private stack. The heap is one shared region the runtime manages with help from the GC. References on the stack can point into the heap, but the reverse is also possible: a heap object can hold references that ultimately reach other heap objects. The GC's job is to figure out which heap objects are still reachable and reclaim the rest.
The default stack size on .NET is 1 MB per thread on most platforms. That's small. A million-element array on the stack throws StackOverflowException and crashes the process. The heap, by contrast, is sized in gigabytes, limited only by the OS and the process's address space. Different jobs, different sizes.
Allocating a stack frame is a single pointer adjustment. Allocating a heap object is also cheap in the common case (more on that shortly), but it's never free, and every heap allocation is potential future GC work. When a piece of data fits naturally on the stack, putting it on the heap costs more in both directions.
This was covered properly in the _Value Types_ and _Reference Types_ lessons, so this section is a re-grounding for memory-model purposes, not a fresh introduction. The mental shorthand that matters here:
int, double, bool, char, decimal, DateTime, any struct, and any enum are value types. When you copy a value type, you copy the bytes.string, arrays, every class, every interface reference, every delegate, and object itself are reference types. When you copy a reference type, you copy the reference, not the bytes it points at.Where a value type physically lives is determined by where the variable holding it lives, not by the type itself. This is the easy part to miss.
decimal local variable inside a method lives on the stack, because the local lives on the stack.decimal field of a class lives on the heap, because the enclosing class instance lives on the heap.decimal element of a decimal[] array lives on the heap, because the array's storage lives on the heap.The type didn't change. The container did.
Two locals live on the stack. One is the integer 50 itself. The other is a reference (a managed pointer) that points at a Product object on the heap. The Product object contains an Id and a Price, and both of those fields are stored inside the same heap allocation as the rest of the object. When the method returns, both locals on the stack disappear immediately. The Product on the heap doesn't disappear with them; it sits on the heap until the GC notices that nothing reachable points at it.
A second wrinkle worth getting straight: assigning between reference-type variables copies the reference, not the object. Two variables can end up pointing at the same heap object, and a mutation through one shows up through the other.
Both cart and alias point at one and the same List<string> on the heap. Adding through alias is visible through cart. With value types, assignment copies the bytes, so the two variables are independent. That contrast is the practical reason the distinction matters at all.
Boxing is the bridge from the value-type world to the reference-type world, and it's a common source of accidental allocation in C# code.
When you assign a value type to a variable of type object (or to any interface that the value type implements), the runtime allocates a small object on the heap, copies the value's bytes into it, and gives you a reference to that boxed copy. That's boxing. Going the other way, casting an object reference back to its underlying value type copies the bytes out of the boxed object into a fresh value-type slot. That's unboxing.
The numbers all look the same, which hides what's happening. The diagram tells the real story.
Three separate copies of the integer 50 are in play. The first is the original int on the stack. The second is on the heap, inside the boxed object. The third is back on the stack, inside unboxed. Boxing is not "wrapping the same memory in a fancy reference"; it's an allocation followed by a byte copy. Mutating the original stock afterwards does not change boxed, and mutating the boxed object (if it were a mutable struct) would not affect stock.
Boxing allocates a small object on the heap and copies the value into it. That's roughly the cost of one small new plus a memcpy. The allocation itself is fast, but every boxed value adds GC pressure. In a tight loop, boxing inside the body is one of the easiest performance bugs to introduce.
The reason boxing matters in real code is that it often happens without an explicit cast. Three common situations cause invisible boxing:
1. Assigning to `object` directly. This is the explicit case shown above. Easy to spot, easy to avoid.
2. Passing a value type where an interface is expected. If a method's parameter type is an interface and you pass a struct that implements it, the struct gets boxed at the call site so the method can use it through the interface reference.
The variable's type is the interface, so the runtime needs a reference. A value type can't be referenced directly, so it gets boxed.
3. Storing a value type in a non-generic collection or in a collection of `object`. Older non-generic collections like ArrayList and Hashtable store everything as object, so every value type you put in gets boxed on the way in and unboxed on the way out. Generic collections (List<int>, Dictionary<int, Product>) fix this: a List<int> stores int values directly, no boxing.
Both produce the same answer; the legacy version performed 100 heap allocations to get there, the modern version performed zero (beyond the initial array buffer). This is one of the biggest reasons generics were added to C# in the first place.
A subtler boxing case: calling ToString() on a value type inside string interpolation does not box on modern .NET, because int.ToString() is a virtual method overridden directly on the struct. But concatenating a value type with + to a string historically did box (the value got promoted to object so String.Concat(object, object) could be called), and certain String.Format-style call sites still cause boxing today. Modern C# string interpolation in .NET 6+ uses an interpolated string handler that avoids the boxing in most cases, but the older string.Format("{0}", someInt) overload that takes object parameters still boxes the int.
The "managed heap" is one logical region, but internally it's split into three sub-heaps that the runtime treats differently. The split exists because different object sizes and use patterns deserve different policies.
| Sub-heap | What goes there | Why it's separate |
|---|---|---|
| Small Object Heap (SOH) | Objects smaller than 85,000 bytes (the default) | The fast common case; eligible for compaction and generations |
| Large Object Heap (LOH) | Objects 85,000 bytes or larger | Copying large objects during compaction is expensive, so the LOH is collected and compacted on a different schedule |
| Pinned Object Heap (POH) | Objects allocated as "pinned" via the GC pinning APIs (added in .NET 5) | Pinned objects can't be moved, which fragments the heap; isolating them keeps the SOH compactable |
The 85,000-byte cutoff for the LOH is the canonical .NET number. It applies to the size of the object as the runtime sees it, not the conceptual size. A byte[85_000] lands on the LOH because the byte array's allocation is at least 85,000 bytes; a byte[84_999] lands on the SOH. The threshold is the same on every common runtime version, though it's been technically configurable in some hosts.
The vast majority of objects live on the SOH. A typical web request might allocate hundreds of small objects (strings, view models, dictionary entries, task continuation states) and zero large ones. The LOH and POH are smaller, special-purpose regions that handle edge cases.
A few practical notes about the LOH:
byte[] or char[] you allocate for reading a file, decoding an image, or buffering a network response can easily exceed 85,000 bytes. That's why ArrayPool<T> exists: instead of allocating a fresh 100 KB buffer for every request, you rent one from a pool and return it.GCSettings.LargeObjectHeapCompactionMode before the next GC asks the runtime to compact the LOH once. That's a heavy operation, so it's a tool for occasional cleanup, not a default.The POH is the newest of the three. Before .NET 5, pinned objects (objects whose addresses had been fixed in place so unmanaged code or fixed/stackalloc interop could safely use them) lived on the SOH, and each pinned object created a hole that the SOH compactor had to work around. Allocating pinned objects on a separate heap keeps the SOH clean for compaction. POH allocation is rarely direct; the common path is GC.AllocateArray<T>(length, pinned: true), used by lower-level interop code.
Crossing the 85,000-byte boundary makes for a much more expensive ~1 byte allocation. The LOH is collected with the slowest generation (Gen 2), not the fastest, so large allocations stay around longer and increase the cost of every Gen 2 collection until they're reclaimed. Sizing buffers just under the threshold is a real optimization technique.
new looks expensive. The runtime has to find space, initialize the object's header, zero its fields, and update its own bookkeeping. In practice, the common path is faster than people expect, because the SOH uses a bump allocator.
A bump allocator keeps a single pointer that points at the next free byte in the current allocation region. Allocating an object means three things: check that there's enough room left in the region, copy the type pointer and zero the fields into the slot at the pointer, then move (bump) the pointer past the new object. That's it. There's no free list to walk, no fit search, no fragmentation handling. It's roughly as fast as allocating a stack slot.
The SOH operates as a series of regions. Each region has its own bump pointer. When a region fills up, the runtime triggers a GC. The collection compacts the live objects to the start of the region (or moves them to an older generation), which resets the bump pointer to right after the last live object. Then bumping resumes.
The takeaway people miss: the cost of using objects on the GC heap is dominated by the eventual GC, not by the call to `new`. A throwaway local that lives and dies inside a single method is cheap to allocate and almost free to reclaim, because by the time the GC runs the object will already be dead and won't be copied anywhere. The expensive object is the one that survives long enough to be promoted to an older generation, because each survival across a GC requires copying.
The exact number varies by machine, but a million small allocations in well under a second is normal. Most of that time is the loop overhead, not the allocations themselves. The reason this runs fast is precisely the bump allocator: every iteration is a pointer increment plus a field zero. The GC pays a one-time cost to discard the dead objects when a region fills, and that cost stays roughly constant regardless of how many short-lived objects you allocated.
The cheap allocations are short-lived ones that die before the next GC. The expensive allocations are long-lived ones that survive collections and get promoted between generations. When a profile shows GC pressure, the fix usually isn't "stop calling new"; it's "stop holding references to things that aren't needed anymore."
Each method call carves off a slice of the current thread's stack called a stack frame. The frame holds three categories of data:
When main() calls CalculateTotal(), the runtime pushes a new frame for CalculateTotal onto the stack. When CalculateTotal calls ApplyDiscount, another frame goes on top. When ApplyDiscount returns, its frame is popped. When CalculateTotal returns, its frame is popped. By the time main() resumes, the stack is back to where it was before the call chain started.
A short example shows the frames in action:
The decimal locals (discount, percent, total, result, price) all live directly in their owning frames; decimal is a value type, so its bytes are stored in the frame itself. The decimal[] is a reference type, so the prices variable is a reference on the stack that points at an array on the heap. The array's elements (the decimal values inside it) live on the heap inside the array, not in any frame.
There's a subtlety worth getting right: closures and async methods break the simple "frame goes away when the method returns" model. A lambda that captures a local from its enclosing method, or an async method that yields and resumes, needs the captured locals to outlive the calling frame. The compiler quietly hoists those locals into a heap-allocated object so they can survive. The frame for the synchronous part of the method is still a stack frame; it's the captured locals that get moved to the heap. This is one of the reasons closures and async methods sometimes show up in allocation profiles where you didn't expect them.
The garbage collector's job is to figure out which heap objects can still be used and reclaim everything else. It does that with a simple-sounding rule: an object is reachable if there's a path of references from a root to that object. Anything not reachable is dead.
A root, in GC terms, is a reference the program is using that the runtime knows about for sure. The four common kinds of roots:
GCHandle, including pinning handles used for interop. Less common in everyday code.Customer is reachable because a stack root points at it. Address is reachable because Customer references it. The product list is reachable because a static field points at it, and Product is reachable because the list contains it. The Orphan box on the right has no incoming arrow from any root, so it's not reachable. The next GC will reclaim its memory.
The mental model is "the GC starts at every root, walks every reference it can see, marks everything it visits, and then reclaims everything that wasn't marked." That's a simplification of generational mark-sweep-compact, which the _Garbage Collection_ lesson unpacks properly. For this chapter, the shorthand is enough:
Note the asymmetry: garbage collection is automatic, but leaks are still possible in C#. A managed memory leak is a reference you forgot you held. The classic offenders are static collections that grow forever, event subscriptions that outlive the subscriber, and caches without an eviction policy. None of those are bugs the GC can fix for you, because as far as the GC is concerned, every one of those objects is still reachable from a root.
10 quizzes