Collections Without the Boxing
In last week’s tips for using collections like List<T>, we saw how struct types are sometimes boxed resulting in GC allocations. This week we’ll see how to avoid boxing and learn some of the clever tricks that .NET collection types use to make this possible.
The Problem
Last week we saw an example of how List<T> and Dictionary<TKey, TValue> will box struct types when performing common operations such as calling the Contains method:
struct IntStruct { public int Value; } bool TestList(List<IntStruct> list, IntStruct s) { return list.Contains(s); // Boxing inside the Contains method } int TestDictionary(Dictionary<IntStruct, int> dict, IntStruct s) { return dict[s]; // Boxing inside the indexer }
This resulted in 60 bytes of GC allocation for Dictionary and 80 bytes for List:

We’d like to avoid these GC allocations for all the usual reasons such as the performance spikes that a GC collect can cause.
Inside Collections
To see how to avoid boxing, we need to understand how methods like List<T>.Contains work. To do that, we can decompile the mscorlib.dll that Unity puts in the PROJECTNAME_OS_BackUpThisFolder_ButDontShipItWithYourGame directory when we do a build. Free tools such as ILSpy and dotPeek make this easy. Here’s what ILSpy shows for List<T>.Contains:
public bool Contains(T item) { if (item == null) { for (int i = 0; i < _size; i++) { if (_items[i] == null) { return true; } } return false; } EqualityComparer<T> @default = EqualityComparer<T>.Default; for (int j = 0; j < _size; j++) { if (@default.Equals(_items[j], item)) { return true; } } return false; }
Our IntStruct type can’t be null, so we’re really just looking at the second part of the function. Let’s start by looking at the Equals method:
public abstract bool Equals(T x, T y);
EqualityComparer is an abstract class, so Equals is implemented in derived classes. To see those, let’s look at the EqualityComparer<T>.Default property:
[Serializable] [TypeDependency("System.Collections.Generic.ObjectEqualityComparer`1")] public abstract class EqualityComparer<T> : IEqualityComparer, IEqualityComparer<T> { private static volatile EqualityComparer<T> defaultComparer; public static EqualityComparer<T> Default { get { EqualityComparer<T> equalityComparer = defaultComparer; if (equalityComparer == null) { equalityComparer = (defaultComparer = CreateComparer()); } return equalityComparer; } } }
It turns out the Default property uses the defaultComparer field as a cached value. The first time the property is called, it creates this field with CreateComparer so let’s look at that method:
private static EqualityComparer<T> CreateComparer() { RuntimeType runtimeType = (RuntimeType)typeof(T); if (runtimeType == typeof(byte)) { return (EqualityComparer<T>)new ByteEqualityComparer(); } if (runtimeType == typeof(string)) { return (EqualityComparer<T>)new InternalStringComparer(); } if (typeof(IEquatable<T>).IsAssignableFrom(runtimeType)) { return (EqualityComparer<T>)RuntimeType.CreateInstanceForAnotherGenericParameter(typeof(GenericEqualityComparer<>), runtimeType); } if (runtimeType.IsGenericType && runtimeType.GetGenericTypeDefinition() == typeof(Nullable<>)) { RuntimeType runtimeType2 = (RuntimeType)runtimeType.GetGenericArguments()[0]; if (typeof(IEquatable<>).MakeGenericType(runtimeType2).IsAssignableFrom(runtimeType2)) { return (EqualityComparer<T>)RuntimeType.CreateInstanceForAnotherGenericParameter(typeof(NullableEqualityComparer<>), runtimeType2); } } if (runtimeType.IsEnum) { switch (Type.GetTypeCode(Enum.GetUnderlyingType(runtimeType))) { case TypeCode.Int16: return (EqualityComparer<T>)RuntimeType.CreateInstanceForAnotherGenericParameter(typeof(ShortEnumEqualityComparer<>), runtimeType); case TypeCode.SByte: return (EqualityComparer<T>)RuntimeType.CreateInstanceForAnotherGenericParameter(typeof(SByteEnumEqualityComparer<>), runtimeType); case TypeCode.Byte: case TypeCode.UInt16: case TypeCode.Int32: case TypeCode.UInt32: return (EqualityComparer<T>)RuntimeType.CreateInstanceForAnotherGenericParameter(typeof(EnumEqualityComparer<>), runtimeType); case TypeCode.Int64: case TypeCode.UInt64: return (EqualityComparer<T>)RuntimeType.CreateInstanceForAnotherGenericParameter(typeof(LongEnumEqualityComparer<>), runtimeType); } } return new ObjectEqualityComparer<T>(); }
Here we see a lot of reflection starting with typeof(T) and proceeding to various calls such as IsAssignableFrom. This method has special cases for built-in types like int and string as well as core .NET concepts like Nullable<T>. When none of the special cases matches, it falls back to the most general tool: ObjectEqualityComparer. This is what we saw in the profiler that resulted in GC allocations. Why? Let’s look at its Equals to find out:
public override bool Equals(T x, T y) { if (x != null) { if (y != null) { return x.Equals(y); } return false; } if (y != null) { return false; } return true; }
The important line here is x.Equals(y) which calls the object.Equals method. This requires x to be boxed to object which results in a GC allocation.
Workaround
Now that we’ve seen that we’re falling into the most general case and using ObjectEqualityComparer, let’s revisit CreateComparer and see if there is a special case that would allow us to avoid the boxing. First, we can ignore all the types that don’t fit our IntStruct such as int, string, and Nullable<T>. That leaves us with the possibility of IEquatable<T>:
if (typeof(IEquatable<T>).IsAssignableFrom(runtimeType)) { return (EqualityComparer<T>)RuntimeType.CreateInstanceForAnotherGenericParameter(typeof(GenericEqualityComparer<>), runtimeType); }
Our type currently doesn’t implement this interface, but we could easily change that:
struct IntStruct : IEquatable<IntStruct> { public int Value; public bool Equals(IntStruct other) { return Value == other.Value; } }
With this in place, CreateComparer will now call RuntimeType.CreateInstanceForAnotherGenericParameter. Let’s take a quick look at it:
internal static object CreateInstanceForAnotherGenericParameter(Type genericType, RuntimeType genericArgument) { return ((RuntimeType)MakeGenericType(genericType, new Type[1] { genericArgument })).GetDefaultConstructor().InternalInvoke(null, null); } [MethodImpl(4096)] private static extern Type MakeGenericType(Type gt, Type[] types);
This begins with a call to MakeGenericType, but that’s implemented in native code so we’ll skip it. Then there are calls to GetDefaultConstructor and InternalInvoke to invoke the constructor. The result is an object which CreateComparer casts to an EqualityComparer<T>. That cast makes sense since the Type being created is GenericEqualityComparer<> which derives from EqualityComparer<T>:
[Serializable] internal class GenericEqualityComparer<T> : EqualityComparer<T> where T : IEquatable<T> { public override bool Equals(T x, T y) { if (x != null) { if (y != null) { return x.Equals(y); } return false; } if (y != null) { return false; } return true; } }
Finally, we come to the implementation of Equals that we want. This one also uses x.Equals(y) but there is a where T : IEquatable<T> constraint which means the compiler will choose IEquatable<T>.Equals(T) instead of object.Equals(object). Since there’s no need to have an object, there’s no need to box the IntStruct and no GC allocation!
Dictionary
With our call to List<T>.Contains fixed, we can turn our attention to the Dictionary<TKey, TValue> indexer. Here’s how it looks:
public TValue this[TKey key] { get { int num = FindEntry(key); if (num >= 0) { return entries[num].value; } throw new KeyNotFoundException(); } }
FindKey does all the work, so let’s see it:
private int FindEntry(TKey key) { if (key == null) { throw new ArgumentNullException("key"); } if (buckets != null) { int num = comparer.GetHashCode(key) & 0x7FFFFFFF; for (int num2 = buckets[num % buckets.Length]; num2 >= 0; num2 = entries[num2].next) { if (entries[num2].hashCode == num && comparer.Equals(entries[num2].key, key)) { return num2; } } } return -1; }
This contains two relevant calls: comparer.GetHashCode and comparer.Equals. Both use comparer, which is a field:
private IEqualityComparer<TKey> comparer;
It’s assigned in the constructor:
public Dictionary(int capacity, IEqualityComparer<TKey> comparer) { if (capacity < 0) { throw new ArgumentOutOfRangeException("capacity", capacity, "Non-negative number required."); } if (capacity > 0) { Initialize(capacity); } this.comparer = (comparer ?? EqualityComparer<TKey>.Default); }
Once again we see EqualityComparer<TKey>.Default, just like in List<T>.Contains. This means making IntStruct implement IEquatable<IntStruct> will fix the same Equals issue with Dictionary too. The same solution also works for GetHashCode because GenericEqualityComparer also wraps that function:
public override int GetHashCode(T obj) { return obj?.GetHashCode() ?? 0; }
All that’s left for us to do is to implement GetHashCode in our IntStruct type:
struct IntStruct : IEquatable<IntStruct> { public int Value; public bool Equals(IntStruct other) { return Value == other.Value; } public override int GetHashCode() { return Value.GetHashCode(); } }
Conclusion
By implementing IEquatable<T> and overriding GetHashCode in our structs, we’ve avoided all GC allocations in container types like List<T> and Dictionary<TKey, TValue>. We’re still left with an abstract method call for every Equals and GetHashCode, so this isn’t a solution that provides ultimate performance. It’s a lot better than boxing though, so we have improved matters considerably. Finally, keep in mind that EqualityComparer<TKey>.Default is public and therefore available for use in your own generic code to avoid boxing and GC allocations.
#1 by Teaspoon on July 11th, 2019 ·
Great article. Deriving from IEquatable is a good solution for your IntStruct but what would suggest for a list of enums List enumLit?
I tried passing
private class EnumComparer : IComparer
{
public int Compare(T x, T y)
{
return Comparer.Default.Compare(x, y);
}
}
#2 by jackson on July 11th, 2019 ·
I’m glad you enjoyed the article. :) Unfortunately, I’m not quite sure what you’re asking. There wouldn’t be any boxing of your
List<T>since it’s already a reference type and therefore doesn’t need to be boxed to one. Can you give an example of the problem you’re trying to solve? Also, to make sure your code doesn’t get mangled, format it like this:<pre lang=”csharp”>
CODE
</pre>
#3 by W DUDZIAK on June 9th, 2020 ·
Thanks so much for this. Had a custom struct that was causing all kinds of havoc with performance. Had no idea this was happening for years behind the scenes. I figure at least 100 million of these structs were boxed every second for the last 4 years. It was amazing how smooth the application was and how few garbage collections took place after this was addressed.
More than 10 Quadrillion structs were boxed over the last few years. Who knows how much CPU time and electricity that wasted! Thanks!
#4 by Mehmet Yavuz Selim Soytürk on December 21st, 2022 ·
For generic code we can avoid
access if we add a type constraint
. This way, we can call
and still avoid boxing. It is also faster (no indirection, so the call can be inlined).