Performance & Memory Management

Value vs reference types for performance, Span<T> for allocation-free slicing, and GC generations conceptually.

Value vs reference types, revisited for performance

The csharp-and-dotnet-basics page in this track introduces value types (struct, and the built-in numeric types) and reference types (class) as a matter of assignment semantics — copy the value, versus copy a reference to the same object. That distinction has a direct performance consequence: a value type generally lives inline, wherever it's declared (on the stack for a local variable, or inline inside whatever object/array contains it), while a reference type is always a separate allocation on the managed heap, tracked by the garbage collector.

C#
struct Point { public int X, Y; }      // lives inline — no separate heap allocation
class PointRef { public int X, Y; }    // every `new PointRef()` is a heap allocation

This makes a struct attractive for small, short-lived, frequently-created values — every avoided heap allocation is one less object the GC ever has to track or collect. But the trade-off cuts the other way for anything larger: a struct is copied by value every time it's passed to a method, returned, or assigned — a large struct copied repeatedly in a hot loop can cost more in copying than the equivalent class would have cost in one single allocation. As a rule of thumb, keep structs small (a few fields, ideally 16 bytes or less) and immutable; pass a larger one with in (read-only reference) to avoid copying it at every call site.

Boxing is the specific cost of a value type crossing into reference-type territory — assigning it to an object, or a non-generic interface — which forces a hidden heap allocation to wrap the value:

C#
int number = 42;
object boxed = number;   // boxing: a new heap object is allocated to hold a copy of 42
int unboxed = (int)boxed; // unboxing: copies the value back out

Generic collections (List<int>, not ArrayList) exist specifically to avoid this — a List<int> stores int values inline in its backing array, while the older, non-generic ArrayList would box every single int added to it.

Span<T>: slicing without allocating

Span<T> (and its read-only counterpart, ReadOnlySpan<T>) is a type-safe view over a contiguous block of memory — an array, a slice of a string, or even memory on the stack — without copying it. Operations that would traditionally allocate a new string or array (Substring, Skip/Take) can instead be expressed as a span slice that references the original data in place:

C#
ReadOnlySpan<char> line = "2026-08-25,42.50".AsSpan();
int commaIndex = line.IndexOf(',');

ReadOnlySpan<char> datePart = line[..commaIndex];
ReadOnlySpan<char> pricePart = line[(commaIndex + 1)..];

decimal price = decimal.Parse(pricePart);
Console.WriteLine(price); // 42.50 — parsed directly from the slice, no intermediate string allocated

Compare this to the traditional approach — line.Substring(0, commaIndex) — which allocates a brand-new string on the heap just to hand it to decimal.Parse. In a hot path parsing large volumes of text (log lines, CSV rows, network buffers), replacing repeated Substring calls with Span<T> slicing can remove a significant amount of otherwise-unnecessary GC pressure.

The trade-off: Span<T> is a ref struct, a special kind of struct the compiler restricts to the stack specifically because it might point at stack memory — it cannot be stored as a field on an ordinary class, boxed, or used across an await boundary in an async method. It's a tool for tight, synchronous, performance-sensitive code, not a general-purpose replacement for arrays or strings everywhere.

GC generations, conceptually

The .NET garbage collector is generational, built around the empirically-observed pattern that most objects die young (a temporary string, a short-lived request object) while a smaller set of objects survive for a long time (caches, singletons, static data):

Generation What lives there Collected
Gen 0 Brand-new, short-lived objects Very frequently — fast, since most of Gen 0 is normally already garbage
Gen 1 Objects that survived one Gen 0 collection A buffer between Gen 0 and Gen 2, collected less often
Gen 2 Long-lived objects (survived multiple collections) Rarely — a full Gen 2 collection is the most expensive kind, scanning far more of the heap
LOH (Large Object Heap) Objects ≥ 85,000 bytes, regardless of age Collected alongside Gen 2; historically not compacted by default (can fragment)

An object starts in Gen 0. If it survives a Gen 0 collection (something still holds a reference to it), it's promoted to Gen 1; if it survives long enough there too, it's promoted again to Gen 2. This lets the GC spend most of its effort on the cheap, frequent Gen 0 collections — which only need to scan a small, recently-allocated portion of the heap — and reserve the expensive, whole-heap Gen 2 collection for far rarer occasions.

.NET also offers two GC flavors, chosen automatically based on how the app is hosted: Workstation GC (the default for client apps, tuned for responsiveness on a single core) and Server GC (the default for ASP.NET Core, using multiple heaps and threads in parallel across all available cores, tuned for throughput). Neither needs manual tuning in the overwhelming majority of applications — it's worth knowing they exist mainly to make sense of GC-related configuration you might encounter in a .csproj or runtimeconfig.json.

Common mistakes

  • Reaching for struct everywhere to "avoid the GC," including for large or frequently-mutated types — a large struct copied by value repeatedly is often slower than the single heap allocation a class would have needed, and a mutable struct is a common source of confusing bugs (mutating a copy instead of the original).
  • Trying to store a Span<T> as a field on a class, return it from an async method, or capture it in a lambda that outlives the current stack frame — the compiler rejects all of these, by design, because a span backed by stack memory would become invalid the moment its stack frame returns.
  • Assuming .NET's garbage collector never pauses the application at all — modern Server GC is highly concurrent and its pauses are usually very short, but it isn't pause-free; chasing GC-related performance problems should start with profiling actual allocation patterns, not guessing.

Interview questions

Q: Why might using a struct instead of a class actually hurt performance? A struct is copied by value every time it's passed as an argument, returned, or assigned to a new variable. If the struct is large, that copying cost can exceed the cost of the single heap allocation a class would have needed instead — structs pay off mainly when they're small and either immutable or passed by in/ref to avoid the copy entirely.

Q: What is Span<T> and why can't it be stored as a class field? Span<T> is a type-safe view over a contiguous region of memory — an array, part of a string, or stack-allocated memory — that lets code slice and process data without allocating a new copy. It's a ref struct, a compiler-enforced restriction that keeps it confined to the stack, because it may point at stack memory that would become invalid once its declaring method returns; storing it on the heap (as a class field) or across an async boundary could leave it pointing at memory that no longer exists.

Q: Why is the .NET garbage collector generational? Because most objects, empirically, die young — a generational GC exploits this by frequently and cheaply collecting Gen 0 (new, likely-already-garbage objects), promoting survivors up through Gen 1 into Gen 2, and only running the expensive, whole-heap Gen 2 collection much less often. This avoids re-scanning long-lived objects on every collection cycle.