gap·map

CLR memory & types

[ middle depth ]

Value types vs reference types in C#

A value type (every struct, int, bool, enum, DateTime) holds its data directly. A reference type (class, string, arrays, delegates) holds a reference to data that lives on the managed heap. The practical consequence is assignment: copying a value type copies the whole value; copying a reference copies the pointer, and both variables see one shared object.

struct P { public int X; }
class C { public int X; }

var a = new P { X = 1 }; var b = a; b.X = 99;   // a.X still 1  (copy)
var c = new C { X = 1 }; var d = c; d.X = 99;   // c.X now 99   (shared)

Passing a struct to a method copies it too, so a method cannot mutate your struct unless you pass it by ref. This copy-on-assign behavior is the single fact most struct bugs trace back to.

Where boxing sneaks in

Boxing wraps a value type in a heap object so it can be used where an object or interface is expected. Unboxing copies it back out and needs the exact type. Each box is a heap allocation plus a copy, so in a hot loop it turns into real GC pressure. The allocations that surprise people:

  • Non-generic collections (ArrayList, Hashtable) store object, so every int you add is boxed. List<int> and Dictionary<int, T> hold the value directly.
  • Casting a struct to an interface (IComparable, IDisposable) boxes it.
  • Composite formatting and concatenation of value types: "count: " + 5 and string.Format("{0}", 5) pass the int as object, boxing it.

Wrong "boxing is just a cast, it is basically free." A box allocates a new object on the heap every time, and those objects become gen-0 garbage the GC has to sweep.

Why your struct change disappears

var list = new List<P> { new P { X = 1 } };
list[0].X = 99;   // compile error: cannot modify the return value

list[0] invokes the indexer, which returns a copy of the struct, so mutating it would change a throwaway value; the compiler blocks it. An array is different: arr[0] is a real storage location, so arr[0].X = 99 works in place. The clean fix is to make structs immutable and replace the whole element (list[0] = list[0] with { X = 99 }).

Cleanup: using vs finalizers, and StringBuilder

using gives deterministic cleanup: Dispose() runs the moment the scope ends, which is how you release files, sockets, and DB connections on time. A finalizer runs later, whenever the GC gets to it, so you never rely on it for timing. Reach for a finalizer only as a backstop for an unmanaged handle you own.

Strings are immutable, so s += x in a loop allocates a fresh string every iteration and copies the old contents forward, which is O(n squared). StringBuilder keeps one growable buffer and appends into it.

[ senior depth ]

Where value types really live

Wrong "value types go on the stack, reference types go on the heap." Storage location is a JIT decision, not a property of the type. A struct field of a class lives inline on the heap inside that object. A local captured by a lambda is hoisted into a compiler-generated heap class. A local in an async method becomes a field on a heap-allocated state machine once the method suspends. A boxed value sits on the heap by definition. A plain, uncaptured local struct usually lives on the stack or in registers, and that is the only case the myth describes.

What the type guarantees is copy semantics, not an address. struct means assignment, argument passing, and boxing all copy the bytes. Reason from that and you predict behavior the stack/heap story gets wrong.

GC generations and the Large Object Heap

The GC is generational because most objects die young. New allocations land in gen 0, collected most often and cheaply. Survivors get promoted gen 0 to gen 1 to gen 2. Gen 0 and gen 1 are the ephemeral generations; a gen-2 (full) collection walks everything and is the expensive one to avoid in a request path. An ever-growing static cache promotes its entries to gen 2 and makes those full collections frequent: the classic "gen-2 leak."

Objects of 85,000 bytes or more go on the Large Object Heap. Two facts define its behavior:

  • The LOH is only swept during a gen-2 collection, so large short-lived buffers force full GCs.
  • It is not compacted by default because copying big blocks is costly, so it fragments: free space exists but not in one contiguous run, and allocations fail or grow the heap anyway. You can force a one-time compaction with GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce, but the real fix is pooling (ArrayPool<T>) so you stop allocating large arrays in the first place.

On 64-bit .NET a double[] needs about 10,600 elements to cross it (85,000 / 8 bytes); the folkloric "1000 doubles" figure was a 32-bit Framework alignment special case. Either way it catches people who never think their arrays are "large."

Boxing you did not order

The subtle boxing is generic and interface dispatch. A generic method constrained with where T : IEquatable<T> emits a constrained call: for a struct T the method runs directly with no box. Drop the constraint, compare through the non-generic object.Equals, and every call boxes. That is why EqualityComparer<T>.Default and generic collections exist: structs get equality and sorting without allocating.

Defensive copies are the other hidden cost. Call a non-readonly method on a struct held in a readonly field or received as an in parameter, and the compiler copies it first to protect the readonly guarantee. Mark the type readonly struct and those copies vanish, because no member can mutate it.

The Dispose pattern interviewers grade

They want deterministic cleanup separated from the GC backstop. Public Dispose() calls Dispose(true) then GC.SuppressFinalize(this); the finalizer calls Dispose(false). SuppressFinalize is load-bearing: an object with a finalizer joins the finalization queue when the GC first finds it dead, survives that collection to run its finalizer on the finalizer thread, and is reclaimed only on a later GC. A finalizable object you disposed correctly still costs two collections and a gen promotion unless you suppress it.

The senior move is to write no finalizer at all. Wrap the unmanaged handle in a SafeHandle; it carries its own critical finalizer, so your class holds only managed fields and needs neither a finalizer nor the two-phase dance. Implement IDisposable to forward Dispose to the handle, and the GC never finalizes your type.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

unlocks

more C# / .NET

was this useful?