Java Generics: Type Safety and Runtime Behavior
java generics: Understand Java generics, including type erasure, wildcards, and practical patterns for writing type-safe, reusable code.
A List without a type parameter accepts any Object, which forces callers to cast and risks ClassCastException at runtime. Java generics move that check to compile time, allowing you to specify the element type when you declare the collection. This article explains how java generics work, including type erasure, wildcards, and the tradeoffs that matter when you design reusable code.
Why Generics Exist: Compile-Time Type Checking
Consider a pre-generics pattern where a list holds different types accidentally:
List numbers = new ArrayList(); numbers.add("42"); Integer value = (Integer) numbers.get(0); // ClassCastException at runtime
The compiler allows this code because numbers is a raw List. The failure appears only when the program runs. With generics, the element type is part of the declaration:
List<Integer> numbers = new ArrayList<>(); numbers.add("42"); // compile error: incompatible types
The compiler rejects the invalid addition before the code ever executes. This is the core benefit: type safety without the overhead of explicit casts everywhere.
Generic Classes and Type Parameters
A generic class declares one or more type parameters in angle brackets after the class name. The type parameter T acts as a placeholder for the actual type supplied by the caller.
public class Box<T> { private T value; public void set(T value) { this.value = value; } public T get() { return value; } }
When you instantiate Box<String>, every occurrence of T is replaced by String at compile time. The compiler ensures that set accepts only String and that get returns String without a cast. The type parameter can be any reference type, not a primitive. For primitives, use their wrapper classes such as Integer, Double, or Boolean.
Type parameters follow naming conventions: T for type, E for element, K and V for keys and values, but any valid identifier works.
Generic Methods and Type Inference
Generic methods allow a type parameter to be scoped to the method rather than the whole class. This is useful for static utility methods that work with multiple types.
public static <T> T firstOrNull(List<T> items) { if (items.isEmpty()) { return null; } return items.get(0); }
The type parameter <T> appears before the return type. When you call the method, the compiler infers T from the argument:
List<String> names = List.of("Alice", "Bob"); String first = firstOrNull(names);
You can also specify the type explicitly, though inference usually suffices. Generic methods are essential when the type parameter is used in the argument list or return type but not in the class definition.
Wildcards and Bounded Type Parameters
Wildcards (?) represent an unknown type. They are useful when you want to accept a collection of any subtype or supertype without fixing the exact type.
List<?>accepts a list of any element type, but you cannot add elements to it (exceptnull) because the element type is unknown.List<? extends Number>acceptsList<Integer>,List<Double>, etc. It allows reading asNumber, but prevents adding new elements because the actual subtype is unknown.List<? super Integer>acceptsList<Integer>,List<Number>, orList<Object>. It allows addingIntegervalues but reading returnsObject.
Bounded type parameters on generic classes or methods restrict the allowed types. For example:
public static <T extends Comparable<T>> T max(List<T> items) { T max = items.get(0); for (T item : items) { if (item.compareTo(max) > 0) { max = item; } } return max; }
Here T must implement Comparable<T>, which guarantees the compareTo method exists. This is a common pattern for generic algorithms that require ordering.
Type Erasure and Runtime Behavior
Java generics are implemented through type erasure. At runtime, the JVM does not know the type parameters; they are erased to their bounds or to Object. This design preserves backward compatibility with pre-generics code but has important consequences.
List<String> strings = new ArrayList<>(); List<Integer> integers = new ArrayList<>(); System.out.println(strings.getClass() == integers.getClass()); // true
Both lists have the same runtime class because the type parameter is erased. You cannot use instanceof with a parameterized type, and you cannot create an array of a concrete generic type like new T[10]. These restrictions stem from erasure.
Erasure also means that generic type information is not available for reflection. If you need to know the type at runtime, you must pass a Class object explicitly or use a super-type token pattern.
Generics and Collections: Common Patterns
The most frequent use of java generics is with the collections framework. List<T>, Set<T>, Map<K,V>, and Queue<T> all rely on type parameters to enforce element types.
A common pattern is to use wildcards in method signatures to accept collections with different type arguments:
public static double sum(Collection<? extends Number> numbers) { double total = 0; for (Number n : numbers) { total += n.doubleValue(); } return total; }
This method works with List<Integer>, Set<Double>, or any collection whose element type extends Number. Without the wildcard, you would need a separate overload for each numeric type.
Another pattern is the producer-extends, consumer-super (PECS) rule. If a method only reads from a collection, use ? extends T. If it only writes, use ? super T. This rule prevents subtle type errors and is especially relevant when designing APIs that accept or return generic collections.
Common Pitfalls and How to Avoid Them
One frequent mistake is using raw types. A raw List bypasses all compile-time checks and can lead to runtime exceptions. Always specify a type parameter unless you are deliberately interacting with legacy code.
Another pitfall is assuming that List<String> and List<Object> are related. They are not. List<String> is not a subtype of List<Object>, even though String is a subtype of Object. This is because generics are invariant. Use wildcards when you need covariance or contravariance.
A third issue is mixing generic and non-generic code. For example, casting from a raw list to a parameterized list produces an unchecked warning. The compiler cannot verify the cast, so the resulting code may fail at runtime. Prefer using Collections.checkedList or refactoring the legacy code to avoid unchecked operations.
Performance and Compatibility Considerations
Type erasure means generics add no runtime overhead for type checks or casts; the compiler inserts casts where needed, and the JVM executes them as normal instructions. There is no performance penalty for using generics compared to explicit casts, and in many cases the code is faster because the compiler can optimize the generated bytecode.
Memory usage is unaffected because type parameters are erased and no extra objects are created. The main cost is at compile time: the compiler performs additional type-checking, which can slow large builds slightly, but this is negligible in practice.
Compatibility is a key reason Java kept erasure. Code written before generics still runs unchanged, and generic code can interoperate with legacy code, albeit with warnings. When you update an existing API to use generics, you may break source compatibility for callers that used raw types. The compiler will emit unchecked warnings, and you should address them to avoid hiding real type errors.
Understanding these runtime characteristics helps you decide when to use generics. They are appropriate for most new code because they improve maintainability and reduce the chance of ClassCastException. The limitations around reflection and array creation are the main constraints to plan around.