Value types and reference types look like a beginner topic until an interviewer asks what's actually in an object's header, why a supposedly-cheap in parameter just got slower, or why two structs that look identical layout differently in memory. At the senior and architect level, this line of questioning checks whether your mental model of "how .NET lays out data" survives contact with the runtime's actual rules rather than the simplified version taught early on. This page works through the object header and method table pointer, boxing, struct design guidelines, explicit layout, readonly struct semantics, ref/in parameters and correct equality — the memory-layout fundamentals that show up constantly in performance-sensitive and low-allocation .NET code.

Q1 What's actually stored in a .NET object's header, and what is the method table pointer used for?#

Short answer: Every object on the managed heap carries a small fixed header immediately before its fields — on 64-bit .NET this is a sync-block/hash header word followed by a pointer to the object's MethodTable — for a combined 16 bytes of overhead (8 bytes of header plus an 8-byte method table pointer) before any of the object's own fields begin; arrays add four more bytes for their element count plus four bytes of padding.

The method table pointer is what makes an object "know its own type" at runtime: it's the field every type check, virtual call and GetType() call ultimately reads, and it's why the runtime can determine an object's exact type from the object reference alone with a single pointer-sized memory read, no separate lookup table required. The header word ahead of it (sometimes called the sync block index) is a lazily-populated slot used for the object's default Monitor-based lock (what lock(obj) actually uses), its default GetHashCode() value when a type doesn't override it, and a few other rare per-instance runtime needs — most objects never populate it beyond its default zero value, but every object pays for its presence. This is precisely why the guidance "never take a lock on this or on a publicly exposed object" isn't just about encapsulation: the header slot backing that lock is a single per-object resource, so any code with a reference to the object can contend for the same header-backed monitor, whether or not it's the code you intended to synchronize with.

C#
// Roughly, on 64-bit .NET:
// [8-byte header/sync-block word][8-byte MethodTable*][instance fields...]
// An array additionally carries a 4-byte length and 4 bytes of padding
// between the MethodTable pointer and its first element.
object o = new object();          // 16 bytes of header overhead, zero fields
int[] arr = new int[4];           // 16 bytes header + 8 bytes length/padding + 16 bytes of ints

What interviewers look for: the two distinct header components (a sync-block/hash word, and the method table pointer) and their separate purposes — conflating "the header" with just "the type pointer" misses the locking/hashing implications entirely.

Follow-up questions:

  • Why does taking a lock on a struct not compile, given the header lives on the object?
  • What happens to the sync-block word the first time you call GetHashCode() on an object with no override?

Q2 What actually happens when a value type is boxed, and where does boxing sneak into code that doesn't look like it should allocate?#

Short answer: Boxing allocates a new heap object with a method table pointer set to the value type's boxed representation and copies the value type's bits into it, producing an ordinary heap-tracked object that a reference can point to; the costly part isn't the copy itself, it's the allocation and the eventual GC pressure, so boxing that happens invisibly, inside a hot path, is where it actually hurts.

The obvious cases — object o = 42;, adding an int to a non-generic ArrayList, or passing a struct to a method that takes object — are easy to spot in review. The hidden cases are what trip up experienced engineers: calling an interface method on a struct through the interface type rather than the concrete type (IComparable c = someStruct; c.CompareTo(other);) boxes the struct to obtain an interface reference unless the JIT can prove and constrain the call away, which it generally can for generic code (Comparer<T>.Default and constrained generic calls are specifically designed to avoid this) but not for a plain, non-generic interface-typed local; string interpolation and composite formatting box a value-typed argument the moment it's captured as object for an overload that doesn't have a value-type-specific path; and boxed values captured into a non-generic collection or cached in a Dictionary<string, object> box on every insert and, less obviously, box again on every read if the caller's code unboxes and reboxes rather than caching a strongly-typed reference. The general rule for finding hidden boxing in review: any time a value type flows into a variable, parameter or field typed as object or as a non-generic interface, assume it boxes unless you've confirmed a generic, constrained code path is actually in use.

C#
struct Point(int x, int y) : IComparable<Point>
{
    public int CompareTo(Point other) => X.CompareTo(other.X);
    public int X { get; } = x;
    public int Y { get; } = y;
}

object boxed = new Point(1, 2);              // boxes: Point flows into an `object`
IComparable<Point> iface = new Point(1, 2);  // boxes: interface-typed reference needs a heap object

// No boxing: the generic method is specialized per value type and calls
// through a constrained interface call instead of an interface reference.
static int CompareGeneric<T>(T a, T b) where T : IComparable<T> => a.CompareTo(b);

What interviewers look for: the "flows into object or a non-generic interface" heuristic as a fast, practical way to spot hidden boxing in a code review, not just a definition of what boxing is.

Common mistakes: assuming generics always avoid boxing regardless of constraints, or assuming boxing is only a concern for explicit object casts and missing interface-typed variables entirely.

Q3 What are the guidelines for when a type should be a struct instead of a class, and what goes wrong when you ignore them?#

Short answer: The long-standing .NET guidance is to make a type a struct only when it logically represents a single value (not an entity with identity), is immutable, is small — the commonly cited rule of thumb is roughly 16 bytes or less — and won't be boxed frequently; violating any of these, especially size and mutability, tends to produce code that's slower than the equivalent class, not faster, which is the opposite of most engineers' intuition about structs.

Size matters because passing, returning and copying a struct copies every byte of it — a 200-byte struct passed by value through several layers of method calls copies 200 bytes at every hop, which can easily exceed the cost of a single 8-byte reference copy plus the amortized cost of GC managing that memory, especially once the struct is large enough to blow past what fits comfortably in registers. Mutability compounds the problem: a mutable struct copied into a local, a collection element, or a boxed instance creates an independent copy the moment it's copied, so mutating that copy silently doesn't affect the original — the classic bug of list[i].SomeField = x; not compiling (or, worse, compiling against a property and silently mutating a throwaway copy) exists specifically because the compiler is protecting you from exactly this trap. The "won't be boxed frequently" guideline exists because boxing erases the entire performance argument for choosing a struct in the first place — a type that spends most of its life as a boxed object behind an interface reference gets none of the copy-avoidance or cache-locality benefits a struct is supposed to provide, while still paying struct-specific costs like larger copies at the point of boxing.

What interviewers look for: connecting each guideline to a concrete failure mode (large copies, silent-mutation bugs, boxing erasing the benefit) rather than reciting the size threshold as an arbitrary rule.

Q4 How does [StructLayout] control field layout, and why might the runtime still reorder a Sequential struct's fields?#

Short answer: LayoutKind.Auto (the default for a struct with no attribute) lets the runtime reorder and pack fields however it thinks is most efficient, including grouping reference-typed fields together for GC scanning purposes; LayoutKind.Sequential keeps fields in their declared order but still allows the runtime to insert padding between them for alignment, so "sequential" is a guarantee about order, not about there being zero gaps; LayoutKind.Explicit with [FieldOffset] on each field hands you full manual control, including deliberately overlapping fields union-style, at the cost of being entirely responsible for getting alignment and any GC-tracked-reference overlap rules right yourself.

The alignment nuance under Sequential is the one that catches people in interviews: a struct with a byte followed by a long will typically have seven bytes of compiler-inserted padding between them so the long lands on an 8-byte boundary, even though you wrote them adjacent in source — reordering fields from largest to smallest is a simple, real technique to shrink a struct's total size by minimizing that padding. Explicit layout is genuinely dangerous territory with reference-typed fields: overlapping a reference-typed field with non-reference bytes via [FieldOffset] is enforced by the runtime specifically because letting the GC misinterpret a raw integer as an object pointer (or vice versa) would corrupt the heap, so explicit layouts mixing reference and value fields have real restrictions the compiler enforces at type-load time, not just at your own risk. Pack on the attribute additionally controls the alignment boundary itself, which matters mainly for interop scenarios matching a native struct's exact byte layout — a mismatch there produces silently wrong marshaling rather than a compile error.

C#
[StructLayout(LayoutKind.Sequential)]
struct Poorly    // 1 + 7 padding + 8 + 4 + 4 padding = 24 bytes
{
    public byte Flag;
    public long Id;
    public int Count;
}

[StructLayout(LayoutKind.Sequential)]
struct Better     // 8 + 4 + 1 + 3 padding = 16 bytes: largest fields first
{
    public long Id;
    public int Count;
    public byte Flag;
}

What interviewers look for: the specific misconception check — that Sequential guarantees order but not the absence of padding — plus awareness that Explicit layout has real, enforced restrictions around reference-typed fields rather than being an unconstrained escape hatch.

Q5 What does readonly struct actually change, and what is a "defensive copy"?#

Short answer: A defensive copy is a compiler-inserted, silent copy of a mutable struct made whenever the compiler calls a member on it through a reference it can't prove is safe to mutate in place — a readonly field, an in parameter, or a foreach iteration variable — because without that copy, calling any member (even one that only reads state) could theoretically mutate the original through a non-readonly member; marking the struct itself readonly removes the need for these copies entirely, because the compiler can now prove statically that no member can mutate any instance field, defensive or otherwise.

Without readonly struct, this cost is easy to introduce accidentally: a large, frequently-passed struct exposed as a readonly field or accessed through an in parameter pays for a full copy on every single member call the compiler can't prove is safe, even simple property getters, because the compiler has no way to know in advance whether that particular getter secretly mutates state. This isn't a hypothetical performance nitpick — it's silent and easy to miss in review, because the code compiles, runs correctly, and produces no warning; the only symptom is a struct that's copied far more often than the source code appears to suggest, which is exactly the kind of cost a profiler surfaces long before code review would. Applying readonly to every field individually (rather than to the struct as a whole) reduces but doesn't eliminate this — the compiler still can't be sure a method isn't mutating some field through an indirect path unless the entire struct is declared readonly, which is why readonly struct at the type level is the complete fix, not merely readonly fields.

C#
struct MutablePoint { public int X, Y; public int Sum() => X + Y; }
readonly struct ReadonlyPoint(int x, int y)
{
    public int X { get; } = x;
    public int Y { get; } = y;
    public int Sum() => X + Y;
}

readonly MutablePoint mp = new() { X = 1, Y = 2 };
mp.Sum();          // defensive copy made here: the compiler can't prove Sum() doesn't mutate X/Y

readonly ReadonlyPoint rp = new(1, 2);
rp.Sum();          // no copy: readonly struct proves no member can mutate state

What interviewers look for: a concrete explanation of why the copy happens (the compiler can't prove safety, not that it's being needlessly cautious) and that readonly struct is what actually eliminates it, not readonly on individual fields alone.

Q6 What do ref returns and in parameters actually buy you, and when does in make performance worse instead of better?#

Short answer: A ref return hands the caller a genuine alias to an existing storage location — an array or List<T> element, typically, via CollectionsMarshal.AsSpan or a custom indexer — letting the caller read or mutate it in place without copying it out and back in; an in parameter passes a large struct by reference to avoid copying it into the callee's stack frame, but for a small struct that already fits in one or two registers, the added indirection of passing a pointer plus the risk of a defensive copy inside the callee can make in measurably slower than plain pass-by-value.

The defensive-copy trap is the specific mechanism that makes in a net loss in some cases: unless the parameter's type is a readonly struct, the compiler still can't prove the callee's member calls on that in parameter won't mutate it, so it inserts the same defensive copy discussed above — at which point you've paid for an indirection and a copy, strictly worse than just passing by value in the first place. This is why the practical guidance is narrower than "always use in for structs": reach for in specifically when the struct is both large (large enough that avoiding the copy into the callee's frame is worth an indirection) and readonly (so no defensive copy re-introduces the cost you were trying to avoid) — for anything pointer-sized or smaller, plain pass-by-value is simpler and often faster. ref returns have a different, narrower use case entirely: they're for genuinely avoiding a copy-out/copy-back-in round trip when working with a large struct stored in a collection, most commonly paired with Span<T>-based APIs; the compiler's ref-safety rules (a ref return can't escape the safe lifetime of what it points to) exist specifically to prevent a ref from outliving the storage it aliases, which is the whole reason this feature required careful, dedicated language design rather than just relaxing an existing rule.

C#
// ref return: mutate the element in place, no copy-out/copy-in round trip.
ref var item = ref CollectionsMarshal.AsSpan(items)[index];
item.Count++;

// `in` only pays off when the struct is large AND readonly:
static decimal Total(in LargeReadonlyOrder order) => order.Subtotal + order.Tax;

What interviewers look for: the specific condition under which in backfires (non-readonly struct triggering a defensive copy) rather than a blanket "in avoids copies" claim.

Follow-up questions:

  • Why can a ref return not point at a local variable declared inside the same method?
  • What's the difference between ref readonly and in as parameter modifiers?

Q7 How do you implement value equality correctly for a struct, and why is the inherited ValueType.Equals slow?#

Short answer: The default Equals every struct inherits from System.ValueType boxes its argument and then, for structs containing only value-typed fields with no padding gaps, does a raw byte comparison — but the moment a struct has any reference-typed field, or the compiler-inserted padding between fields differs in a way the byte comparison can't safely ignore, it falls back to comparing every field individually through reflection, which is dramatically slower than a hand-written comparison; the fix is implementing IEquatable<T>.Equals, overriding object.Equals and GetHashCode to match, and typically ==/!= operators, so equality never has to fall back to the reflection path or box either operand.

The reflection fallback is easy to trigger without realizing it: any struct with a string field, a nested reference-typed field, or fields whose layout leaves the byte-comparison path unsafe ends up on the slow path every single time Equals is called, which is exactly the kind of cost that's invisible in code review and only shows up as an unexplained hotspot in a profiler on a type used heavily as a dictionary key or in equality-heavy LINQ operations. IEquatable<T> specifically avoids boxing on top of avoiding reflection — without it, calling .Equals(other) on a value-typed generic parameter constrained only by the default object.Equals contract boxes both sides for the comparison, while IEquatable<T>.Equals(T other) takes the comparand by its actual type and never boxes. Records solve this identically but automatically: the compiler generates a field-by-field Equals, GetHashCode and PrintMembers for you with no reflection involved, which is exactly why "just use a record" is often the right answer when you'd otherwise be hand-writing this boilerplate for a small, immutable data-holder type — though a record struct still inherits every layout and copy consideration covered elsewhere on this page, since it's a struct underneath the generated equality code.

C#
readonly struct Money(long cents, string currency) : IEquatable<Money>
{
    public long Cents { get; } = cents;
    public string Currency { get; } = currency;

    public bool Equals(Money other) => Cents == other.Cents && Currency == other.Currency;
    public override bool Equals(object? obj) => obj is Money m && Equals(m);
    public override int GetHashCode() => HashCode.Combine(Cents, Currency);
    public static bool operator ==(Money left, Money right) => left.Equals(right);
    public static bool operator !=(Money left, Money right) => !left.Equals(right);
}

What interviewers look for: knowing specifically why the default is slow (boxing plus a reflection-driven field walk once any field breaks the raw-byte-comparison fast path), not just "implement IEquatable<T> because it's faster."

Common mistakes: implementing IEquatable<T>.Equals but forgetting to override object.Equals/GetHashCode to match, which leaves boxed comparisons and hash-based collections using the slow, inconsistent inherited behavior.

Q8 Why can an array of value types dramatically outperform an array of reference types for sequential processing, and when does that advantage disappear?#

Short answer: A struct[] stores every element's data inline, contiguously, inside one allocation, so scanning it sequentially is a straight, cache-friendly memory walk with a single object header for the whole array; a class[] stores only references inline, with each actual object a separate heap allocation potentially scattered anywhere the GC placed it, so the same scan chases a pointer per element into whatever part of memory each object happens to occupy, which is far more likely to miss cache and, on a moving (compacting) collector, can relocate between accesses.

This advantage shrinks or reverses under a few common conditions worth naming precisely: once the struct is large enough that copying it (into a foreach iteration variable, into a method parameter, into a LINQ pipeline's intermediate buffers) costs more than the cache-locality win saves, the reference-type array can win instead; if you need reference semantics — shared identity, in-place mutation visible through multiple holders, or polymorphism across a hierarchy — a struct array can't provide that without real design contortions (wrapping in a class anyway, or ref returns tightly scoped to avoid escaping); and the classic silent-bug variant, iterating a struct array with foreach and mutating the loop variable, does nothing to the underlying array at all, because the loop variable is a copy, which is a correctness trap that the reference-type equivalent doesn't share. For genuinely large, fixed-shape numeric or record-like data processed in bulk — physics simulation state, particle systems, tight numeric kernels — struct arrays (often paired with Span<T> to avoid even the array's own bounds-check and allocation overhead in hot loops) remain one of the most reliable, low-risk performance techniques available in idiomatic C#.

What interviewers look for: naming the specific conditions where the advantage reverses (large struct copy cost, need for reference semantics, mutation-through-copy bugs) rather than treating "struct arrays are always faster" as an unconditional rule.

Q9 How does the runtime handle a generic type instantiated over a value type versus a reference type? Why do List<int> and List<string> behave so differently under the hood?#

Short answer: Every reference-type instantiation of a generic type — List<string>, List<object>, List<Customer> — shares one canonical MethodTable, EEClass and, generally, one compiled body of code, because all references are the same pointer size and the generic code can be written once against that shared representation (internally represented by the placeholder type System.__Canon); every value-type instantiation — List<int>, List<Guid>, List<DateTime> — gets its own dedicated, unshared MethodTable and, because field layout and size genuinely differ between an int and a Guid, generally its own specialized JIT-compiled code as well.

This is why List<int> doesn't box its elements the way a non-generic ArrayList would: the runtime generates (or reuses a cached) genuinely int-specialized version of List<T>'s internal array field as an actual int[], not an object[], because the value-type instantiation was never forced through the shared reference-type representation in the first place. The practical cost of this design shows up at load and compile time rather than at execution time: an application that instantiates a generic collection type over dozens of distinct value types (an unusual but real pattern in some serialization or numeric-heavy codebases) pays for that many separate MethodTables and, potentially, that many separately JIT-compiled code bodies, where the equivalent spread of reference-type instantiations would have shared nearly all of it — this is one of the concrete reasons "too many distinct generic value-type instantiations" is a real, if uncommon, contributor to both larger working set and longer JIT warm-up time in large applications.

What interviewers look for: the specific mechanism (canonical sharing via System.__Canon for reference types, dedicated per-instantiation MethodTables for value types) rather than a vague "generics work differently for structs," plus the load-time/JIT-time cost implication of over-using generic value-type instantiations.

Q10 A candidate says "structs are always faster because they avoid the GC." Where does that claim break down?#

Short answer: It conflates "doesn't independently heap-allocate as a local" with "never touches the GC or never costs anything" — a struct field inside a class is still scanned and moved by the GC as part of that class's object, a struct captured by a closure or an async state machine is heap-allocated exactly like a class would be, a struct passed by value repeatedly copies its full size at every call (a cost a reference never pays, since copying a reference is always pointer-sized), and boxing turns a struct into a heap object with all the same GC obligations a class instance has, sometimes worse because the boxed copy and the original now diverge.

A precise version of the claim would be: structs can avoid a heap allocation and its resulting GC pressure specifically when they're small, remain as true locals or parameters for their entire lifetime, and are never boxed — under those conditions, yes, they're meaningfully cheaper because there's no allocation to track or eventually collect at all. Outside those conditions, the comparison flips: a large struct copied through several layers of calls can cost more in raw CPU time than a reference type's single allocation plus a generation-0 collection would have, since gen0 collection cost is proportional to what survives, and a short-lived class instance that dies before the next gen0 collection is close to free in the CLR's cost model, as covered in Garbage Collector Interview Questions. The version of this answer a senior interviewer actually wants: "value types avoid GC pressure under specific, checkable conditions — small, non-escaping, non-boxed — and violate every one of those conditions constantly in real code, at which point the reference-type alternative can win outright," which is a materially different, more useful claim than the blanket statement the question opens with.

What interviewers look for: whether you push back on the premise with the specific conditions under which it's true, rather than either agreeing with the blanket claim or dismissing it entirely — this is a deliberately imprecise statement designed to test calibration, not recall.

Quick-Fire Round#

QuestionAnswer
How many bytes of header overhead does a plain object carry on 64-bit .NET?16 (8-byte header word + 8-byte method table pointer).
What field does every type check and virtual call ultimately read?The object's method table pointer.
Does LayoutKind.Sequential guarantee zero padding between fields?No — it guarantees declared order, not the absence of alignment padding.
What eliminates the need for defensive copies entirely?Declaring the struct readonly.
When can in make a call slower than plain pass-by-value?When the struct is small, or not readonly (triggering a defensive copy).
Why is the inherited ValueType.Equals often slow?It boxes and, once any field breaks the raw-byte path, compares fields via reflection.
Do reference-type generic instantiations share code?Yes — via a canonical representation (System.__Canon).
Do value-type generic instantiations share code the same way?No — each gets its own MethodTable and typically its own compiled code.
Does a struct field inside a class still get GC-scanned?Yes — it's scanned and moved as part of the containing object.
Is a boxed struct still subject to GC like a class instance?Yes — boxing produces an ordinary heap object.

How to Prepare#

  • Be able to sketch the 64-bit object header (sync-block/hash word plus method table pointer) and its byte cost from memory.
  • Practice spotting hidden boxing: any value type flowing into an object-typed or non-generic-interface-typed variable.
  • Know the size/immutability/boxing-frequency guidelines for choosing struct over class, each tied to a concrete failure mode.
  • Rehearse the defensive-copy explanation with a runnable example, and state precisely what readonly struct changes about it.
  • Be ready to explain when in helps and when it backfires — this is a very common "gotcha" follow-up in senior loops.
  • Prepare the generic sharing answer (System.__Canon for reference types, per-instantiation MethodTables for value types) with the List<int> vs List<string> example.