Welcome to our next lesson. In our previous discussion, we delved into Java's mutable string classes, StringBuilder and StringBuffer, uncovering how their internal mechanics provide an efficient alternative to the immutable String class.
Today, we shift our focus to another powerful feature of the Java language: generics. While you've likely used generics extensively to create type-safe collections like List<String> or Map<Integer, MyObject>, we will now explore the "inner information" that makes them work. Specifically, we will uncover what happens to that type information after compilation through a process called type erasure, and how the compiler uses a clever technique called bridge methods to ensure that object-oriented principles like polymorphism are not broken in the process.
The Core Concept: Type Erasure
Generics were introduced in Java 5 to add a layer of compile-time type safety. Before generics, a common practice was to use collections of Object, which required explicit and often unsafe casting.
// Pre-Java 5
List list = new ArrayList();
list.add("hello");
String s = (String) list.get(0); // Requires a cast
list.add(123); // Whoops! A runtime error waiting to happen later.
Generics solve this by allowing you to specify the type, which the compiler then enforces. However, a key design goal for generics was backward compatibility with older, non-generic code. To achieve this, the Java compiler implements type erasure.
In short, type erasure means that the generic type information is used for checks at compile-time and is then "erased" from the bytecode. The JVM itself has very little knowledge of generics; it mostly sees Object.
Let's begin by reading a formal definition and a basic example.
Type Erasure in Java Explained
This Baeldung article provides a clear, concise definition of type erasure.
Please read the first two sections. Start with the overview and continue to the main definition. Pay close attention to how the generic method containsElement<E> is transformed into a non-generic method that uses Object.
How Type Erasure Replaces Types
The compiler doesn't just delete the type parameters; it replaces them according to a set of rules. The replacement depends on whether the type parameter is unbound or bound.
-
Unbound Type Parameters: If a type parameter is unbound (e.g.,
<T>,<E>), the compiler replaces it withObject. This is the most common case. -
Bound Type Parameters: If a type parameter is bound (e.g.,
<T extends Number>), the compiler replaces it with its bound. In this case,TbecomesNumber. If there are multiple bounds (e.g.,<T extends Runnable & Serializable>), the compiler uses the first bound (Runnable) for the replacement.
The next part of the Baeldung article illustrates this for both classes and methods.
Type Erasure in Java Explained
This section details how the compiler handles both unbound and bound type parameters during erasure.
Please read section 3, Types of Type Erasure. Focus on the two Stack class examples. The first shows an unbound <E> becoming Object. The second shows a bound <E extends Comparable<E>> becoming Comparable. This distinction is fundamental to understanding the mechanism.
The Problem: Preserving Polymorphism
At first glance, type erasure seems like a simple substitution. However, it introduces a significant problem related to polymorphism and method overriding.
Consider this classic inheritance scenario:
// Generic parent class
class Node<T> {
public T data;
public void setData(T data) {
this.data = data;
}
}
// Child class with a specific type
class MyNode extends Node<Integer> {
@Override
public void setData(Integer data) {
super.setData(data);
}
}
Now, let's trace what happens after type erasure:
- The
Node<T>class becomesNode. ThesetData(T data)method becomessetData(Object data). - The
MyNodeclass now extendsNode. Its methodsetData(Integer data)remains as is.
The post-erasure code looks like this:
class Node {
public Object data;
public void setData(Object data) { ... }
}
class MyNode extends Node {
public void setData(Integer data) { ... }
}
Notice the issue? From the JVM's perspective, the method MyNode.setData(Integer) does not override Node.setData(Object). They have different signatures. This means that if you had the following code, polymorphism would fail:
Node n = new MyNode();
n.setData("some string"); // Should fail, but appears to call Node.setData(Object) directly!
This violates a core principle of object-oriented programming. The compiler needs a way to fix this.
The Solution: Bridge Methods
To solve this problem and preserve polymorphism, the Java compiler generates a synthetic method called a bridge method. A bridge method is created in the subclass (MyNode in our example) and has the exact same signature as the erased superclass method. Its only job is to cast the input and delegate the call to the type-specific method you actually wrote.
The official Java Tutorials explain this process clearly.
Effects of Type Erasure and Bridge Methods (The Java ...
This official Oracle documentation explains exactly why bridge methods are necessary and how they are generated.
Read the entire page. It starts by posing the problem we just discussed with the Node and MyNode classes. Then, in the section on bridge methods, it shows the post-erasure code and explains how the compiler-generated method "bridges" the gap to maintain polymorphic behavior.
To see this all together, the following image provides a perfect side-by-side comparison of the Java source code and the resulting compiled class after type erasure, including the generated bridge method.
As you can see in the compiled MyNode class, the compiler added:
// Bridge method generated by the compiler
public void setData(Object data) {
setData((Integer) data);
}
This bridge method setData(Object data) is what actually overrides the setData(Object data) method from the parent Node class. When n.setData("some string") is called, it invokes this bridge method. The bridge method then attempts to cast "some string" to Integer, which correctly fails and throws a ClassCastException. This is how type safety is ultimately enforced at runtime, thanks to the combination of type erasure and bridge methods.
Conclusion
In this lesson, we explored the hidden mechanics of Java generics. You now have a deeper understanding of what happens when you write generic code, moving beyond just usage to the internal implementation—a key goal of our course.
Key Takeaways:
- Type Erasure is for Compatibility: Generics are a compile-time feature. The compiler uses type erasure (removing generic type information from bytecode) to maintain backward compatibility with older Java code that does not use generics.
- Types are Replaced, Not Deleted: Unbound type parameters (like
<T>) are replaced withObject, while bound parameters (like<T extends Number>) are replaced with their bound (Number). - Bridge Methods Preserve Polymorphism: Type erasure can create a mismatch in method signatures between superclasses and subclasses, seemingly breaking method overriding.
- The Compiler's Trick: To fix this, the compiler generates synthetic bridge methods. These methods have the erased superclass signature and simply delegate the call (with a cast) to the specific method in the subclass, ensuring that polymorphism works as expected and type safety is maintained.
In our next lesson, we will continue our deep dive into core language semantics by tackling another often-misunderstood topic: Java's pass-by-value semantics. We will dissect how arguments are passed to methods for both primitive types and object references.