「Java 进阶之路」系列 Day26
写在前面
模块三集合框架写到这篇收官,回过头讲讲一直陪着这些集合类出现、却很少被单独拎出来讲透的泛型。很多人知道"Java泛型是伪泛型"这个说法,但问一句"伪在哪、具体擦除掉了什么",就说不清楚了。这篇把类型擦除的机制和几个由此产生的经典坑讲明白。
一、是什么:泛型只存在于编译期,运行时会被擦除
Java的泛型是编译期的类型检查机制,<T>这类类型参数只在编译阶段起作用,编译器会在这个阶段帮你检查类型是否匹配、该插入自动类型转换的地方插入转换代码;一旦编译完成,字节码里泛型信息就被"擦除"了,List<String>和List<Integer>在运行时其实就是同一个List,用一段代码可以直接验证:
List<String> stringList = new ArrayList<>();
List<Integer> intList = new ArrayList<>();
System.out.println(stringList.getClass() == intList.getClass()); // true!
这就是"类型擦除"这个说法的直接体现——JVM运行时根本不知道也不关心一个List装的是String还是Integer,泛型参数信息在编译产出.class文件的那一刻就已经不存在了。
二、为什么要这样设计:向后兼容是首要考量
Java泛型是JDK5才引入的,在这之前所有集合类都是直接存Object,取出来自己强制转型。如果泛型引入之后彻底改变运行时的类型表示(比如像C++模板那样,为每种类型参数各自生成一份独立的字节码),那JDK5之前编译出来的、大量存在的老代码将没法和新代码互相调用——类型擦除本质上是用"泛型信息只在编译期存在"这种设计,换来了新旧代码在运行时层面的无缝兼容。代价是运行时丢失了泛型的具体类型信息,由此产生了下面这些容易踩的坑。
擦除的具体规则:没有限定的类型参数(比如<T>)会被擦除成Object;有限定的类型参数(比如<T extends Comparable>)会被擦除成这个限定的上界(也就是Comparable)。
三、怎么用:类型擦除带来的几个经典坑
坑一:不能直接创建泛型数组
class Box<T> {
// T[] arr = new T[10]; // 编译报错!
Object[] arr = new Object[10]; // 只能这样绕过去
}
数组在运行时需要明确知道自己的元素类型(用来做ArrayStoreException这类运行时类型检查),但泛型参数T在运行时已经被擦除成了Object,JVM压根不知道T具体是什么类型,没法创建一个"运行时类型明确"的数组,所以这行代码直接被编译器拒绝。
坑二:静态成员不能使用类的泛型参数
class Box<T> {
// static T value; // 编译报错!
// static T getValue() { ... } // 编译报错!
}
类的泛型参数T是和具体实例绑定的(new Box<String>()和new Box<Integer>()是两个不同泛型实例化),但静态成员属于类本身,在类加载的时候就已经存在,不依赖任何具体实例——这时候T到底该是String还是Integer根本无从谈起,所以类的泛型参数不能用在静态字段和静态方法上。
坑三:反射能绕开泛型的编译期检查
List<String> list = new ArrayList<>();
list.add("hello");
try {
Method add = list.getClass().getMethod("add", Object.class);
add.invoke(list, 100); // 通过反射,硬塞进去一个Integer!
} catch (Exception e) { }
System.out.println(list); // ["hello", 100],编译期类型检查形同虚设
泛型的类型检查完全是编译器插入的语法糖,运行时add方法的真实签名其实是add(Object)(T被擦除成了Object)。用反射直接拿到这个方法调用,跳过了编译器插入的类型检查逻辑,照样能把一个类型不匹配的元素塞进"声明为List<String>"的集合里——这也说明了泛型提供的类型安全,仅仅是编译期的一层保护,不是运行时真正的类型隔离。
四、面试追问
Q1:什么是类型擦除,Java泛型为什么被称为"伪泛型"?
类型擦除是指泛型的类型参数信息只在编译阶段存在,用于编译期的类型检查,编译完成后字节码里这些类型参数信息就被擦除掉了:没有限定的类型参数擦除成Object,有限定的擦除成限定的上界。因为运行时已经不存在真正独立的泛型类型(List<String>和List<Integer>运行时是同一个Class),和C++模板那种为每种类型生成独立代码的"真泛型"不同,所以Java的泛型常被称为"伪泛型"。
Q2:为什么 List<String> 和 List<Integer> 的 getClass() 返回结果相等?
因为类型擦除之后,两者在运行时其实是同一个类——ArrayList。泛型参数String和Integer只在编译期用于类型检查,编译完成后这部分信息就不存在了,JVM在运行时看到的只是普通的ArrayList,不会区分它当初是用什么泛型参数声明的。
Q3:为什么不能创建泛型数组,比如 new T[10]?
因为数组在运行时需要明确知道自己的元素类型,用来支持类似ArrayStoreException这样的运行时类型检查;但泛型参数T在运行时已经被擦除掉了,JVM并不知道T具体对应哪个类型,无法创建一个运行时类型信息完整的数组,所以编译器直接禁止这种写法。
Q4:为什么类的静态成员不能使用这个类声明的泛型参数?
因为类的泛型参数是和具体的实例化绑定的,比如Box<String>和Box<Integer>是两种不同的泛型实例化;而静态成员属于类本身,在类加载阶段就已经存在,不依赖任何具体的实例,这时候根本没有一个明确的泛型参数可以对应,所以类的泛型参数不能用在静态字段和静态方法上。
Q5:反射为什么能绕过泛型的类型检查,往 List<String> 里塞进一个 Integer?
因为泛型提供的类型检查是编译器在编译阶段插入的语法糖,add方法真正的运行时签名因为类型擦除已经变成了add(Object)。通过反射直接获取这个方法并调用,跳过了编译器在正常调用路径上插入的类型检查逻辑,运行时ArrayList本身并不会校验传进来的对象是不是String,所以能够成功把类型不匹配的元素塞进去,这也说明泛型的类型安全只在编译期有效,不是运行时层面真正的类型隔离。
下一篇预告
模块三集合框架到这篇正式收官。Day27 开始进入模块四"异常与IO",讲清楚 Checked 异常和 Unchecked 异常的本质区别,以及自定义异常该怎么设计才合理。

441

被折叠的 条评论
为什么被折叠?



