← 返回题目列表

Java 泛型是什么?类型擦除是怎么回事?

高频 中等 第 11 / 32 题 更新于 2026/07/26
泛型类型擦除通配符PECS

简化版

泛型是「参数化类型」——把类型当参数传,让 List<String>List<Integer> 共用一套代码,同时让编译器帮你做类型检查,避免强转出错。但 Java 的泛型是「伪泛型」:只存在于编译期,编译后类型参数会被擦除List<String>List<Integer> 运行时都是 List),擦除后用 Object(或上界)替代,并在需要处自动插入强制转换。所以泛型是「编译期的类型安全糖」,运行时并不知道自己是什么泛型类型。

详细版

泛型解决什么:Java 5 之前集合只能存 Object,取出来要手动强转,还可能转错(编译不报错、运行时 ClassCastException)。泛型把类型检查提前到编译期:

// 无泛型:能编译,运行时炸
List list = new ArrayList();
list.add("hello");
Integer x = (Integer) list.get(0);  // 运行时 ClassCastException

// 有泛型:编译期就拦住
List<String> list = new ArrayList<>();
list.add("hello");
Integer x = list.get(0);   // ✗ 编译不通过,问题提前暴露

类型擦除:编译后所有泛型信息被抹掉,类型参数替换为其上界(无上界则为 Object):

class Box<T> { T value; }
// 擦除后 → class Box { Object value; }

class Box<T extends Number> { T value; }
// 擦除后 → class Box { Number value; }  (擦成上界 Number)

擦除的证据:

List<String> ls = new ArrayList<>();
List<Integer> li = new ArrayList<>();
System.out.println(ls.getClass() == li.getClass());  // true!运行时都是 ArrayList

通配符

  • ? extends T(上界):只能读、不能写(PECS 里的 Producer),如 List<? extends Number> 能取出 Number 但不能 add。
  • ? super T(下界):能写、读只能当 Object(Consumer),如 List<? super Integer> 能 add Integer。
  • PECS 原则:Producer Extends, Consumer Super——生产者(提供数据、你去读)用 extends,消费者(接收数据、你去写)用 super。

⚠️ 因为类型擦除,很多操作被禁止:不能 new T()、不能 new T[]、不能 instanceof List<String>、泛型类不能有 static T 字段、catch 不能捕获泛型异常。这些都源于「运行时没有 T 的类型信息」。

完整版教学

一、泛型的本质:把类型变成参数

方法可以传值参数,泛型让你能传「类型参数」。没有泛型时,要写一个「装任意类型的盒子」,只能用 Object,取出来全靠强转,编译器帮不上忙。泛型把类型作为参数 <T>,一次定义、多类型复用,且编译器能在每个具体使用点检查类型:

class Box<T> {          // T 是类型参数
    private T value;
    void set(T v) { value = v; }
    T get() { return value; }
}
Box<String> b = new Box<>();
b.set("hi");            // 只能塞 String
String s = b.get();    // 取出来就是 String,无需强转

泛型的两大价值:① 编译期类型安全(塞错类型直接编译报错);② 消除强制转换(取出来就是声明的类型)。它把「运行时才暴露的 ClassCastException」提前成「编译错误」,这是它存在的核心意义。

二、类型擦除:Java 泛型是「编译期的」

关键认知:Java 泛型只在编译期存在,编译成字节码后类型参数被擦除。这是为了兼容 Java 5 之前的老代码(泛型是后加的,不能破坏已有的非泛型字节码)。

编译前(源码):           编译后(字节码,擦除):
List<String> list         List list             ← <String> 没了
Box<T>                    Box(T→Object)
Box<T extends Number>     Box(T→Number,擦成上界)

取值时编译器自动插入强转:
String s = list.get(0);  →  String s = (String) list.get(0);

所以运行时 List<String>List<Integer>同一个类 List。泛型信息在编译期用于检查,检查通过后就「功成身退」被擦掉,运行时的 JVM 根本不知道泛型的存在。这跟 C++ 的模板(为每个类型生成一份真实代码)完全不同——Java 是「一套代码 + 编译期检查 + 运行时强转」。

三、擦除带来的一堆限制(都源于同一个原因)

「运行时没有 T 的类型信息」直接导致一系列不能做的事,用一张表统一理解:

不能做原因
new T()擦除后 T 是 Object,JVM 不知道该 new 哪个类
new T[10]同理,无法确定数组元素的真实类型
obj instanceof List<String>运行时只有 List,没有 <String>,无法判断
class Box<T> { static T val; }static 属于类不属于实例,但 T 随实例化才确定,矛盾
catch (MyException<String> e)异常在运行时抛出/捕获,但泛型信息已擦除

记住:只要一个操作需要在”运行时”知道 T 是什么,它就被擦除机制禁止了。反过来,编译期能确定的操作(类型检查、强转插入)都正常。这条主线能推出所有泛型限制,不用死记。

四、擦除引发的诡异现象:桥接方法与重载冲突

擦除还会造成一些反直觉的现象。比如两个「看起来不同」的方法,擦除后签名相同,无法重载:

// ✗ 编译错误:擦除后都是 method(List),签名冲突
void method(List<String> list) {}
void method(List<Integer> list) {}

再比如桥接方法(bridge method):子类重写泛型方法时,编译器会偷偷生成一个桥接方法来维持多态:

class Node<T> { public void set(T t) {} }
class IntNode extends Node<Integer> {
    public void set(Integer i) {}   // 你以为是重写
}
// 擦除后父类 set(Object),子类 set(Integer),签名不同本不构成重写
// 编译器自动生成桥接方法 set(Object o){ set((Integer)o); } 来连接多态

这些现象平时看不见,但面试深挖「泛型重写为什么能多态」时,桥接方法就是答案。它是编译器为「擦除后维持多态」打的补丁。

五、通配符与 PECS:extends 和 super 怎么选

通配符 ? 解决「泛型不支持型变」的问题。注意 List<String> 不是 List<Object> 的子类(泛型默认不协变),需要通配符表达「某种子类型」:

// ? extends Number:上界通配,能读不能写
List<? extends Number> producer = new ArrayList<Integer>();
Number n = producer.get(0);   // ✓ 读出来至少是 Number
producer.add(1);              // ✗ 不能写!编译器不知道具体是 Integer 还是 Double 的 List

// ? super Integer:下界通配,能写不能安全读
List<? super Integer> consumer = new ArrayList<Number>();
consumer.add(1);              // ✓ 能塞 Integer
Object o = consumer.get(0);   // 读出来只能当 Object

PECS 记忆法Producer Extends,Consumer Super——

如果这个集合是"生产者"(你从它读数据)→ 用 extends
如果这个集合是"消费者"(你往它写数据)→ 用 super
Collections.copy(dest, src):
  src 是生产者(读)→ List<? extends T>
  dest 是消费者(写)→ List<? super T>

六、如何在运行时保留泛型信息

既然擦除,有时又确实需要运行时的泛型类型(如 JSON 反序列化要知道 List<User> 的 User),怎么办?靠一个技巧:匿名子类保留在类元数据里的泛型(TypeToken / 超类型令牌):

// 擦除只擦"使用处"的泛型,但"类定义的泛型父类"信息保留在字节码里
new TypeReference<List<User>>() {}   // Jackson/Gson 用这个拿到 List<User>
// 匿名类继承了 TypeReference<List<User>>,其泛型父类信息可通过反射 getGenericSuperclass() 取出

这解释了为什么 Jackson 反序列化复杂泛型要写 new TypeReference<>(){} 而不能直接传 List.class——后者擦除了 User,前者靠匿名子类把泛型「钉」在了类结构上,反射能读到。这是「擦除有例外」的重要知识点。

记忆钩子:「泛型是编译期的类型安全糖,运行时被擦除成 Object/上界;擦除导致不能 new T、不能 static T、不能 instanceof;通配符记 PECS:读用 extends、写用 super」

七、常见误区与追问

  • 误区:List<String>List<Integer> 是不同的类。 运行时因类型擦除是同一个类 List,getClass() 相等;泛型只在编译期区分。
  • 误区:泛型能提升运行时性能。 泛型是编译期机制,运行时和用 Object 无异,不涉及性能优化;它的价值是类型安全和免强转。
  • 误区:List<Object> 能接收 List<String> 不能,泛型默认不协变;要表达「某种子类」得用通配符 List<? extends Object>
  • 误区:能 new T() 创建泛型对象。 不能,擦除后 JVM 不知道 T 的真实类型;要传 Class<T> 用反射 clazz.newInstance(),或传工厂。
  • 追问:什么是 PECS? Producer Extends、Consumer Super——集合作为数据生产者(你读)用 ? extends,作为消费者(你写)用 ? super,兼顾灵活性与类型安全。
  • 追问:泛型方法重写为什么能多态? 擦除后父子方法签名可能不同,编译器自动生成「桥接方法」(参数为擦除后的 Object,内部强转再调真实方法)来维持多态。
  • 追问:既然擦除,Jackson 怎么反序列化 List<User> 用 TypeReference 匿名子类——擦除不擦「泛型父类」信息,通过 getGenericSuperclass() 反射拿到 List<User> 的完整类型。

八、加强记忆

Java 泛型是「编译期的类型安全糖」:把类型作为参数 <T>,让编译器在每个使用点做类型检查、自动插入强转,把运行时的 ClassCastException 提前成编译错误,同时消除手动强转。但它是「伪泛型」——编译后类型参数被擦除(无上界擦成 Object、有上界擦成上界),所以 List<String>List<Integer> 运行时是同一个 List。擦除的连锁后果是一串限制:不能 new T()/new T[]、不能 instanceof List<String>、不能有 static T 字段、不能 catch 泛型异常——凡是需要运行时知道 T 的操作都被禁。擦除还催生了「桥接方法」(维持泛型重写的多态)和「擦除后签名冲突」等现象。通配符用 PECS 记:读数据(生产者)用 ? extends、写数据(消费者)用 ? super。需要运行时保留泛型(如 JSON)靠 TypeReference 匿名子类把泛型钉在类结构上。一句话「编译期检查、运行时擦除,擦除生一堆限制,通配符记 PECS」。