覆盖考点:028|Java 泛型:类型擦除、通配符 PECS 原则与桥接方法
适合读者:高级 Java 工程师、技术专家、架构师候选人
预计阅读时长:24 分钟(文末问答不计入)
上一篇讲的比较契约,是关于"怎么比较两个对象"的规则;这一篇要讲的泛型,是关于"编译器怎么帮你在编译期检查类型"的机制。很多人天天写 List<String>,却从没想过一个问题:这个类型信息在程序运行起来之后,到底还在不在。答案是不在——Java 泛型是一种编译期特性,运行时早就被抹掉了,这篇要讲清楚这个"类型擦除"具体擦掉了什么、编译器用什么手段弥补、通配符和 PECS 原则怎么用,以及擦除带来的几个经典陷阱。
面试官在这个考点上通常这样追问:为什么运行时无法知道 List 里装的具体是什么类型 → 类型擦除之后,编译器是怎么保证类型安全的 → extends 和 super 到底怎么区分该用哪个 → 桥接方法是干什么的,为什么源码里看不到 → 为什么不能创建泛型数组 → 为什么不能同时写两个只有泛型参数不同的重载方法。本文按这条链展开。
一、泛型给你的是"假象",还是真实的类型安全?
写 List<String> list = new ArrayList<>(); 的时候,你会觉得这个 list 从头到尾都"知道"自己只能装字符串。但如果你在运行时用反射去看这个对象,会发现它的 class 就是普通的 ArrayList,根本没有"泛型版本"这种东西存在于字节码里。这不是 bug,是 Java 泛型的设计选择——类型擦除(Type Erasure)。这篇要讲清楚:擦除具体擦掉了什么、编译器用什么手段弥补、通配符和 PECS 原则怎么用、以及擦除带来的几个经典陷阱。

二、类型擦除具体做了什么
Java 泛型是编译期特性,编译器在编译阶段完成三件事,之后泛型信息就不再存在于生成的字节码里:
- 擦除类型参数:
List<String>、List<Integer>编译后统一变成List(更准确地说是变成它的擦除类型——如果类型参数有上界就变成上界类型,没有上界就变成Object)。 - 插入强制类型转换:
String s = list.get(0);这行代码,编译器会在字节码里生成String s = (String) list.get(0);,这个(String)转换在源码里完全看不到,是编译器悄悄加上的。 - 生成桥接方法(下文详述):保证擦除后子类仍然能正确重写(override)泛型父类/接口的方法。

这解释了一个经常被问到的问题:为什么运行时无法知道**List<String>**里的**String**是什么? 因为编译完成之后,这个信息就已经不存在了,list.getClass() 拿到的永远是 ArrayList.class,无论你创建的时候写的是哪个类型参数。
三、上下界通配符与 PECS 原则
泛型类型参数可以加边界限制:<T extends Number> 表示 T 必须是 Number 或其子类(上界);通配符 <? extends T> 和 <? super T> 则用在"不关心具体是哪个类型参数,只关心大致范围"的场景。
List<? extends Number>:表示这是某个 Number 子类型的列表,具体是哪个子类型不确定。你可以安全地从里面读取元素(读出来的一定是 Number 或其子类),但不能写入(因为编译器不知道这个列表实际持有的是Integer还是Double,写入任何具体类型都可能出错)。List<? super Integer>:表示这是某个 Integer 父类型的列表。你可以安全地写入Integer类型的元素,但读取出来的只能保证是Object(因为不确定具体是Integer还是Number还是Object)。
这两条规则被总结为一句口诀:PECS —— Producer-Extends, Consumer-Super。如果一个参数化类型只是用来"生产"数据给你读,用 extends;如果只是用来"消费"你传入的数据,用 super。

一个经典例子是 Collections.copy(List<? super T> dest, List<? extends T> src):src 是数据来源(Producer),用 extends;dest 是数据去向(Consumer),用 super。理解了数据流向,这个签名就不难记了。
四、桥接方法:编译器为你补的"隐形方法"
假设有一个泛型接口 Comparable<T>,某个类 MyClass implements Comparable<MyClass> 并实现了 compareTo(MyClass o)。擦除之后,接口里的方法签名变成了 compareTo(Object o)(因为擦除后类型参数变 Object),但你写的实现类里的方法签名是 compareTo(MyClass o)——这两个签名不一致,按照正常的重写规则,MyClass 并没有真正重写接口方法!
为了让多态在擦除之后依然成立,编译器会自动生成一个桥接方法(bridge method):一个签名为 compareTo(Object o) 的方法,内部做类型转换后再调用你写的 compareTo(MyClass o)。这个桥接方法你在源码里看不到,只有用 javap 反编译字节码,或者用反射查看 getDeclaredMethods() 时才会发现它真实存在。

这也解释了为什么用反射查看类的方法列表时,有时会看到两个参数类型不同但方法名相同的方法——多出来的那个就是桥接方法,是编译器为了兼容擦除机制而自动生成的。
五、类型擦除带来的经典陷阱
- 无法创建泛型数组:
new T[10]或new List<String>[10]都无法编译。因为数组在运行时会做类型检查(数组是"协变且带运行时类型信息"的),而擦除后泛型类型参数在运行时不存在,数组的运行时类型检查和泛型的编译期检查会互相矛盾,所以语言层面直接禁止创建泛型数组。 - 重载方法擦除后签名冲突:
void process(List<String> list)和void process(List<Integer> list)这两个方法擦除后签名都变成process(List list),完全相同,编译器会直接报错"方法已经被定义",根本不允许你同时声明这两个重载版本。 - 无法用 instanceof 判断具体的参数化类型:
if (list instanceof List<String>)无法通过编译,因为运行时根本没有String这个信息可供判断,只能写list instanceof List<?>(判断是不是某种 List,不管里面装的是什么)。 - 无法捕获泛型类型的异常:
catch (T e)是非法语法,因为catch需要在运行时知道异常的具体类型用于匹配,而擦除后T在运行时不存在具体信息。

六、常见误区
- 以为泛型能提供运行时类型安全。 泛型只在编译期做类型检查,运行时该有的类型转换失败(比如通过反射强行往
List<String>里塞一个Integer)依然会在读取时抛出ClassCastException,只是抛出的位置往往在编译器插入的强转代码那一行,容易让人误以为泛型本身"失效"了。 - 以为
**List<?>**和**List**是一回事。List<?>(未知类型的列表)仍然受泛型类型检查约束(比如不能往里面 add 任何具体类型的元素,除了 null),而裸类型List(不带任何类型参数)会完全绕过泛型检查,只会在编译时给出"未检查转换"的警告,运行时风险要大得多。 - 搞混 extends 和 super 的方向。 记混了就用 PECS 口诀反推:这个类型参数在这里是被读取(生产者)还是被写入(消费者),读多写少就用 extends,写多读少就用 super。
- 以为桥接方法会影响业务逻辑。 桥接方法只是编译器为了保证多态而生成的"转发壳",本身不包含任何业务逻辑,正常业务代码不需要关心它的存在,只有在做字节码分析、反射统计方法数量等场景才需要意识到它。
七、面试追问链

延伸阅读
- 《Java 语言规范》第 4.5、4.6 节关于参数化类型与类型擦除的定义
- 用
javap -c -p反编译一个实现了泛型接口的类,观察生成的桥接方法 - 上一期:《比较契约:不是不让你自定义排序,是自定义了就必须自圆其说》
问答(不计入阅读时长)
Q1:为什么运行时无法知道 List 里装的具体是什么类型?
A:因为 Java 泛型是编译期特性,编译器完成类型检查之后就会做"类型擦除":把泛型类型参数擦除成原始类型或边界类型,List<String> 和 List<Integer> 编译后都变成同一个 List。这个过程发生在编译阶段,运行时的字节码里根本不存在泛型类型参数这个信息,所以 list.getClass() 永远只能拿到 ArrayList.class,不区分你当初写的是哪个类型参数。
Q2:类型擦除之后,编译器是怎么保证类型安全的?
A:安全检查全部在编译期完成——只要代码通过了泛型类型检查,编译器就会在你实际取值的地方自动插入强制类型转换(比如 String s = (String) list.get(0)),这个转换在源码里看不到,是编译器悄悄加上的。正是因为检查提前在编译期做完了,运行时才不需要再保留泛型信息。
Q3:extends 和 super 到底怎么区分该用哪个?
A:记 PECS 口诀——Producer-Extends,Consumer-Super。如果这个参数化类型的作用是"生产数据给你读"(你只从里面取),用 extends;如果作用是"消费你传入的数据"(你只往里面放),用 super。判断标准就是看数据的流动方向,跟类型本身是什么无关。
Q4:为什么 List<? extends Number> 不能调用 add() 方法?
A:因为编译器不知道这个 ? 具体是 Integer 还是 Double 还是其他 Number 子类,如果允许你 add() 任何具体类型,都有可能与它实际持有的真实类型不匹配,为了保证类型安全,编译器直接禁止对 extends 通配符类型做写入操作(除了 add(null))。
Q5:桥接方法是编译器自己加的,为什么我从来没在源码里看到过?
A:因为它不是你写的,是编译器在编译阶段自动生成并插入到字节码里的,源码文件里完全看不到。它的作用是:当接口方法因为类型擦除导致签名发生变化(比如 Comparable<T> 的 compareTo(T) 擦除后变成 compareTo(Object))时,编译器补一个签名匹配接口的桥接方法,内部做类型转换后再调用你真正写的那个方法,从而让多态机制在擦除之后依然成立。用 javap 反编译或者用反射看 getDeclaredMethods() 才能看到它。
Q6:为什么不能同时写 process(List) 和 process(List) 两个重载方法?
A:因为擦除之后,这两个方法的签名都变成了 process(List),完全一样。方法重载依赖的是方法签名不同,擦除让这两个"看起来不同"的方法在字节码层面变成了同一个签名,编译器会直接报错"方法已经被定义",根本不允许你这样写。
Q7:为什么不能创建泛型数组,比如 new T[10]?
A:因为数组在运行时是"协变且带运行时类型信息"的(比如运行时会检查往数组里存的元素类型是否匹配),而泛型的类型参数在运行时经过擦除后根本不存在具体信息。这两种机制的假设互相矛盾——如果允许创建泛型数组,运行时的数组类型检查就会失去意义,所以语言层面直接禁止这种写法。
Q8:为什么 catch (T e) 这种写法是非法的?
A:因为 catch 语句在运行时需要拿到异常的具体类型去做匹配判断,决定这个 catch 块要不要接住抛出的异常。而 T 作为泛型类型参数,在运行时经过擦除后不再携带具体的类型信息,编译器没法给你生成一段"运行时判断异常类型是不是 T"的逻辑,所以这种写法在语法层面就被禁止了。