面试考点分析:
- 泛型基础概念——考察对类型参数化思想的理解,以及泛型如何提升类型安全。
- 泛型擦除机制——核心考点,涉及编译期擦除原理、擦除后的类型替换规则。
- 通配符与PECS原则——上界通配符(? extends)和下界通配符(? super)的区别及使用场景。
- 泛型在集合中的应用——结合 List、Map 等常见集合,考察泛型的实际编码能力。
- 泛型的限制与注意事项——泛型数组、静态上下文、instanceof 等常见雷区。
一、标准回答
泛型(Generics)是 Java 5 引入的一种类型参数化机制,允许在定义类、接口或方法时使用类型占位符进行限制,使用过程中再指定具体的类型。它的核心目的是在编译期进行类型检查,避免运行时出现 ClassCastException,从而提升代码的安全性。泛型也有代码复用的能力,如果没有泛型,可能需要为每一种数据类型编写集合类(如Int类型的List,String类型的List),有了泛型后,你只需要指定具体的类型List<Integer>或Map<String,Object>,就可以安全地复用于任何对象类型。
泛型擦除(Type Erasure)是 Java 实现泛型的方式。Java 的泛型仅在编译期有效,编译器在检查完类型安全后,会擦除所有的类型参数,替换为它们的限定类型或Object,并自动插入类型转换代码。也就是说,JVM 在运行时并不知道泛型的存在。
可以用一句话概括:Java 泛型是编译期的语法糖,运行时通过类型擦除实现向下兼容。
二、核心原理
2.1 擦除规则
编译器在处理泛型时遵循以下擦除规则:
- 无限制类型参数(如
<T>):擦除为Object。 - 有上界类型参数(如
<T extends Number>):擦除为上限类型,即Number。 - 方法签名中的泛型:返回值、参数中的类型参数都会被替换为擦除后的类型。
2.2 擦除前后对比
下面通过一段代码直观感受擦除前后的变化:
// 编译前:泛型代码 public class Box<T> { private T value; public void set(T value) { this.value = value; } public T get() { return value; } } // 使用时 Box<String> box = new Box<>(); box.set("Hello"); String s = box.get(); // 无需强制类型转换编译后,经泛型擦除,代码等价于:
// 编译后:擦除视角 public class Box { private Object value; // T 擦除为 Object public void set(Object value) { this.value = value; } public Object get() { return value; } } // 使用时,编译器自动插入强制类型转换 Box box = new Box(); box.set("Hello"); String s = (String) box.get(); // 编译器自动插入转换可以看到,泛型信息在字节码中完全消失,所有的类型安全由编译器在编译期保证。
2.3 桥方法(Bridge Method)
当子类继承(或实现)一个泛型父类并指定具体类型时,编译器会生成桥方法来保证多态的正确性。例如:
class Node<T> { public T data; public void setData(T data) { this.data = data; } } class MyNode extends Node<Integer> { @Override public void setData(Integer data) { super.setData(data); } }擦除后,父类的setData方法签名变为setData(Object),而子类写的是setData(Integer)。为了让多态正常工作,编译器会为MyNode自动生成一个桥方法setData(Object),内部调用setData((Integer) obj)。这是泛型擦除带来的一个重要底层细节。
三、应用场景
几个最典型的应用场景:
3.1 集合框架
这是泛型使用最频繁的地方。没有泛型时,集合中取出的元素是 Object,需要手动强转,容易出错:
// 没有泛型(Java 5 之前) List list = new ArrayList(); list.add("hello"); String s = (String) list.get(0); // 必须强转,容易抛 ClassCastException // 有了泛型 List<String> list = new ArrayList<>(); list.add("hello"); String s = list.get(0); // 类型安全,无需强转3.2 泛型方法
工具类中经常需要定义泛型方法,使方法能处理多种类型而保持类型安全:
// 泛型方法:返回数组中间元素 public static <T> T getMiddle(T... array) { return array[array.length / 2]; } // 调用时自动推断类型 String middle = getMiddle("A", "B", "C"); // 推断为 String Integer num = getMiddle(1, 2, 3, 4); // 推断为 Integer3.3 泛型接口
常见于 DAO 层、Repository 设计,通过泛型接口定义通用操作:
public interface Repository<T, ID> { T findById(ID id); void save(T entity); void delete(T entity); } // 具体实现 public class UserRepository implements Repository<User, Long> { @Override public User findById(Long id) { /* ... */ return null; } @Override public void save(User entity) { /* ... */ } @Override public void delete(User entity) { /* ... */ } }四、使用方式
泛型的核心用法包括通配符、边界与类型推断。
4.1 泛型类与泛型接口
| 语法 | 说明 | 示例 |
|---|---|---|
class Name<T> | 单个类型参数 | class Box<T> |
class Name<T, U> | 多个类型参数 | class Pair<K, V> |
class Name<T extends Number> | 上界限定,T 必须是 Number 或其子类 | class MathBox<T extends Number> |
interface Name<T> | 泛型接口 | interface Comparable<T> |
4.2 通配符(Wildcard)
通配符用于方法参数中,增强泛型的灵活性。核心原则是PECS:Producer-Extends,Consumer-Super。
| 通配符 | 含义 | PECS 角色 | 典型场景 |
|---|---|---|---|
? extends T | 上界通配符,能读不能写 | Producer(生产者) | 只从集合中读取数据 |
? super T | 下界通配符,能写不能读(读为 Object) | Consumer(消费者) | 只向集合中写入数据 |
? | 无界通配符,等价于? extends Object | 仅读取为 Object | 不关心具体类型 |
代码示例:
//我是 Number 或 Number 的儿子。我往外给东西时,你把我当 Number 用绝对安全。(只读) public static double sumOfList(List<? extends Number> list) { double sum = 0.0; for (Number n : list) { sum += n.doubleValue(); } return sum; } // 我是 Integer 或 Integer 的爹。你往里塞 Integer 时,我绝对能装得下。(只写) public static void addNumbers(List<? super Integer> list) { for (int i = 1; i <= 10; i++) { list.add(i); } }4.3 类型推断与菱形语法
Java 7 引入的菱形语法(<>)让泛型对象的创建更加简洁:
// Java 7 之前 Map<String, List<String>> map = new HashMap<String, List<String>>(); // Java 7 之后,编译器自动推断右侧泛型 Map<String, List<String>> map = new HashMap<>();五、扩展延伸
5.1 泛型数组问题
Java不允许创建泛型数组:
// 编译错误:Generic array creation List<String>[] listArray = new List<String>[10]; // 变通方案:使用集合或通配符数组配合强转 List<String>[] listArray = (List<String>[]) new List[10];原因在于泛型擦除后数组无法保证类型安全。如果允许创建泛型数组,编译器插入的类型转换可能会失败且无法被数组的类型检查机制拦截。
5.2 静态上下文中的泛型
静态方法或静态变量不能使用类的泛型参数,因为静态成员属于类级别,而泛型参数在实例化时才确定:
class Box<T> { // 编译错误:non-static type variable T cannot be referenced from a static context // static T value; // 正确做法:定义静态泛型方法,使用自己的类型参数 public static <U> void print(U item) { System.out.println(item); } }5.3 instanceof 与泛型
由于泛型擦除,不能用 instanceof 检查泛型类型参数:
// 编译错误 // if (obj instanceof List<String>) { } // 只能检查原始类型 if (obj instanceof List) { }5.4 泛型与重载冲突
擦除后方法签名相同会导致编译错误:
// 编译错误:两个方法擦除后签名相同 // public void print(List<String> list) { } // public void print(List<Integer> list) { }六、面试追问
Q1:什么是 Java 泛型擦除?为什么要这样设计?
A:泛型擦除是指编译器在编译期将泛型类型参数替换为限定类型或 Object,并插入必要的类型转换。这样设计是为了向后兼容——Java 5 之前的字节码和类库可以无缝运行在新的 JVM 上,无需修改。代价是运行时丢失了泛型类型信息。
Java 5 对核心类库(如java.util包)进行了大规模的泛型改造。Java 5 之前的老代码(比如List list = new ArrayList(); list.add("abc");),List的方法签名在运行时会变成类似add(E e),因为类型擦除:List<E>在运行时的真实签名依然是List,add(E e)在运行时依然是add(Object e),底层签名没变,老代码完全不需要修改就能正常调用。
Q2:List<String> 和 List<Integer> 的 Class 对象相同吗?
A:相同。由于泛型擦除,两者在运行时都是List.class。验证代码如下:
List<String> list1 = new ArrayList<>(); List<Integer> list2 = new ArrayList<>(); System.out.println(list1.getClass() == list2.getClass()); // 输出 trueQ3:? extends 和 ? super 的区别?什么时候用哪个?
记住PECS 原则即可:
? extends T——只读,适合生产者场景(从集合中获取数据)。? super T——只写,适合消费者场景(向集合中放入数据)。
举例:Collections.copy(List<? super T> dest, List<? extends T> src)就是 PECS 的经典体现——src 是生产者(extends),dest 是消费者(super)。
Q4:可以创建泛型数组吗?为什么?
A:不可以。因为泛型擦除后数组无法保证类型安全。例如,如果允许new List<String>[10],擦除后变为new List[10],你可以把一个List<Integer>放进去,绕过编译期检查,导致运行时类型污染。
Q5:如何获取泛型的实际类型参数?
A:虽然运行时泛型被擦除了,但可以通过反射获取带有泛型签名的方法或类的 ParameterizedType。常见做法是利用匿名子类保留父类的泛型信息:
// 通过匿名子类保留泛型信息 Type type = new TypeReference<List<String>>() {}.getType(); // 很多 JSON 框架(如 Jackson、Fastjson)就是这样处理泛型反序列化的Q6:泛型方法中 <T> 放在返回值前是什么含义?
A:<T>是类型参数声明,告诉编译器「T 是这个方法的泛型类型参数」。它必须出现在返回值之前,让编译器知道 T 不是某个已存在的类或接口,而是方法级的新类型变量。