Article · C# & .NET
Where Do Value Types Actually Live in C#? The Stack Myth Explained
Part 01 of C# & .NET, from the Inside, Collection 1: C# Types, Memory & Runtime Behavior. New to the series? Start with the roadmap.
Ask a C# developer where value types are stored, and you will often hear the same answer:
Value types live on the stack. Reference types live on the heap.
It is a simple explanation. It is also an incomplete one. Even Microsoft's own .NET 9 runtime notes open their section on boxing with "value types… are typically allocated on the stack".
The distinction between struct and class is fundamental to C#, but reducing it to a rule about memory locations creates a misleading mental model. It encourages us to think that the type of a value determines where it must be stored, regardless of how that value is used.
Consider a few cases. A struct can be a field inside a class instance. A value type can be an element of an array. An integer can be boxed into an object. A local variable can be captured by a lambda, which changes how the compiler represents it.
If value types always live on the stack, how do we explain these cases? And if the answer depends on the circumstances, what actually determines where a value is stored?
To answer that, we need to separate three layers: how C# defines value and reference semantics, how the compiler transforms our code, and how the .NET runtime and its JIT compiler represent values and objects. We'll start with the language, inspect the generated IL, look at boxing and object layout, measure the cost with BenchmarkDotNet on .NET 9 and .NET 10, and finish with the native code RyuJIT actually produces.
The goal is not simply to learn where a struct lives. It is to understand why storage location is not what defines a value type in C#.
All the benchmarks in this article are public in the DotNetBoxingBenchmarks repository, with their raw reports.
Contents
- 1. Value types vs. reference types: the actual difference
- 2. Where does the data actually live?
- 3. Boxing and unboxing: what really happens?
- 4. Inside the runtime: objects, layout and size
- 5. Measuring the cost of boxing with BenchmarkDotNet
- 6. The modern .NET twist: when the box disappears
- 7. Conclusion: a better mental model
- Reproducing the experiments
- A note on technical accuracy
- References and further reading
1. Value types vs. reference types: the actual difference
C# has two fundamental categories of types. Value types include the numeric types (int, double, decimal), bool, char, enumerations and user-defined struct types. Reference types include classes, arrays, interfaces, delegates and string.
The fundamental difference is not where they live. It is how values and references behave when they are assigned, passed to methods and stored in variables.
Value types: assignment copies the value
public struct Point
{
public int X;
public int Y;
}
var first = new Point { X = 10, Y = 20 };
var second = first;
second.X = 99;
Console.WriteLine(first.X); // 10
Console.WriteLine(second.X); // 99
var second = first copies the value of first into second. The two variables now hold independent Point values, so modifying second.X changes the copy, not the original.
The same principle applies when a value type is passed to a method by value: the method receives a copy. C# also has ref, in and out parameters, which change how arguments are passed, but the essential point is this: ordinary assignment of a value type copies its value.
Reference types: assignment copies the reference
public sealed class Person
{
public string Name { get; set; } = string.Empty;
}
var first = new Person { Name = "Ayoub" };
var second = first;
second.Name = "Ahmed";
Console.WriteLine(first.Name); // Ahmed
Console.WriteLine(second.Name); // Ahmed
The code looks almost identical, but the result is different. var second = first copies the reference held by first. It does not create a new Person. Both variables refer to the same object, so a change made through second is visible through first.
To get two independent objects, we have to create another instance explicitly:
var first = new Person { Name = "Ayoub" };
var second = new Person { Name = first.Name };
second.Name = "Ahmed";
Console.WriteLine(first.Name); // Ayoub
Console.WriteLine(second.Name); // Ahmed
Reference types can be copied. The important distinction is between copying a reference and copying the object it points to: assignment copies the reference, not the referenced object.
What gets copied is the real distinction
| Operation | Value type | Reference type |
|---|---|---|
| Ordinary assignment | Copies the value | Copies the reference |
| Modifying the copied variable | Does not modify the original value | Can modify the same referenced object |
| Creating an independent instance | Assignment already does it | Requires creating or copying an object explicitly |
| Storage location | Depends on context and implementation | The referenced object is ordinarily a managed heap object |
The first three rows describe semantics. The last row concerns implementation and storage. Those are different questions.
VALUE TYPE (each variable contains its own value)
first = Point { X = 10, Y = 20 }
second = Point { X = 99, Y = 20 } (an independent copy)
REFERENCE TYPE
first ──┐
├──> Person { Name = "Ahmed" } (one shared object)
second ──┘
A value type can be stored inline in a class instance or an array. It can be represented by a boxed object. A local value might sit in a CPU register, in a stack slot, or be optimized away by the JIT. We cannot determine the physical location of a value just by checking whether its type is a struct or a class. That is the misconception at the heart of the stack myth.
Copying a struct is not a deep copy
Value types are copied by value, but that does not mean every object reachable from a value is copied.
public sealed class Address
{
public string City { get; set; } = string.Empty;
}
public struct Employee
{
public Address Address;
}
var first = new Employee
{
Address = new Address { City = "Casablanca" }
};
var second = first;
second.Address.City = "Rabat";
Console.WriteLine(first.Address.City); // Rabat
Console.WriteLine(second.Address.City); // Rabat
Assigning first to second copied the Employee struct, including its Address reference. Both structs now point to the same Address object, and the next line modifies that shared object.
If the field had been a string and we had assigned second.Name = "Ahmed", the original would not have changed: we would have replaced the reference held by the copy, not modified a shared object. Value types are not automatically deep-copied. A struct's reference-type fields are copied as references.
What about strings?
string is a reference type. Assigning one string variable to another copies the reference, not the characters. Yet strings often behave like values because they are immutable:
string first = "Hello";
string second = first;
second = "World";
Console.WriteLine(first); // Hello
Console.WriteLine(second); // World
second = "World" doesn't modify the original string; it makes second refer to a different string. Immutability, interning, equality and Span<char> deserve their own article later in this series. For now: a type's everyday behavior does not tell you where its data is stored.
The interview angle
Question: What is the difference between a value type and a reference type in C#?
Typical answer: "Value types hold their values, while reference types hold references to objects. Structs are value types and classes are reference types."
Stronger answer: "They differ in language semantics. Assigning a value type copies its value; assigning a reference type copies the reference to the same object. That shapes how values behave when they're assigned or passed to methods, but it doesn't establish a stack-versus-heap rule. Where the data physically lives depends on its context and on the runtime."
The second answer separates the language-level distinction from the runtime's memory decisions. That separation is the foundation for everything that follows.
2. Where does the data actually live?
If value types don't always live on the stack, where do they live? The answer depends on how the value is used, what contains it and how the compiler and runtime represent it. It helps to distinguish three concepts:
- A variable is a storage location that can hold a value.
- A value is the data represented by a particular type.
- An object is a runtime instance whose representation depends on its type and execution context.
A variable of reference type holds a reference, which may refer to an object. A variable of value type directly contains a value of its declared type. The physical representation of that variable or object is a separate, implementation-level question. Let's look at six situations.
Local variables: does an int always live on the stack?
static int Calculate()
{
int x = 10;
int y = 20;
return x + y;
}
The traditional explanation says the runtime reserves stack space for x and y, stores the values, reads them back, adds them and returns the result. That is one possible implementation, but it is not guaranteed.
The C# compiler translates the method into Intermediate Language (IL), and the JIT compiler translates that IL into machine code, optimizing where it can. Here the JIT can see that the result is a constant and emit code equivalent to return 30;. There may be no stack locations for x and y at all. Even when values can't be folded into constants, the JIT often keeps them in CPU registers instead of stack memory. The IL's list of locals doesn't prove that matching stack slots exist in the final machine code either.
A local variable in C# source does not guarantee a dedicated stack slot at runtime. It may live in a register, in a stack slot, or disappear in optimized code.
Try it yourself. Paste
Calculate()into SharpLab, switch the output to IL and then to JIT Asm in Release mode, and compare the two. Note which locals exist in the IL and which survive in the native code.
A struct inside a class
public struct Coordinates
{
public int Latitude;
public int Longitude;
}
public sealed class Location
{
public Coordinates Position;
}
var location = new Location
{
Position = new Coordinates { Latitude = 33, Longitude = -7 }
};
Coordinates is a value type and Location is a reference type. The Position field belongs to the Location object. For an ordinary Location instance on the managed heap, the value of Position is stored directly in that object's instance data. The runtime doesn't create a separate boxed Coordinates object just because a class contains a struct field.
Location object (conceptual)
┌──────────────────────────┐
│ Runtime object metadata │
├──────────────────────────┤
│ Position │
│ Latitude = 33 │
│ Longitude = -7 │
└──────────────────────────┘
This is a conceptual picture, not an exact byte layout. The key observation is that the struct's fields are part of the containing object's data: if the object is on the managed heap, so is the struct. That is already a counterexample to "structs always live on the stack."
Structs inside arrays
Point[] points = new Point[3];
points[0] = new Point { X = 10, Y = 20 };
points[1] = new Point { X = 30, Y = 40 };
points[2] = new Point { X = 50, Y = 60 };
An array is a reference type, even when its elements are value types. In the ordinary .NET array representation, the array object contains its elements inline:
Point[] array object (conceptual)
┌──────────────────────────┐
│ Runtime array metadata │
├──────────────────────────┤
│ [0] X = 10, Y = 20 │
├──────────────────────────┤
│ [1] X = 30, Y = 40 │
├──────────────────────────┤
│ [2] X = 50, Y = 60 │
└──────────────────────────┘
The runtime does not create three boxed objects for these elements; their values are part of the array's element storage. An array of a class type is different: Person[] people = new Person[3]; holds three reference slots, and each Person is a separate object.
The representation affects object counts, memory access patterns and potentially performance. It doesn't follow that arrays of structs are always faster than arrays of classes: that depends on struct size, copying costs, access patterns and the work being done.
Boxing: a value type represented as an object
int number = 42;
object boxed = number;
number = 100;
Console.WriteLine(number); // 100
Console.WriteLine(boxed); // 42
Converting an int to object is a boxing conversion. In the usual runtime implementation, boxing creates an object containing a copy of the value, which is why boxed still holds 42 after number changes. A value type can therefore be represented inside a managed object. We'll look at boxing in depth in the next section.
Captured local variables
static Func<int> CreateCounter()
{
int count = 0;
return () => ++count;
}
Func<int> counter = CreateCounter();
Console.WriteLine(counter()); // 1
Console.WriteLine(counter()); // 2
Console.WriteLine(counter()); // 3
count is a local of CreateCounter(), yet the lambda keeps reading and modifying it after CreateCounter() has returned. To preserve that behavior, the C# compiler moves the captured variable into a compiler-generated closure class, often called a display class. A simplified illustration:
// Illustrative only: not the exact code the compiler generates.
sealed class DisplayClass
{
public int Count;
public int Increment()
{
return ++Count;
}
}
static Func<int> CreateCounter()
{
var closure = new DisplayClass();
closure.Count = 0;
return closure.Increment;
}
count is now a field of an object whose lifetime can extend beyond the method call. Its type is still int, but its storage lives inside a heap-allocated closure object. The value-type classification didn't change; the representation did, to preserve the program's semantics.
Try it yourself. Paste
CreateCounter()into SharpLab and switch the output to C#. Look for a generated type whose name starts with<>c__DisplayClass, find the field that holdscount, and see how the delegate reaches it. The exact names depend on the compiler version.
Async methods
static async Task<int> CalculateAsync()
{
int value = 42;
await Task.Delay(100);
return value * 2;
}
value is declared before the await and used after it. The method may suspend while waiting and resume later, so the compiler turns it into a state machine that preserves the values needed to resume. A simplified view:
// Conceptual only: the real state machine has more fields and a MoveNext method.
struct CalculateAsyncStateMachine
{
public int State;
public int Value;
}
value can become a field of that state machine because it's needed after the await. The generated state machine is not automatically a heap object just because the method is async: if the method completes synchronously, it doesn't need the same allocations as one that actually suspends. When it does suspend, the state needed for the continuation must survive, and the runtime can use heap storage for it. The exact behavior depends on the generated code, the awaitable and runtime optimizations. So it would be wrong to claim that every local in an async method lives on the heap, and just as wrong to claim it lives on the stack.
Putting the evidence together
| Scenario | What we observed |
|---|---|
| Local value-type variable | May be held in a register, a stack slot, or optimized away |
| Struct field in a class | Stored inline in the containing object's data |
| Struct element in an array | Stored inline in the array's element storage |
| Boxed value type | Represented inside a boxed object, ordinarily allocated |
| Captured local variable | May become a field of a compiler-generated closure object |
Local used across an await | May become a field of a compiler-generated state machine |
The language defines the semantics of values and references; the compiler and runtime decide how to implement them. A more useful mental model than "structs go on the stack, classes go on the heap" is: value types are copied with value semantics, and their physical storage depends on the context in which they're used and on the runtime implementation.
3. Boxing and unboxing: what really happens?
int number = 42;
object result = number;
This compiles without a cast, yet int is a value type and object is a reference type. The bridge between the two worlds is boxing.
Why does boxing exist?
Every C# type ultimately derives from System.Object, value types included, so an int can call ToString() or GetType(). C# and the CLR provide a unified type system. But inheriting from System.Object doesn't mean every integer is a separate heap object. An int normally exists as a plain integer, with no object wrapper.
When the program needs a reference-type representation of that value, boxing provides it. Conceptually, the runtime:
- creates a boxed representation of the integer,
- copies the integer's value into it,
- produces a reference to that boxed object.
Under ordinary execution, this creates an object on the managed heap. The original integer doesn't become a reference type; a separate boxed copy of its value is created. As we'll see in section 6, the JIT can sometimes remove that object when doing so preserves the program's behavior.
Boxing creates a copy, not an alias
We saw it above: after object boxed = number; number = 100;, boxed still contains 42. The boxed object holds its own copy of the integer, not a reference to the original variable.
number boxed
┌───────┐ │
│ 100 │ ▼
└───────┘ ┌─────────────────┐
│ Boxed Int32 │
├─────────────────┤
│ Value: 42 │
└─────────────────┘
Boxing changes how a value is represented; it does not turn the original variable into an alias for the boxed storage.
The box instruction in IL
C# source doesn't show the instructions the runtime executes. To see boxing explicitly, we can look at the IL the C# compiler generates for this method:
static object BoxNumber(int number)
{
return number;
}
A representative IL sequence is:
ldarg.0
box [System.Runtime]System.Int32
ret
ldarg.0loads the first argument onto the IL evaluation stack. That stack is part of IL's abstract execution model; it is not the physical thread stack.boxperforms the boxing operation and produces an object reference.retreturns that reference.
The box instruction makes the conversion explicit even though the C# code has no cast. The exact formatting and assembly qualification vary with the compiler and target framework.
Seeing box in IL establishes that the program has boxing semantics. It does not prove that the optimized machine code allocates on every execution. That distinction will matter later.
Unboxing: getting the value back
int original = 42;
object boxed = original;
int restored = (int)boxed;
Console.WriteLine(restored); // 42
The explicit cast is an unboxing conversion. For a non-nullable value type, unboxing checks that the object contains a boxed value of the right type and copies that value out. The IL typically uses unbox.any:
ldarg.0
unbox.any [System.Runtime]System.Int32
ret
| IL instruction | Role |
|---|---|
box | Produces a boxed representation of a value |
unbox | After the type check, produces a managed pointer to the value inside a boxed object |
unbox.any | For a value type, obtains a copy of the value from the boxed representation |
For ordinary C# such as (int)boxed, unbox.any is what we usually see. Unboxing into a local doesn't create another object: it copies the value out of the existing box.
Unboxing is not a numeric conversion
int number = 42;
object boxed = number;
long result = (long)boxed; // InvalidCastException
C# allows an implicit conversion from int to long, so this looks reasonable. But the object contains a boxed System.Int32, not a boxed System.Int64, and the cast tries to unbox it as a long. The correct approach is to unbox to the actual type first, then convert:
long result = (int)boxed;
Console.WriteLine(result); // 42
Unboxing is type-sensitive. Unboxing an incompatible boxed value throws InvalidCastException, and unboxing null to a non-nullable value type throws NullReferenceException.
Boxing through interfaces
Boxing also happens when a value type is converted to an interface it implements.
public interface ICounter
{
void Increment();
int GetValue();
}
public struct Counter : ICounter
{
private int _value;
public void Increment() => _value++;
public int GetValue() => _value;
}
Counter counter = new Counter();
ICounter interfaceCounter = counter;
interfaceCounter.Increment();
Console.WriteLine(counter.GetValue()); // 0
Console.WriteLine(interfaceCounter.GetValue()); // 1
Assigning the struct to an interface variable boxes it. The box contains a copy of counter, and Increment() modifies the copy inside the box, not the original variable. The two values have become independent.
That doesn't mean calling an interface method on a struct always boxes. A direct call on the struct, or an appropriately constrained generic call, can avoid it.
Generics and the constrained. prefix
static void IncrementGeneric<T>(ref T value)
where T : struct, ICounter
{
value.Increment();
}
Counter counter = new Counter();
IncrementGeneric(ref counter);
Console.WriteLine(counter.GetValue()); // 1
The method is constrained to structs implementing ICounter, and the argument is passed by reference, so it operates on the original variable without converting it to an interface reference. The IL for the call includes:
constrained. !!T
callvirt instance void ICounter::Increment()
The constrained. prefix tells the runtime how to perform the virtual call for the generic type parameter. When the value type implements the method itself, the call can operate directly on the value without boxing. This is one of the mechanisms behind efficient generic code in .NET: generics preserve value-type representations as well as type safety. They're not automatically allocation-free, though, so the specific operations still need inspecting.
Boxing in collections
Before generics, values were commonly stored in non-generic collections like ArrayList, which stores object references. Adding an int boxes it, and reading it back as an int unboxes it:
using System.Collections;
var numbers = new ArrayList();
numbers.Add(10); // boxing
int first = (int)numbers[0]; // unboxing
List<int> stores its integers directly in an int[] backing array, so the same operations need no boxing. It also gives compile-time type safety and removes the casts. We'll measure the difference in section 5.
Boxing Nullable<T>
Nullable value types follow special boxing rules:
int? first = 42;
int? second = null;
object? boxedFirst = first;
object? boxedSecond = second;
Console.WriteLine(boxedFirst.GetType()); // System.Int32
Console.WriteLine(boxedSecond is null); // True
If HasValue is true, the underlying value is boxed, so the box is a System.Int32, not a Nullable<int>. If HasValue is false, boxing produces a null reference. This is defined by the language, not an optimization. Nullable value types get their own article later in the series.
When does boxing become a performance problem?
Ordinary boxing can involve a managed allocation, copying the value into the box, object metadata and alignment overhead, more objects for the garbage collector to track, and a type check plus copy when unboxing.
Not every boxing operation is a problem. Boxing an occasional integer during startup is unlikely to matter. Repeated boxing in a hot loop or a high-throughput path can have a measurable impact. And the presence of box in IL doesn't tell us what the final machine code does. That's why sections 5 and 6 rely on BenchmarkDotNet, allocation measurements and disassembly.
4. Inside the runtime: objects, layout and size
An int needs four bytes. A boxed int needs more. To see why, we need to look at how the CLR represents objects in memory.
What does a managed object contain?
The runtime must be able to identify an object's type and support virtual dispatch, synchronization and garbage collection. In a typical 64-bit CoreCLR implementation, a heap object has three conceptual parts:
- Object header: runtime bookkeeping associated with the object.
- Method table pointer: identifies the object's runtime type and gives access to type information.
- Instance data: the object's fields, plus any padding.
public sealed class Person
{
public int Age;
}
Person instance, typical 64-bit CoreCLR (conceptual)
┌──────────────────────────────┐ lower addresses
│ Object header (8 bytes) │
├──────────────────────────────┤ <- object reference points here
│ Method table ptr (8 bytes) │
├──────────────────────────────┤
│ Age (4 bytes) │
├──────────────────────────────┤
│ Padding / alignment │
└──────────────────────────────┘ higher addresses
This is a common 64-bit CoreCLR layout, not a guarantee of the C# specification. The object reference normally points to the method table pointer, with the header just before it, so a reference and the start of the object's allocated memory are not the same address.
- The object header holds bookkeeping such as synchronization state and, when needed, information related to the object's identity hash code. On typical 64-bit configurations, the region before the method table pointer takes eight bytes: the sync block value uses four, and the rest is alignment.
- The method table pointer identifies type information shared by all instances of the type. Each instance doesn't carry its own copy of the method table.
- The instance data contains the fields and any padding required by layout rules, so an object's size isn't simply the sum of its fields.
Why can an empty object take 24 bytes?
public sealed class Empty
{
}
An object with no fields still needs its runtime representation. On a typical 64-bit CoreCLR implementation, the minimum managed object size is 24 bytes:
| Component | Typical size |
|---|---|
| Object header region | 8 bytes |
| Method table pointer | 8 bytes |
| Minimum object storage / alignment | 8 bytes |
| Total | 24 bytes |
This minimum is a property of the runtime implementation, not a universal rule across all runtimes and architectures. It also explains why "the bytes needed to represent a value" and "the bytes occupied by an object containing that value" are different quantities.
What does a boxed integer look like?
Boxed System.Int32, typical 64-bit CoreCLR (conceptual)
┌──────────────────────────────┐
│ Object header 8 bytes │
├──────────────────────────────┤
│ Method table ptr 8 bytes │
├──────────────────────────────┤
│ Value: 42 4 bytes │
├──────────────────────────────┤
│ Padding / min size 4 bytes │
└──────────────────────────────┘
Total: 24 bytes
| Representation | Typical size on 64-bit CoreCLR |
|---|---|
int value | 4 bytes |
| Object reference | 8 bytes |
Boxed int object | 24 bytes |
These are different things. The 8-byte reference is separate from the 24-byte box; if the reference sits in a local, it may live in a register or a stack slot. And a 24-byte allocation isn't necessarily a 24-byte increase in process memory, because allocator bookkeeping and the GC affect what you observe. Still, boxing a small value clearly costs much more memory than storing it directly. Section 5 confirms the 24 bytes with real measurements.
A struct does not carry an object header
public struct Point
{
public int X;
public int Y;
}
public sealed class Location
{
public Point Position;
}
Point holds two 4-byte integers, so its managed size is 8 bytes. Stored as a local, an array element or a field, it doesn't need its own header or method table pointer; its data is part of the containing storage. The Position field above is embedded in the Location object's instance data, next to the object's single header.
Box the struct, though (object boxedPoint = point;), and the runtime needs an object representation, with the usual object overhead on top of the 8 bytes of data. A value type does not inherently carry an object header. A boxed representation of it does.
Measuring sizes with Unsafe.SizeOf<T>()
Unsafe.SizeOf<T>(), in System.Runtime.CompilerServices, returns the size, in bytes, of a value of type T as represented in managed memory. For reference types, it measures the reference itself, not the referenced object's allocation.
using System.Runtime.CompilerServices;
Console.WriteLine($"int: {Unsafe.SizeOf<int>()}");
Console.WriteLine($"Point: {Unsafe.SizeOf<Point>()}");
Console.WriteLine($"Mixed: {Unsafe.SizeOf<Mixed>()}");
Console.WriteLine($"object reference: {Unsafe.SizeOf<object>()}");
public struct Point
{
public int X;
public int Y;
}
public struct Mixed
{
public byte Flag;
public int Value;
}
This is an illustrative example rather than a measured result in this article. On a typical 64-bit runtime, you would expect 4, 8, 8 and 8. Mixed is the interesting one: a 1-byte field plus a 4-byte field adds up to 5 bytes, but the runtime typically inserts 3 bytes of padding so that Value is 4-byte aligned:
Mixed (typical layout)
┌──────────────────┐
│ Flag 1 byte │
├──────────────────┤
│ Padding 3 bytes │
├──────────────────┤
│ Value 4 bytes │
└──────────────────┘
Total: 8 bytes
The reference-type case is easy to misread. Unsafe.SizeOf<string>() returns 8 on a typical 64-bit runtime: that's the size of the reference, not of the string's header and character data.
Try it yourself. Run the snippet above in a console app on your machine and check the output against these expectations. Then reorder the fields of
Mixed, or add along, and see how the size changes.
Marshal.SizeOf<T>() answers a different question
Marshal.SizeOf<T>() sounds similar but reports the size of a type's unmanaged representation for interop. The clearest example is bool:
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
Console.WriteLine(Unsafe.SizeOf<bool>()); // 1
Console.WriteLine(Marshal.SizeOf<bool>()); // 4
A managed bool takes one byte. By default, the interop marshaller represents bool as a 4-byte Win32 BOOL, and that default applies on every platform. That isn't evidence that a C# Boolean takes four bytes in managed memory; it shows that marshalling rules produce a different representation. Don't use Marshal.SizeOf<T>() to measure managed sizes.
Inspecting object layout with ObjectLayoutInspector
Unsafe.SizeOf<Person>() only measures a reference. To inspect the layout of a managed object, including field offsets, padding and header overhead, you can use ObjectLayoutInspector, an open-source tool by Sergey Teplyakov.
Try it yourself. Add the
ObjectLayoutInspectorNuGet package to a console app and print the layout of this class:using ObjectLayoutInspector; TypeLayout.PrintLayout<Sample>(); public sealed class Sample { public byte Flag; public int Count; public long Timestamp; }In what order does the runtime store the fields? Where does padding appear, and how much space is overhead? Change the field types and run it again. Remember that these tools expose implementation details of the runtime you're running, not guarantees for every .NET runtime.
Does smaller always mean faster?
It's tempting to conclude that structs use less memory and are therefore faster. That's too broad.
public struct LargeValue
{
public long A, B, C, D, E, F;
}
public sealed class LargeObject
{
public long A, B, C, D, E, F;
}
LargeValue holds 48 bytes of data, and every assignment copies all of it: LargeValue second = first; copies 48 bytes. LargeObject second = first; copies only an 8-byte reference, but the class needs an object and, under ordinary execution, a heap allocation.
Which is better depends on the workload. A struct helps when it's small, frequently accessed and not boxed unnecessarily. A class fits better when identity, shared mutable state or avoiding large copies matter. Choose a struct for its semantics and measured performance, not because you believe it lives on the stack.
5. Measuring the cost of boxing with BenchmarkDotNet
Boxing an int ordinarily creates a 24-byte object. But knowing that it allocates doesn't tell us what it costs, or whether modern .NET removes the overhead. Let's measure it.
Why a stopwatch isn't enough
var stopwatch = Stopwatch.StartNew();
for (int i = 0; i < 1_000_000; i++)
{
object boxed = i;
}
stopwatch.Stop();
Console.WriteLine(stopwatch.ElapsedMilliseconds);
This looks like a benchmark, but it has several problems. The boxed values are never used, so the JIT may optimize the work away. The measurement includes JIT compilation, runtime start-up and GC noise. One run gives no statistics. And it says nothing about memory, which is exactly what makes boxing interesting.
BenchmarkDotNet and the metrics we use
BenchmarkDotNet runs warmups and repeated measurements, computes statistics and, with [MemoryDiagnoser], reports allocations.
| Metric | Meaning |
|---|---|
| Mean | Average measured execution time per operation |
| Allocated | Managed bytes allocated per operation |
| Gen0 | Generation 0 collections per 1,000 operations |
| Gen1 | Generation 1 collections per 1,000 operations |
An operation can run fast and still allocate a lot, so we shouldn't judge performance by time alone.
All runs used the same machine (Intel Core i7-6820HQ, Windows 10 22H2, BenchmarkDotNet 0.15.8), once on .NET 9.0.22 and once on .NET 10.0.14, from the same multi-targeted project. The full environment and commands are in Reproducing the experiments.
Experiment 1: boxing an integer
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser]
public class BoxingBenchmarks
{
private int _number = 42;
[Benchmark(Baseline = true)]
public int WithoutBoxing()
{
return _number;
}
[Benchmark]
public object WithBoxing()
{
return _number;
}
}
WithBoxing returns the integer as object, which requires boxing. Returning the result, rather than assigning it to an unused local, matters: BenchmarkDotNet consumes returned values, so the boxed object escapes the method and stays observable.
| Metric | .NET 9.0.22 | .NET 10.0.14 |
|---|---|---|
WithoutBoxing mean | 0.0029 ns | 0.0202 ns |
WithBoxing mean | 3.9804 ns | 3.8452 ns |
WithoutBoxing allocated | 0 B | 0 B |
WithBoxing allocated | 24 B | 24 B |
WithBoxing Gen0 | 0.0057 | 0.0057 |
Both runtimes allocated exactly 24 bytes per boxing operation, matching the boxed-integer layout from section 4. The value takes 4 bytes; its boxed representation takes six times as much, not counting the reference to it.
Timing needs more care. Boxing averaged about 3.98 ns on .NET 9 and 3.85 ns on .NET 10, a 3.4% difference from one session per runtime. That isn't enough to claim .NET 10 is faster at boxing. In fact, the EscapingBox benchmark in section 6 performs an equivalent operation, boxing an int field and returning it as object, on the same .NET 10 runtime, and measured 4.16 ns in a separate session. That's an 8% gap between two sessions of equivalent work, larger than the difference between the runtimes.
BenchmarkDotNet also reported a ZeroMeasurement warning for WithoutBoxing on both runtimes: its duration was indistinguishable from an empty method. The 0.0029 ns and 0.0202 ns figures are noise, which is also why the Ratio column shows ?. Dividing 3.98 ns by 0.0029 ns would give a dramatic and meaningless ratio.
The reliable conclusion is about allocation: boxing allocated 24 bytes, returning the integer allocated nothing. The timing is an estimate for this microbenchmark, not a universal cost of boxing.
Experiment 2: ArrayList vs. List<int>
What happens when boxing occurs thousands of times? We compare the non-generic ArrayList, which stores object references, with List<int>, which stores integers in its backing array:
using System.Collections;
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser]
public class CollectionBenchmarks
{
[Params(100, 1_000, 10_000)]
public int Count { get; set; }
[Benchmark(Baseline = true)]
public ArrayList NonGenericCollection()
{
var list = new ArrayList(Count);
for (int i = 0; i < Count; i++)
{
list.Add(i);
}
return list;
}
[Benchmark]
public List<int> GenericCollection()
{
var list = new List<int>(Count);
for (int i = 0; i < Count; i++)
{
list.Add(i);
}
return list;
}
}
Both collections are created with enough capacity, so neither resizes its backing array during insertion. The difference is how each integer is stored: boxed in ArrayList, inline in List<int>.
Execution time
| Elements | Runtime | ArrayList | List<int> | List<int> faster by |
|---|---|---|---|---|
| 100 | .NET 9 | 669.3 ns | 191.4 ns | 3.50× |
| 100 | .NET 10 | 633.4 ns | 194.0 ns | 3.27× |
| 1,000 | .NET 9 | 7,079.8 ns | 1,742.1 ns | 4.06× |
| 1,000 | .NET 10 | 6,984.9 ns | 1,749.0 ns | 3.99× |
| 10,000 | .NET 9 | 95,358.9 ns | 16,862.1 ns | 5.66× |
| 10,000 | .NET 10 | 83,564.6 ns | 16,886.2 ns | 4.95× |
List<int> was faster at every size on both runtimes, by 3.27× to 5.66×. But this benchmark doesn't measure boxing alone. It measures building a whole collection: allocating it and its backing array and inserting the elements. The difference includes boxing, but also implementation details, memory access patterns and GC activity. So these are collection-construction results, not a pure boxing cost.
Allocated memory
Allocations were identical on .NET 9 and .NET 10:
| Elements | ArrayList | List<int> | Reduction |
|---|---|---|---|
| 100 | 3,256 B | 456 B | 86.0% |
| 1,000 | 32,056 B | 4,056 B | 87.3% |
| 10,000 | 320,056 B | 40,056 B | 87.5% |
At 10,000 elements, ArrayList allocated 280,000 bytes more than List<int>, about 273.4 KiB of extra allocation for one collection.
These numbers can be explained byte for byte. Per element, ArrayList needs an 8-byte reference slot in its object[] plus a 24-byte boxed integer: 32 bytes. List<int> needs 4 bytes in its int[]. That's a 28-byte difference per element, which gives exactly 2,800 B, 28,000 B and 280,000 B for 100, 1,000 and 10,000 elements.
The fixed part fits too, if we assume a 24-byte array header and a 32-byte collection object. These two sizes are assumptions: they're consistent with typical 64-bit CoreCLR layouts and with the measured totals, but we didn't measure the object layouts independently.
| Collection object | Backing array | Boxed integers | Total for n elements | |
|---|---|---|---|---|
ArrayList | 32 B | 24 + 8n B | 24n B | 56 + 32n B |
List<int> | 32 B | 24 + 4n B | none | 56 + 4n B |
With those assumptions, the formulas reproduce all six measured values exactly for n = 100, 1,000 and 10,000. The extra memory ArrayList allocates grows linearly with the number of boxed integers, and it follows directly from the element representations.
Garbage collection
Allocating doesn't trigger a collection immediately, and the .NET GC handles short-lived objects efficiently. But more allocation means more collection work eventually. On .NET 10:
| Elements | Collection | Gen0 | Gen1 |
|---|---|---|---|
| 100 | ArrayList | 0.7782 | – |
| 100 | List<int> | 0.1090 | – |
| 1,000 | ArrayList | 7.6599 | 0.7629 |
| 1,000 | List<int> | 0.9689 | – |
| 10,000 | ArrayList | 65.1855 | 21.7285 |
| 10,000 | List<int> | 9.5215 | – |
The .NET 9 figures were the same except for a Gen1 count of 21.8506 at 10,000 elements. These are collections per 1,000 operations, not per operation. The pattern is clear: ArrayList caused far more Gen0 collections and measurable Gen1 activity, while List<int> caused no Gen1 collections in these runs.
In a service handling many requests, unnecessary allocations in hot paths add up to higher allocation rates and more GC work, which costs CPU and can affect latency. How much it matters depends on allocation frequency, object lifetimes, overall memory pressure and the application's latency requirements.
Did .NET 10 improve boxing?
| Benchmark | .NET 9 | .NET 10 | Observed change |
|---|---|---|---|
| Boxing an integer | 3.9804 ns | 3.8452 ns | −3.4% |
ArrayList, 100 | 669.3 ns | 633.4 ns | −5.4% |
List<int>, 100 | 191.4 ns | 194.0 ns | +1.4% |
ArrayList, 1,000 | 7,079.8 ns | 6,984.9 ns | −1.3% |
List<int>, 1,000 | 1,742.1 ns | 1,749.0 ns | +0.4% |
ArrayList, 10,000 | 95,358.9 ns | 83,564.6 ns | −12.4% |
List<int>, 10,000 | 16,862.1 ns | 16,886.2 ns | +0.1% |
The largest change was the 10,000-element ArrayList, from about 95.4 µs to 83.6 µs. Does that mean .NET 10 introduced a boxing optimization? Not necessarily. BenchmarkDotNet flagged that .NET 9 measurement as bimodal, with a standard deviation of 9,635 ns, about 10% of its mean. We ran one session per runtime, and we've already seen an 8% gap between two sessions of equivalent work. A real runtime-level improvement would need repeated sessions, a controlled environment and probably a look at the generated code.
Most importantly, the allocations didn't change. Both runtimes allocated 24 bytes per escaping boxed integer and identical amounts for the collections. These experiments show that escaping boxed integers kept allocating on both runtimes. They don't show .NET 10 removing those allocations.
What this means for real applications
One boxed integer allocates 24 bytes. Repeated a million times, that's 24 million bytes, about 22.9 MiB of managed allocation, before counting anything else the code allocates.
That doesn't mean avoiding boxing at all costs. A boxing operation at startup doesn't deserve attention; one repeated millions of times in a hot path might. The goal isn't to eliminate every allocation but to find the unnecessary ones that have a measurable effect. Generic collections, strongly typed APIs and constrained generic methods often keep values unboxed, but the choice should follow the measured workload.
So far, every boxed object we measured escaped its method. That raises the next question: if a program contains a boxing instruction, must the JIT always create an object?
6. The modern .NET twist: when the box disappears
C# code, IL and machine code are different layers
C# source code
│ Roslyn (C# compiler)
▼
IL (Common Intermediate Language)
│ RyuJIT (at run time)
▼
Native machine code
│
▼
CPU execution
Roslyn translates C# into IL, which describes operations such as creating objects, loading values, calling methods and converting types. The JIT then translates IL into machine code. The JIT doesn't have to preserve every IL operation literally. It has to preserve the program's observable behavior, which lets it remove unnecessary instructions, propagate constants, inline methods and sometimes eliminate allocations. We already saw Calculate() collapse to return 30. The same principle can apply to objects: an allocation described by IL is not necessarily an allocation in the final machine code.
A boxing operation that might disappear
public static int BoxAndUnbox(int value)
{
object boxed = value;
return (int)boxed;
}
ldarg.0
box [System.Runtime]System.Int32
unbox.any [System.Runtime]System.Int32
ret
Read literally, the IL allocates a box and unboxes it. But the boxed reference is never returned, stored in a field, passed to another method or observed by other code. The method returns the original integer. An optimizing compiler could therefore turn it into return value; with no object at all. A box instruction in IL doesn't, by itself, prove that an allocation happens in the optimized machine code. Whether it does depends on the runtime version and the surrounding code, so we need to inspect the output before claiming anything.
Escape analysis
To reason about allocations, a compiler asks: can a reference to this object escape the scope it's analyzing?
// The object escapes: the caller receives it.
public static object CreateBox(int value)
{
object boxed = value;
return boxed;
}
// The object does not escape: the reference stays local.
public static int ReadBoxedValue(int value)
{
object boxed = value;
return (int)boxed;
}
In CreateBox, the caller can keep the reference, inspect its type, compare its identity or store it, so within this method alone, the object must stay valid after it returns. Escaping a method doesn't, by itself, prove that an allocation must happen, though: after inlining, a JIT can sometimes analyze the caller as well. Our escaping benchmarks return the box to BenchmarkDotNet's consumer, and under the tested conditions they allocated 24 bytes.
In ReadBoxedValue, no reference leaves the method, which gives the JIT an opportunity. But not escaping is not a guarantee: the compiler must also understand every operation on the object and prove the transformation preserves behavior. Identity, synchronization, calls the JIT can't see through, exceptions and other observable effects can all block it. Escape analysis is a tool for reasoning about lifetimes and optimization opportunities, not a promise.
Stack allocation and allocation elimination are different things
When C# says new, a compiler can implement it in conceptually different ways:
| Mechanism | What happens | Where the object lives |
|---|---|---|
| Heap allocation | A managed object is allocated | Managed heap |
| Stack allocation | The object's storage is placed in the method's stack frame | Stack |
| Allocation elimination | The optimizer proves the object is unnecessary and removes it | Nowhere: no object exists |
Consider a class this time:
public sealed class PointObject
{
public PointObject(int x, int y)
{
X = x;
Y = y;
}
public int X { get; }
public int Y { get; }
}
public static int Compute()
{
var point = new PointObject(10, 20);
return point.X + point.Y;
}
A capable optimizer could replace the object with its individual field values and compute the result directly, a technique related to scalar replacement. This example illustrates what's possible, not what a given runtime does. And if an allocation is eliminated entirely, saying the object "moved to the stack" is wrong: there may be no object at all.
The two mechanisms can also be stages of the same optimization. A JIT can first place a non-escaping object on the stack and then, because its fields are now ordinary locals, optimize those fields further until nothing of the object remains in the final code.
What modern .NET actually does
This isn't hypothetical. According to Microsoft's runtime documentation:
- .NET 9: "Starting in .NET 9, the 64-bit compiler allocates unescaped boxes on the stack, which unlocks several other optimizations." In Microsoft's example, a method that boxed two integers and compared them was reduced to
mov eax, 100; ret: no allocations remained (What's new in the .NET 9 runtime). - .NET 10: escape analysis was extended. The JIT can stack-allocate small fixed-size arrays of value types and of reference types, delegates that don't escape, and objects referenced by fields of non-escaping local structs. Closures were still heap-allocated at the time of that release (What's new in the .NET 10 runtime).
Both come with conditions. They apply to objects the JIT can prove don't escape, typically after inlining, and Microsoft describes them in terms of "certain" patterns. They don't mean every non-escaping box or object is stack-allocated, and the C# language promises nothing about it.
stackalloc: explicit stack allocation
C# does have an explicit stack-allocation construct:
public static int Sum()
{
Span<int> numbers = stackalloc int[4];
numbers[0] = 10;
numbers[1] = 20;
numbers[2] = 30;
numbers[3] = 40;
int total = 0;
foreach (int number in numbers)
{
total += number;
}
return total;
}
stackalloc requests a block of stack memory, accessed here through a Span<int>, without creating a managed array object. It has restrictions: it's meant for suitable element types such as unmanaged values, and the allocated storage is tied to the current stack frame, so it can't safely escape that frame's lifetime. Every execution of stackalloc consumes stack space, so large or repeated stack allocations, such as stackalloc inside a loop, can exhaust the available stack. It's an explicit tool for small temporary buffers, not evidence that all value types live on the stack.
ref struct and Span<T>
public ref struct BufferReader
{
private Span<byte> _buffer;
public BufferReader(Span<byte> buffer)
{
_buffer = buffer;
}
public byte ReadFirst() => _buffer[0];
}
A ref struct is a byref-like value type. Its restrictions stop it from escaping to the heap, for example by being boxed or stored in a field of a class, so it can safely hold references to stack memory. Span<T> and ReadOnlySpan<T> are the best-known examples.
A ref struct is not a command to allocate on the stack; it's a type with lifetime restrictions. And a Span<T> doesn't necessarily point to stack memory:
int[] values = { 10, 20, 30 };
Span<int> span = values;
This span is a view over the elements of an ordinary heap-allocated array. Spans can refer to arrays, stack memory or other contiguous memory, which is one more reason not to equate value types with the stack.
Experiment 3: what RyuJIT actually generates
Our section 5 benchmarks only tested boxes that escape. To see whether RyuJIT removes a box that doesn't, we ran three benchmarks on .NET 10.0.14 with BenchmarkDotNet's MemoryDiagnoser and DisassemblyDiagnoser:
[MemoryDiagnoser]
[DisassemblyDiagnoser(
maxDepth: 2,
printSource: true,
printInstructionAddresses: false)]
public class JitOptimizationBenchmarks
{
private int _value = 42;
[Benchmark]
public int DirectReturn()
{
return _value;
}
[Benchmark]
public int BoxAndUnbox()
{
object boxed = _value;
return (int)boxed;
}
[Benchmark]
public object EscapingBox()
{
return _value;
}
}
| Method | Mean | Allocated | Native code size |
|---|---|---|---|
DirectReturn | 0.0060 ns | 0 B | 4 B |
BoxAndUnbox | 0.0406 ns | 0 B | 4 B |
EscapingBox | 4.1574 ns | 24 B | 35 B |
BoxAndUnbox boxes and unboxes in its source, yet BenchmarkDotNet reported zero allocation. Allocation numbers alone don't show what the JIT generated, so let's read the native code. Here is what RyuJIT emitted for each method, from the DisassemblyDiagnoser output (x64, .NET 10.0.14).
DirectReturn:
mov eax,[rcx+8]
ret
; Total bytes of code 4
It loads _value from the benchmark instance (rcx holds this, and the field is at offset 8) and returns it.
BoxAndUnbox:
mov eax,[rcx+8]
ret
; Total bytes of code 4
These are exactly the same instructions as DirectReturn. There's no allocation helper, no copy into a box and no type check. Combined with the zero-allocation measurement, this shows that the boxing operation does not survive in the generated machine code.
EscapingBox:
push rbx
sub rsp,20
mov rbx,rcx
mov rcx,offset MT_System.Int32
call CORINFO_HELP_NEWSFAST
mov ecx,[rbx+8]
mov [rax+8],ecx
add rsp,20
pop rbx
ret
; Total bytes of code 35
In this benchmark, the boxed object is returned to BenchmarkDotNet's consumer, which makes the allocation observable and, under the tested conditions, prevents the JIT from eliminating it. The code loads the method table of System.Int32, calls the runtime allocation helper CORINFO_HELP_NEWSFAST, then copies _value into the new object at offset 8, right after its method table pointer. BenchmarkDotNet measured the matching 24 bytes per call, and the code grew from 4 to 35 bytes.
Interpreting the results
BenchmarkDotNet reported ZeroMeasurement warnings for DirectReturn and BoxAndUnbox, so their means can't be used for a performance ratio. The evidence comes from four facts together:
DirectReturnandBoxAndUnboxcompiled to identical machine instructions.- Both allocated zero bytes.
EscapingBoxcontains an explicit allocation-helper call.EscapingBoxallocated 24 bytes per call.
What this establishes, and what it doesn't:
- Established: on .NET 10.0.14, the unnecessary box in
BoxAndUnboxdoes not exist in the final native code. Nothing is allocated, on the heap or on the stack. - Established: when the box is returned to the benchmark's consumer, the allocation remains under the tested conditions.
- Not established: how RyuJIT got there. The disassembly shows the result, not the intermediate steps. Since .NET 9, the 64-bit JIT can stack-allocate non-escaping boxes and then optimize further, so it may have treated the box as a stack allocation before removing it entirely. Our output can't tell us either way.
- Not established: that this optimization is new in .NET 10. We disassembled this experiment on .NET 10 only.
- Not established: that every non-escaping box, or every non-escaping object, is optimized away or stack-allocated.
Try it yourself. Clone the benchmark repository and run the JIT experiment with
-f net9.0to compare the .NET 9 disassembly with the .NET 10 results above.
Two experiments, two questions
Section 5's boxing benchmark returned the box, so it escaped, and both runtimes allocated 24 bytes. That shows the allocation remained when the box was observable to the caller, under the tested conditions. It doesn't show that every box instruction allocates, and it never tested a non-escaping box. Experiment 3 did, and the box vanished from the native code.
A benchmark designed to make an allocation observable measures its cost. A benchmark designed to give the optimizer an opportunity tests whether it takes it. They answer different questions, and confusing the two is how myths start.
7. Conclusion: a better mental model
We started with "value types live on the stack, reference types live on the heap." It's simple, memorable and misleading. Here is what the evidence supports instead.
Three layers, not one rule
- The C# type system defines value and reference semantics: what gets copied on assignment.
- The runtime's storage model decides how values and objects are represented: inline in an object or array, in a stack slot or register, or in a boxed object.
- The JIT compiler decides which operations survive into machine code, and can remove allocations that aren't needed to preserve behavior.
The stack myth comes from collapsing those three layers into one rule.
Myths and more accurate statements
| Common misconception | More accurate explanation |
|---|---|
| Value types always live on the stack. | Value-type data is stored inline in its containing context: heap objects, arrays, stack storage or registers. |
| Reference types always mean a heap allocation. | Objects are generally heap-allocated when an object is required, but the JIT can stack-allocate or eliminate some that don't escape. |
| Every local variable occupies stack memory. | The JIT may use registers, stack slots, or no storage at all. |
| A struct field inside a class lives on the stack. | It is stored inline in the containing object. |
| Boxing is just a conversion with no memory cost. | An escaping box allocated 24 bytes in our measurements and adds GC pressure. |
Every IL box instruction creates a heap object. | In our .NET 10 experiment, a non-escaping box left no trace in the native code. |
| Zero allocation means the object was stack-allocated. | The object may have been eliminated completely. |
stackalloc explains where value types live. | stackalloc is an explicit tool for temporary buffers, not a rule about structs. |
A practical checklist
When you investigate memory or performance in C#, ask:
- What are the type's semantics? What happens when it's assigned or passed to a method?
- What contains the data? A local, an array element, an instance field, a boxed object?
- Does the operation require an allocation? Are objects created, values boxed, collections grown?
- Can the JIT optimize it? Is the object observable outside the local computation?
- What do the measurements show? Use allocation diagnostics, profiling and disassembly instead of source-level assumptions.
A struct isn't automatically faster than a class, a class isn't automatically inefficient, and avoiding every allocation isn't the goal. The right choice depends on semantics, mutability, size, copying, identity, allocation patterns and the application's real requirements.
Final conclusion
A value type can be stored inside a heap object. A local value can exist entirely in a register. A boxing conversion can allocate 24 bytes, or, in a case the JIT can optimize like the one we measured, leave nothing behind at all.
So the right question isn't "does this type live on the stack or the heap?" It's: what semantics does this type have, what storage does its context require, and which operations survive JIT optimization?
The stack and the heap are implementation details of storage and lifetime. Value types and reference types are concepts of the type system. Keeping them apart is the first step toward understanding how C# and .NET actually run our code, and it's the standard the rest of this series will hold to.
Reproducing the experiments
The benchmark project, its source code and the generated reports are public: github.com/Ayoub-EDAHLOULI/DotNetBoxingBenchmarks. The project targets both net9.0 and net10.0, so you need the .NET 10 SDK with the .NET 9 runtime installed.
| Component | Configuration |
|---|---|
| Operating system | Windows 10 22H2 (10.0.19045), x64 |
| Processor | Intel Core i7-6820HQ, 2.70 GHz (Skylake) |
| Cores | 4 physical, 8 logical |
| .NET SDK | 10.0.403 |
| Runtimes | .NET 9.0.22 and .NET 10.0.14, X64 RyuJIT (x86-64-v3) |
| BenchmarkDotNet | 0.15.8 |
| Garbage collector | Concurrent Workstation |
| Build | Release |
git clone https://github.com/Ayoub-EDAHLOULI/DotNetBoxingBenchmarks.git
cd DotNetBoxingBenchmarks
# All benchmarks, once per runtime
dotnet run -c Release -f net9.0 -- --filter "*"
dotnet run -c Release -f net10.0 -- --filter "*"
# Only the JIT experiment (section 6)
dotnet run -c Release -f net10.0 -- --filter "*JitOptimizationBenchmarks*"
The double quotes work in Bash, PowerShell and the Windows command prompt, and stop the shell from expanding * itself.
Experiments 1 and 2 ran once per runtime; experiment 3, with disassembly, ran on .NET 10 only. BenchmarkDotNet writes its CSV, HTML and Markdown exports to BenchmarkDotNet.Artifacts/results and overwrites them on every run, so the exports from two runtimes don't sit side by side. The .NET 9 figures in this article therefore come from the log of the .NET 9 run. The repository's results folder keeps the outputs by runtime: net9.0/ holds that log, and net10.0/ holds the .NET 10 exports, the disassembly and both .NET 10 logs. If you compare runtimes yourself, keep each run's BenchmarkDotNet.Artifacts/BenchmarkRun-*.log or copy the exports between runs.
The snippets in this article show type declarations before the code that uses them, for readability. In a console app using top-level statements, C# requires the statements to come first, so move the type declarations below them or into separate files. The Unsafe.SizeOf and ObjectLayoutInspector examples are already ordered that way.
Results vary with hardware, operating system, runtime version, JIT decisions and background activity, so expect different absolute timings on your machine. Allocation sizes are far more stable than timings, which is why the conclusions here rest mainly on allocation diagnostics and generated machine code. Methods that are too short trigger BenchmarkDotNet's ZeroMeasurement warning; don't compute ratios from them.
A note on technical accuracy
The claims in this article sit at three levels, and it's worth keeping them apart:
- Language guarantees come from the C# specification: the semantics of value types, reference types, assignment and conversions.
- Runtime implementation details describe how a particular .NET runtime represents values and objects. Object layouts, allocation strategies and JIT optimizations differ across implementations and versions.
- Experimental observations describe what happened in our benchmark environment. They're concrete evidence, but they shouldn't be generalized beyond what they measure.
A specification tells us what a program means. A runtime decides how that meaning is realized. A benchmark and its disassembly show what actually happened on one machine. If you spot a mistake or get different results, tell me: learning in public only works if the corrections are public too.
References and further reading
C# language specification
- C# specification: Types: §8.2 Reference types, §8.3 Value types, §8.3.13 Boxing and unboxing.
- C# specification: Conversions: §10.2.9 Boxing conversions, §10.3.7 Unboxing conversions.
Memory, boxing and the runtime
- Eric Lippert, The Stack Is An Implementation Detail, Part One: the classic explanation of why the stack/heap rule is the wrong way to think about value types.
- Type conversions, casting and boxing (Microsoft Learn).
- Fundamentals of garbage collection (Microsoft Learn).
- System.ValueType (API reference).
- What's new in the .NET 9 runtime: object stack allocation for boxes.
- What's new in the .NET 10 runtime: escape analysis and stack allocation of arrays and delegates.
- Object stack allocation, design document in the dotnet/runtime repository.
Stack allocation, spans and byref-like types
IL and the JIT
- ECMA-335: Common Language Infrastructure: the IL instruction set (
box,unbox,unbox.any,constrained.) and execution model. - dotnet/runtime, including the RyuJIT source. Implementations evolve, so tie any conclusion to a specific runtime version.
Tools
- BenchmarkDotNet and its diagnosers documentation (
MemoryDiagnoser,DisassemblyDiagnoser). - BenchmarkDotNet disassembler documentation.
- SharpLab: view the C#, IL and JIT assembly generated for a snippet.
- ObjectLayoutInspector: inspect field offsets, padding and object overhead.
- DotNetBoxingBenchmarks: the benchmarks and raw results for this article.
Thoughts on this post?
Reply by email — I read everything.

Discussion
Comments No comments yet
No comments yet. Start the conversation.