Generic performance internals: canonical shared code (__Canon), value-type specialisation, runtime generic dictionaries, constant folding of typeof(T), boxing avoidance and AOT code size.
Generics are usually zero-cost, but how depends on T. For value types the JIT generates separate, fully specialised code, so there is no boxing and typeof(T) == typeof(int) folds to a constant. For reference types all instantiations share one canonical body, which looks up type-specific data such as statics and typeof(T) through a runtime dictionary. new T() and generic virtual methods take slower paths. The cost of specialisation is code size and JIT time, which matters for Native AOT, where every instantiation must be known at build time.
.NET generics are reified and compiled by the JIT with a hybrid strategy: shared code for reference-type instantiations and specialised code for each value-type instantiation. That design explains most generic performance characteristics — why List<int> is fast, why typeof(T) checks can be free or not, why new T() and generic virtual methods have costs, how generic static caches work, and why heavy generic use can grow NativeAOT binaries.
For List<string>, List<Customer>, List<Order>, the JIT compiles each method once using a canonical placeholder (__Canon). Since all references are pointer-sized and handled identically by the GC, the same machine code works. Type-specific information (the exact T for casts, typeof(T), static fields, new T[]) is obtained at run time via a generic dictionary — an extra hidden argument or a lookup through the object's MethodTable.
Benefit: less code, less JIT time, smaller images. Cost: operations needing exact type info do a dictionary lookup (usually cached and cheap, but not free).
For List<int>, List<double>, List<Guid>, the JIT generates separate code per value type, with T known exactly:
T.CompareTo, INumber<T> operators),This is why Dictionary<int, X> lookups, Sum<T> over int with generic math, and sorting Span<int> compile to the same tight code as hand-written specialised versions.
typeof(T) checksstatic int Size<T>()
{
if (typeof(T) == typeof(int)) return 4;
if (typeof(T) == typeof(long)) return 8;
return -1;
}
typeof(T) == typeof(int) is a JIT-time constant, so all but one branch is eliminated — zero cost. The BCL uses this pattern heavily (e.g. in Span helpers, Vector<T>).typeof(T).IsValueType, RuntimeHelpers.IsReferenceOrContainsReferences<T>() and default(T) == null are similarly folded for value types.new T()With the new() constraint, new T() historically compiled to Activator.CreateInstance<T>() (reflection). Modern runtimes optimise it substantially — for value types it's just default(T) (unless there's an explicit parameterless constructor), and for reference types the runtime uses cached allocators — but a factory delegate (Func<T>) or a static abstract factory (static abstract T Create() on an interface) is still the fastest and most flexible approach in hot paths.
virtual T Get<T>() or interface methods with their own type parameters need runtime lookup of the right instantiation for the receiver's type on each call (a hash-table lookup with caching). They're noticeably slower than ordinary virtual calls and can't be devirtualised as easily. Avoid GVMs on hot paths; prefer generic interfaces/classes (IHandler<T>) where the type parameter is fixed per object.
internal static class Cache<T> { public static readonly Func<T, string> Formatter = BuildFormatter(); }
Each closed type has its own statics, initialised once. Accessing Cache<int>.Formatter in specialised code is a direct static load; in shared code it's a lookup via the generic dictionary. This pattern (used by EqualityComparer<T>.Default, Array.Empty<T>()) is faster than a Dictionary<Type, X> lookup and is thread-safe through static initialisation.
NativeAOT must generate code ahead of time for every instantiation that might be used:
__Canon is shared for references;MakeGenericType/MakeGenericMethod at run time with value types not seen at compile time fails (or needs annotations);Foo<List<List<T>>>) can explode instantiations.Tools: the AOT compiler's size reports (IlcGenerateMstatFile, sizoscope), trimming analysis warnings (IL2xxx/IL3050), and reducing gratuitous value-type instantiations in libraries.
A serialisation library used a generic virtual method Write<T>(T value) on an abstract writer, called for every field. Profiling showed significant time in GVM resolution. Restructuring to cached IFieldWriter<T> instances (resolved once per type via a static generic cache) turned per-call lookups into ordinary interface calls, and on value types the specialised code inlined the writes — throughput improved by roughly a third.
Separates people who use generics from people who understand the runtime.
Green flags: canonical shared code versus specialisation; generic dictionary lookups; constant folding of typeof(T); new T() and GVM costs; AOT implications; measures with BenchmarkDotNet.
Red flags: "generics are compiled away like C++ templates"; "generics always box"; no idea how reference-type code is shared.
When compiling List<__Canon>.Add, the JIT emits code that needs type info for:
T[] when growing (new T[]),is T, casts),EqualityComparer<T>.Default).It gets them from the generic dictionary of the closed type (List<string>), reached through the this object's MethodTable (for instance methods on generic classes) or a hidden instantiation argument (for generic methods and static methods). Dictionary slots are filled lazily and cached.
Dictionary<string, int>: TKey is a reference type, TValue is a value type — the instantiation counts as needing specialisation because int is a value type; the canonical form is Dictionary<__Canon, int>, shared across all reference-type keys with int values.
For where T : IComparable<T>, the IL uses constrained. !!T callvirt IComparable<T>::CompareTo. In specialised (value-type) code, the JIT resolves it to a direct call on the struct (no boxing, inlinable). In shared code, it resolves through the dictionary to an interface call. That's why generic algorithms over value types match hand-written performance.
Generic math calls like T.Zero or a + b on T : INumber<T> are resolved per instantiation; in value-type specialised code they inline completely. In shared reference-type code they'd require dictionary lookups, but numeric types are almost always value types.
Shared generic code benefits from dynamic PGO (guarded devirtualisation of interface calls on the observed T). ReadyToRun images may not contain all generic instantiations (especially those combining types from different assemblies), so some generic code is JIT-compiled at start-up — a cold-start consideration.
[DynamicallyAccessedMembers] and RequiresDynamicCode annotations flag APIs that can't be AOT-safe.MakeGenericType and reflection over generics; source generators replace them.int vs object instantiations, and with [DisassemblyDiagnoser] to see specialised code.DOTNET_JitStdOutFile + DOTNET_JitDisasmSummary to list compiled instantiations..mstat files to analyse size by generic instantiation.Size<int>() and Size<string>() from the example above.new T() vs Func<T> vs static abstract factory.using System.Buffers;
using System.Numerics;
using System.Text;
namespace Generics.Performance;
public static class Numeric
{
// Specialised per value type: no boxing, typeof checks fold away
public static T Sum<T>(ReadOnlySpan<T> values) where T : INumber<T>
{
var total = T.Zero;
foreach (var value in values) total += value;
return total;
}
public static string Describe<T>()
{
if (typeof(T) == typeof(int)) return "32-bit integer"; // constant for T = int
if (typeof(T) == typeof(double)) return "double";
return typeof(T).Name;
}
}
// Generic static cache: one formatter per T, built once, no locks
public static class Utf8Formatter<T> where T : IUtf8SpanFormattable
{
public static readonly Action<T, IBufferWriter<byte>> Write = static (value, writer) =>
{
var span = writer.GetSpan(64);
if (value.TryFormat(span, out var written, default, null)) writer.Advance(written);
};
}
public static class Metrics
{
public static void Record<T>(ArrayBufferWriter<byte> buffer, T value) where T : IUtf8SpanFormattable
=> Utf8Formatter<T>.Write(value, buffer); // no boxing
}
public static class Program
{
public static void Main()
{
Console.WriteLine(Numeric.Sum<int>([1, 2, 3]));
Console.WriteLine(Numeric.Sum<double>([1.5, 2.5]));
Console.WriteLine(Numeric.Describe<int>());
Console.WriteLine(Numeric.Describe<string>());
var buffer = new ArrayBufferWriter<byte>();
Metrics.Record(buffer, 42);
Metrics.Record(buffer, 3.14);
Console.WriteLine(Encoding.UTF8.GetString(buffer.WrittenSpan));
}
}
What happens: heavy generic virtual methods and runtime type lookups in hot paths. Why it is wrong: GVMs and dictionary lookups add overhead. Do instead: use generic types with fixed type parameters and static caches.
Dictionary<Type, X> instead of generic static cachesWhat happens: per-type metadata looked up by typeof(T) in a dictionary.
Why it is wrong: hashing and locking on every call.
Do instead: use static class Cache<T> with static readonly fields.
new T() in hot loopsWhat happens: generic factories call new T() millions of times.
Why it is wrong: slower than direct construction for reference types.
Do instead: use factory delegates or static abstract factories.
What happens: if (value is IFoo f) in generic code.
Why it is wrong: boxing for value types and runtime branching.
Do instead: constrain T.
What happens: libraries use MakeGenericType with value types.
Why it is wrong: fails or requires warnings under NativeAOT.
Do instead: design for known instantiations or use source generators.
What happens: generic helpers instantiated with dozens of struct types. Why it is wrong: code size and JIT time grow; AOT binaries bloat. Do instead: limit gratuitous generic specialisation in libraries; share code where possible.
typeof(T) branches to fold for reference typesWhat happens: relying on branch elimination for string/class instantiations.
Why it is wrong: shared code keeps the branches.
Do instead: accept the small runtime check or use specialised overloads.
What happens: restructuring generic code based on assumptions. Why it is wrong: the JIT often optimises better than expected. Do instead: benchmark and inspect disassembly.
The senior message is that generics are a zero-cost abstraction for value types and a near-zero-cost one for reference types, with a few known slow paths. I can explain the canonical-code model, why typeof checks fold only for structs, and when to reach for a static generic cache. I also weigh code size: in AOT or serverless start-up scenarios, dozens of struct instantiations of a large generic type have a measurable cost. And I always back claims with BenchmarkDotNet and disassembly rather than folklore.
Boxing wraps a value type in a new heap object so it can be used as object or an interface; unboxing checks the object's type and copies the value back, and it must be the exact type or you get InvalidCastException. Every box is a heap allocation, so in hot paths it creates GC pressure. Hidden boxing comes from non-generic APIs like ArrayList and params object[], passing structs as interfaces, calling GetType() or non-overridden Equals/GetHashCode on structs, and enumerating a List<T> through IEnumerable<T>. I find it with BenchmarkDotNet's MemoryDiagnoser or an allocation profiler and fix it with generics, constraints and IEquatable<T>.
Constraints tell the compiler what T can do so you can use it safely. C# has class and class? for reference types, struct for non-nullable value types, unmanaged for blittable structs, notnull, new() for a public parameterless constructor, base-class and interface constraints, where T : U, and Enum and Delegate constraints. C# 13 added the allows ref struct anti-constraint so T can be a Span<T>-like type. Interface constraints on struct T also avoid boxing because the JIT emits constrained calls.
A virtual member can be overridden by a derived class, and a call to it is resolved by the run-time type of the object, not the variable's type. In C#, members are non-virtual by default (unlike Java), because every virtual member is a promise to subclasses and is harder to change. At run time the call loads the object's method table, reads the method's slot and calls through it, which prevents inlining unless the JIT can devirtualise (exact type known, sealed class or method, or dynamic PGO's guarded devirtualisation). Methods, properties, indexers and events can be virtual; static (outside interfaces), private and constructors cannot.
How to tell it (STAR)
**S:** Our metrics library was a top allocator in a latency-sensitive API. **T:** Remove the allocations without changing every caller. **A:** I found it boxed generic values to format them, rewrote the formatting with an interface constraint and a generic static cache, and proved zero allocations with BenchmarkDotNet. **R:** Gen 0 GCs fell by [percentage] and p99 latency improved by [amount].
Last updated 30 Oct 2026.