Java 泛型:类型信息只活在编译期,运行时早就被抹掉了(028)

集合框架、泛型与函数式编程约 10 分钟阅读5,183 字符
知识库文档 29 / 251

覆盖考点: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 泛型是编译期特性,编译器在编译阶段完成三件事,之后泛型信息就不再存在于生成的字节码里:

  1. 擦除类型参数:List<String>、List<Integer> 编译后统一变成 List(更准确地说是变成它的擦除类型——如果类型参数有上界就变成上界类型,没有上界就变成 Object)。
  2. 插入强制类型转换:String s = list.get(0); 这行代码,编译器会在字节码里生成 String s = (String) list.get(0);,这个 (String) 转换在源码里完全看不到,是编译器悄悄加上的。
  3. 生成桥接方法(下文详述):保证擦除后子类仍然能正确重写(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() 时才会发现它真实存在。

文章配图
文章配图

这也解释了为什么用反射查看类的方法列表时,有时会看到两个参数类型不同但方法名相同的方法——多出来的那个就是桥接方法,是编译器为了兼容擦除机制而自动生成的。

五、类型擦除带来的经典陷阱

  1. 无法创建泛型数组:new T[10] 或 new List<String>[10] 都无法编译。因为数组在运行时会做类型检查(数组是"协变且带运行时类型信息"的),而擦除后泛型类型参数在运行时不存在,数组的运行时类型检查和泛型的编译期检查会互相矛盾,所以语言层面直接禁止创建泛型数组。
  2. 重载方法擦除后签名冲突:void process(List<String> list) 和 void process(List<Integer> list) 这两个方法擦除后签名都变成 process(List list),完全相同,编译器会直接报错"方法已经被定义",根本不允许你同时声明这两个重载版本。
  3. 无法用 instanceof 判断具体的参数化类型:if (list instanceof List<String>) 无法通过编译,因为运行时根本没有 String 这个信息可供判断,只能写 list instanceof List<?>(判断是不是某种 List,不管里面装的是什么)。
  4. 无法捕获泛型类型的异常:catch (T e) 是非法语法,因为 catch 需要在运行时知道异常的具体类型用于匹配,而擦除后 T 在运行时不存在具体信息。

文章配图
文章配图

六、常见误区

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

七、面试追问链

文章配图
文章配图

延伸阅读

  • 《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"的逻辑,所以这种写法在语法层面就被禁止了。