Type Erasure and What It Actually Costs
List<String> and List<Integer> are the exact same class once the JVM gets involved. Here’s why Java made that trade, and what it still costs today.
Ask a Java developer to run this quick check, and the answer surprises almost everyone the first time: list1.getClass() == list2.getClass() returns true, even when list1 is a List<String> and list2 is a List<Integer>. Both are just ArrayList at runtime. Not “ArrayList of String.” Not “ArrayList of Integer.” Just ArrayList, full stop. The type parameter you wrote in your source code never makes it past the compiler.
That behavior has a name, type erasure, and it is not a bug. It is a deliberate design decision Java made in 2004 when generics were introduced in Java 5, and understanding why helps explain a surprising amount of Java’s modern performance quirks, its verbose workarounds, and why languages like C# went a completely different direction.
What Type Erasure Actually Does
When you write List<String>, the type parameter exists purely for the compiler’s benefit. The compiler uses it to check that you never accidentally add an Integer to a list of Strings, and it inserts the casts you would otherwise have to write by hand. Then, before producing bytecode, the compiler throws that information away. As Oracle’s own documentation on generics explains, every type parameter gets replaced with its bound, or with Object if it has no bound, and the JVM that eventually runs your code never learns that a generic type parameter was ever there.
This is why you cannot write if (someList instanceof List<String>). The JVM has no way to check that, because by the time your code runs, there is no such thing as a List<String> class distinct from plain List. You can check instanceof List. That’s it.
Why Java chose this path
The motivation was backward compatibility, and it was a real constraint, not a hand-wave. By 2004, there were already millions of lines of Java code and countless compiled libraries built before generics existed. Modifying the JVM itself to understand generic type parameters at runtime would have meant either breaking all of that existing bytecode or running two incompatible object models side by side. Erasure let the compiler add type safety on top of the existing JVM without touching a single byte of it. Old, non-generic code and new, generic code could keep calling each other without anyone recompiling anything.
What This Actually Costs You
Type erasure is not free. It shows up in three concrete ways once you go looking for it.
1. No primitives in generics, which means boxing
Because erasure replaces a type parameter with Object, and primitives like int or double are not objects, Java simply does not allow ArrayList<int>. Every int you put into a generic collection gets autoboxed into an Integer first. That is not a cosmetic inconvenience, it is a real memory and performance cost, and it is directly measurable.
JMH Benchmark: add() on a Boxed List vs. Primitive-Backed Lists

ArrayList’s boxing overhead is not a rounding error here. It is the slowest option by a wide margin precisely because every single add() call has to allocate or look up an Integer object instead of just writing a raw int into an array slot.
JMH Benchmark: contains() on a Boxed List vs. Primitive-Backed Lists

Libraries like Trove, Fastutil, and Colt exist specifically to work around this. They implement their own int-specialized collections backed by real primitive arrays, sidestepping erasure’s “everything is an Object” rule entirely. Their existence is itself evidence of the cost: nobody builds a specialized third-party library to fix a problem that doesn’t measurably exist.
2. Runtime reflection loses the type argument
Frameworks that inspect objects at runtime, think Jackson deserializing JSON, Hibernate mapping database columns, or a dependency injection container wiring up beans, constantly need to know what a generic type parameter actually was. Erasure took that information away, so the entire Java ecosystem developed a workaround called the “type token” pattern: wrap the type in an anonymous subclass so its generic superclass signature survives in the class file’s metadata, then read it back with reflection. It works, but it is a workaround built specifically because the language doesn’t give you the type directly.
3. Overload and array pitfalls
Two overloaded methods that only differ by generic type parameter, one taking List<String> and another taking List<Integer>, collide at compile time, because after erasure both signatures become the identical List. Generic arrays run into a related problem: Java forbids creating a new List<String>[10] directly, because an array does check its element type at runtime, and erasure has nothing to check against. Both restrictions exist purely to prevent problems that would otherwise surface as confusing runtime failures.
How C# Solved the Same Problem Differently
C# generics arrived a year after Java’s, and the .NET team made the opposite call. As Microsoft’s own documentation on generics in the runtime explains, the Common Language Runtime keeps type parameters around and instantiates specialized versions of generic types as needed. For value types specifically, such as int or a custom struct, the CLR generates a genuinely separate, specialized implementation the first time it encounters that combination, with the real value type substituted directly into the compiled code. A List<int> in C# stores actual ints in a real int array, with zero boxing, because the runtime built a dedicated version of List just for int.
Reference types get a smaller optimization: since a reference is a reference regardless of what it points to, the CLR lets List<string>, List<object>, and any other reference-typed List share the bulk of the same compiled implementation, substituting object pointers underneath. Either way, the type argument survives into the running program. Reflecting on typeof(List<int>) at runtime genuinely tells you the element type was int, something no amount of cleverness makes possible in plain Java.
Where Kotlin Actually Stands
Here’s a common misconception worth clearing up directly: Kotlin does not have reified generics as a general language feature. Kotlin compiles to JVM bytecode, and ordinary Kotlin generics are erased in exactly the same way Java’s are. A List<String> and a List<Int> in Kotlin are, at runtime, the same erased class, just like in Java.
What Kotlin does have is a narrow, specific escape hatch: the reified keyword, and it only works on type parameters of inline functions. As the official Kotlin documentation on generics puts it, inline functions with reified type parameters have their actual type arguments substituted directly at each call site, because the compiler copies the function’s body into the caller instead of emitting a real function call. Since the type argument is known at the exact point the code gets inlined, the compiler can bake it in before erasure ever gets a chance to remove it. This is a compile-time trick, not a runtime capability the JVM gained. It cannot be used on a regular class’s type parameter, and it cannot be called from Java, precisely because the mechanism depends on Kotlin’s inliner rewriting the call site.
Side by Side
| Question | Java | Kotlin | C# |
|---|---|---|---|
| Is the type argument present at runtime? | No, always erased | No, erased by default | Yes, for both value and reference types |
| Any exception to that rule? | None | Reified type parameters, inline functions only | None needed; it’s the default behavior |
| Can generics wrap primitives without boxing? | No, always boxed | No, always boxed (same JVM constraint) | Yes, value types get specialized, unboxed implementations |
| Where the trick happens | Nowhere; erasure is final | Compiler inlining, before erasure occurs | Runtime, via CLR-level specialization |
| Underlying reason | Preserve JVM binary compatibility, pre-2004 code | Inherits the JVM’s constraint from Java | CLR designed with generics as a first-class goal |
Is Anything Changing?
Yes, slowly and deliberately. Project Valhalla, OpenJDK’s long-running initiative for value types and generic specialization, is working toward letting Java generics operate over primitives without boxing, aiming for a world where a hypothetical List<int> could exist without erasure forcing every element through Integer. It has been in development for years precisely because retrofitting this onto two decades of existing bytecode is exactly as hard as the original 2004 decision predicted it would be. Erasure was the pragmatic choice then, and undoing its side effects now is proving to be a genuinely multi-year engineering effort.
Quick Checklist: Spotting Where Erasure Is Costing You
- Reach for a primitive-specialized collection library when a hot loop pushes large volumes of ints or doubles through a generic collection.
- Watch for the type-token workaround (an anonymous subclass just to preserve a generic signature) whenever a framework needs your generic type at runtime.
- Remember that
instanceofand casts against a parameterized type likeList<String>simply aren’t checkable; only the raw type is. - If you’re using Kotlin’s
reified, confirm the function is actuallyinline, since that is the only place the keyword is legal. - Don’t assume C#’s reified generics mean zero cost either; specialized value-type implementations still take time to generate the first time each one is used.
What We Have Learned
Type erasure means List<String> and List<Integer> really are the same class the moment your code becomes bytecode, and that was a deliberate, pragmatic trade Java made in 2004 to keep decades of existing code and libraries working without modification. The cost of that decision is real and measurable: primitives must be boxed before they can enter a generic collection, runtime reflection needs workarounds like type tokens to recover a type argument, and certain overloads and array constructions are blocked outright to avoid confusing failures.
C# took the opposite path from the start, letting the CLR generate specialized, unboxed implementations for value types while sharing implementations across reference types, so a type argument genuinely survives into the running program. Kotlin, despite running on the same JVM as Java, does not get that for free either; its reified keyword is a narrow, inline-function-only compiler trick, not a general runtime capability. None of these designs is free of trade-offs, but only one of them, Java’s, is still actively being worked around by both third-party libraries and, eventually, Project Valhalla itself.

