Boxing in C# happens when a value (int
, struct
, etc.) is converted to an object
reference or an interface reference. This wraps the value in a heap-allocated object, which adds overhead. In performance-critical code, boxing can slow execution both due to the extra allocation and the increased work for the garbage collector.
Boxing has been part of .NET since its inception two decades ago. In this post, we’ll start by introducing boxing and its performance cost, then cover more modern and lesser-known topics such as JIT escape analysis, tooling for detecting boxing, ref struct and the .NET generics math library.
Boxing and Unboxing
Here’s a sample program demonstrating the two main scenarios where C# performs boxing and unboxing:
-
When a value is boxed into an
object
reference. -
When a value is boxed by casting it to an interface that its value type implements.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
// object reference scenario int i = 1; object obj = i; // Boxing i++; int j = (int)obj; // Unboxing Console.WriteLine(i); // Outputs: 2 Console.WriteLine(j); // Outputs: 1 // interface reference scenario MyStruct value1 = new MyStruct(1); IDisposable interfaceRef = value1; // Boxing value1.Value++; MyStruct value2 = (MyStruct)interfaceRef; // Unboxing Console.WriteLine(value1.Value); // Outputs: 2 Console.WriteLine(value2.Value); // Outputs: 1 // Just use IDisposable as a random interface for illustration purposes // Boxing and disposing are two different topics struct MyStruct : IDisposable { public int Value; public MyStruct(int value) { Value = value; } public void Dispose() { } } |
Here is the decompiled IL code illustrating the use of the box
and unbox.any
instructions:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
IL_0000: ldc.i4.1 IL_0001: stloc.0 IL_0002: ldloc.0 IL_0003: box [System.Runtime]System.Int32 // <-- box: object scenario IL_0008: stloc.1 IL_0009: ldloc.0 IL_000a: ldc.i4.1 IL_000b: add IL_000c: stloc.0 IL_000d: ldloc.1 IL_000e: unbox.any [System.Runtime]System.Int32 // <-- unbox: object scenario IL_0013: ldloc.0 IL_0014: call void [System.Console]System.Console::WriteLine(int32) IL_0019: call void [System.Console]System.Console::WriteLine(int32) IL_001e: ldloca.s 2 IL_0020: ldc.i4.1 IL_0021: call instance void MyStruct::.ctor(int32) IL_0026: ldloc.2 IL_0027: box MyStruct // <-- box: interface scenario IL_002c: ldloca.s 2 IL_002e: ldflda int32 MyStruct::Value IL_0033: dup IL_0034: ldind.i4 IL_0035: ldc.i4.1 IL_0036: add IL_0037: stind.i4 IL_0038: unbox.any MyStruct // <-- unbox: interface scenario IL_003d: ldloc.2 IL_003e: ldfld int32 MyStruct::Value IL_0043: call void [System.Console]System.Console::WriteLine(int32) IL_0048: ldfld int32 MyStruct::Value IL_004d: call void [System.Console]System.Console::WriteLine(int32) |
The Cost of Boxing
Using Benchmark.NET we can asses the cost of boxing:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
using BenchmarkDotNet.Running; using BenchmarkDotNet.Attributes; using System.Runtime.CompilerServices; BenchmarkRunner.Run<BoxingPerformanceBenchmark>(); Console.ReadKey(); [MemoryDiagnoser] // Use this attribute to track heap allocation public class BoxingPerformanceBenchmark { private int _intValue = 691; // Avoid inline JIT optimisation on benchmark methods [MethodImpl(MethodImplOptions.NoInlining)] [Benchmark] public int NoBoxing() { // Returns the integer directly // The value stays on the stack, no boxing involved return _intValue; } [MethodImpl(MethodImplOptions.NoInlining)] [Benchmark] public object BoxedToHeap() { // Returns the integer boxed as an object allocated on the heap return _intValue; } [MethodImpl(MethodImplOptions.NoInlining)] [Benchmark] public int BoxedToStack() { // The integer is temporarily boxed into an object reference. // Since the boxed object doesn’t escape the method, // the JIT can keep it on the stack // instead of allocating it on the heap (escape analysis optimization). object obj = _intValue; return (int)obj; } } |
Here are the results:
1 2 3 4 5 |
| Method | Mean | Error | StdDev | Median | Gen0 | Allocated | |------------- |----------:|----------:|----------:|----------:|-------:|----------:| | NoBoxing | 0.0134 ns | 0.0184 ns | 0.0225 ns | 0.0000 ns | - | - | | BoxedToHeap | 3.9643 ns | 0.1414 ns | 0.1736 ns | 3.9199 ns | 0.0038 | 24 B | | BoxedToStack | 0.0228 ns | 0.0130 ns | 0.0217 ns | 0.0000 ns | - | - | |
BoxedToHeap()
shows that boxing isn’t free—it consumes CPU cycles and allocates memory on the heap.
On the other hand BoxedToStack()
is almost as fast as NoBoxing()
despite a boxing and unboxing operation. This is because since .NET 9 the JIT is smart enough to detect that the reference doesn’t escape the method scope. As a consequence it can allocate the wrapping object on the stack, as if it was a value type. This feature is name Object Stack Allocation. It results from JIT Escape Analysis Optimization and this is explained by Stephan Toub here.
Identifying Where Boxing and Unboxing Occur in Your Code
Although boxing occurs at runtime, the C# compiler dictates it by emitting specific IL instructions. This means you don’t need to run the code to detect it—it can be identified through static analysis. NDepend is a .NET static analyzer and it can detect the usage of boxing and unboxing with such code rule:
1 2 3 4 5 |
// <Name>Boxing Detection</Name> warnif count > 0 from m in Application.Methods where m.IsUsingBoxing || m.IsUsingUnboxing select new { m, m.IsUsingBoxing, m.IsUsingUnboxing } |
Getting Rid of Boxing and Unboxing with Generics
The good news is that boxing and unboxing can often be avoided in C# thanks to .NET generics, which preserve value types at compile time. In contrast, Java’s generics use type erasure, so value types are still boxed when stored in generic collections.
For example, in C# a List<int>
stores values directly, while in Java a List<Integer>
stores boxed integers. Since generics were introduced in .NET 2.0, they have given the .NET platform a performance advantage over Java in handling value types.
A common scenario where boxing and unboxing occurs unintentionally is when passing or returning value types through object reference as method parameters:
1 2 3 4 5 6 7 8 9 10 11 |
// Boxing occurs because value type is passed as object object Increment(object value) { return value switch { int i => (object)(i + 1), double d => d + 1, _ => throw new ArgumentException("Unsupported type", nameof(value)) }; } int i = (int)Increment(5); double d = (double)Increment(5.1d); |
The method above may look awkward. Unfortunately it’s not uncommon to see object
used where a more specific type would be appropriate—consider, for example, Equals<object>(object other)
.
Thanks to the .NET Generics math library, this Increment(object):object
method can be rewritten this way:
1 2 3 4 5 6 |
T Increment<T>(T value) where T : System.Numerics.INumber<T> { return value + T.One; // no boxing } int i = Increment(5); double d = Increment(5.1d); |
Although all primitive numeric types (int
, double
, byte
, etc.) implement the INumber<TSelf>
interface, constraining a generic type to INumber<TSelf>
does not generate any boxing or unboxing IL instructions. This works because, at runtime, an optimized version of Increment<T>()
is generated for each value type T
it’s called with. For reference types T
, the JIT can often share the same compiled code. This is a remarkable design feature of .NET that you should be aware of.
When I first explored the .NET generics math library, my reaction was: “What? Primitive types now implement plenty of interfaces—INumber<TSelf>
alone extends 18 interfaces!” Does this hurt performance? Not at all—generics in .NET are carefully designed to avoid any boxing and any overhead.
Interfaces on structs matter—and using them wisely can even improve your application’s performance.
Preventing Boxing at compile-time with ref struct
To implement Span<T>
, C# introduced the concept of a ref struct
. A ref struct
is a struct that the compiler ensures will never be boxed or placed in an object field. This restriction exists because Span<T>
holds internally something special through a ref field, a managed pointer. A managed pointer is a high-performance reference that must remain on the stack to be safely tracked by the garbage collector. Managed pointers are awesome because they can track any kind of memory location: Managed pointers are powerful because they can reference any exotic memory location: a field inside an object, an element in a collection, or a character at a specific index in a string.
Managed pointers have existed since the early days of .NET with the by ref
parameter, but they gained new prominence with Span<T>
and ref struct
. This is why we use the ref
keyword in ref struct
, ref field
, and ref return
—even though it has nothing to do with a regular reference type. This blog post explains it all: Managed pointers, Span, ref struct, C#11 ref fields and the scoped keyword
As a result, using a ref struct
forces the compiler to ensure its values are never boxed because they cannot live on the heap.
Nevertheless keep in mind that ref struct
comes with the limitation that they cannot be stored in object fields.