Java-Generics-PartII
泛型擦除
Java泛型是编译时(compile time)行为,在运行时(runtime)所有与泛型相关的信息都会被彻底擦除。擦除后,所有参数化类型都会统一还原为同一个原始类型(raw type)。这里引入一个新术语——原始类型,指的是泛型类
/
接口去掉所有泛型类型参数后的基础类型,也是泛型类型经过类型擦除后,JVM在运行时实际识别的类型。
1 | |
明确泛型是编译时行为,是理解泛型诸多特性的关键。
泛型类中允许定义静态成员,例如静态方法、静态变量、静态内部类等。由于静态成员隶属于类而非对象,而所有参数化类型在运行时都会因类型擦除退化为原始类型,因此它们会共享同一个静态成员实例。
1 | |
同理,引用泛型类的静态变量时,应使用原始类型而非参数化类型。
1 | |
上一篇提到,泛型类型需先指定具体类型,成为参数化类型后才能使用。这里的使用,指的是像非泛型类一样出现在代码的各个位置,常见场景包括作为方法参数、返回值、局部变量类型、成员变量类型,以及通过new关键字实例化对象等。
但相较于非泛型类,参数化类型存在一些使用限制:无法创建泛型数组、无法作为异常类型、无法作为类型字面量(如List<String>.class)、无法用于instanceof关键字的类型判断。
无法作为类型字面量的原因很好理解。受泛型擦除特性影响,List<String>.class和List<Integer>.class最终都会指向List.class。而Java类型系统明确规定:一个类(原始类型)在JVM中仅存在一个Class实例。参数化类型的字面量既无实际意义,还会导致类型标识混乱,因此Java在编译期就直接禁止了这种语法。
无法作为异常类型和instanceof关键字的判断类型,本质原因也与此相同。异常捕获和instanceof判断均为运行时行为:JVM在程序运行中捕获异常对象后,需匹配对应的catch块处理,这个过程依赖JVM对异常具体类型的精准识别。Exception<String>与Exception<Integer>在运行时会因泛型擦除完全无区别,JVM无法区分泛型异常类型的不同参数化实例,自然也无法在catch块中匹配对应的参数化类型。即便允许定义泛型异常,运行时也无法达到预期效果,因此Java从语法层面彻底禁止了这类用法。
协变
在解释为何不能创建参数化类型数组前,我们先做一个小测验:以下类型中,哪些可以相互进行类型转换(cast)?
List<String>、List<Object>、List<?>、Collection<String>、Collection<Object>、Collection<?>
提到类型转换,总体可分为基本数据类型转换、引用类型转换、自动装箱/拆箱及特殊场景转换。本测验中的类型转换特指引用类型转换,具体包括向上转型(upcast)和向下转型(downcast)。
向上转型是引用类型转换的特殊且简单形式,特指在类的继承体系中,将子类类型转换为父类类型(包括直接父类、间接父类,以及子类实现的接口类型),是Java多态特性的核心实现方式。
向上转型属于隐式类型转换,无需手动编写转换语法,由编译器自动完成,且转换过程绝对安全,不会抛出运行时异常。
1 | |
向下转型则是将指向子类对象的父类引用,重新赋值给子类引用,这种转换需要显式手动声明(添加(子类类型))。
1 | |
显然,上述测验的核心是判断多个参数化类型之间是否存在继承关系。
先看List<String>与List<Object>:这两个参数化类型不存在任何继承关系。这一点可能与直觉相悖——毕竟String是Object的子类,从语义上看,储存String的容器似乎也可看作储存Object的容器。这种直觉对应的特性在类型系统中被称为协变(covariance),即容器类型的继承关系与容器内元素类型的继承关系保持同向一致。简单来说:若子类类型可替换父类类型,那么“装载子类的容器”也可替换“装载父类的容器”,这就是协变。
Java中的数组支持协变,子类数组可直接赋值给父类数组(隐式向上转型),但这种特性存在运行时风险。
1 | |
这是因为数组是具体化类型(Reifiable Type),运行时会完整保留其元素类型信息。即便进行了协变赋值,数组的真实类型也不会丢失,JVM能精准识别数组的实际组件类型(元素类型)。每次向数组存入元素时,JVM都会对比待添加元素的实际类型与数组的真实组件类型,这个过程称为数组存储检查(array store check),若类型不兼容则直接抛出ArrayStoreException,确保数组内元素的同质性(homogenous),从源头阻止错误扩散。
1 | |
而Java泛型本质是编译时语法糖,编译后会执行类型擦除:所有参数化类型(如List<Animal>、List<Dog>)都会被擦除为原始类型List,JVM在运行时无法区分List<Dog>与List<Cat>,自然无法像数组那样校验元素类型。因此,若允许泛型协变,会导致容器内元素失去同质性,引发类型安全问题。
1 | |
Java数组的协变特性中,ArrayStoreException的报错行就是错误行为的发生行(即向Dog数组中存入了Cat)。而泛型若允许协变,错误要等到获取元素并强转时才会暴露,ClassCastException的报错行与错误根源行脱节,排查难度大幅增加。基于类型安全的核心目标,Java禁止泛型支持协变。
回到最初的测验,各类参数化类型的转换规则如下表所示。
| 转换方向 → 源类型 ↓ |
List<String> |
List<Object> |
List<?> |
Collection<String> |
Collection<Object> |
Collection<?> |
|---|---|---|---|---|---|---|
List<String> |
✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
List<Object> |
❌ | ✅ | ✅ | ❌ | ✅ | ✅ |
List<?> |
❌ | ❌ | ✅ | ❌ | ❌ | ✅ |
Collection<String> |
❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
Collection<Object> |
❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
Collection<?> |
❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
再来看List<String>与Collection<String>:这两个参数化类型存在继承关系。总体而言,参数化类型的继承关系需满足以下条件:
- 原始类型之间必须存在继承关系;
- 类型实参需完全相同,或目标参数化类型使用通配符且源类型的类型实参符合通配符约束。
关于通配符的详细内容,限于篇幅,将在下一篇中展开说明。
回到核心话题——为何不允许创建元素类型为参数化类型的数组。由于Java数组支持协变,若允许创建List<String>[],则可将其协变为Object[]。但受类型擦除影响,数组仅能识别元素类型为List,无法感知泛型参数String,因此可向数组中存入其他泛型参数的List(如List<Integer>),导致数组元素失去同质性。当从数组中取出元素并强转为List<String>时,就会抛出ClassCastException。因此,Java禁止创建泛型数组,本质是为了规避这种类型安全风险。
1 | |
总结
Java泛型是编译时语法糖,编译完成后会擦除所有类型参数和实参信息,仅保留原始类型。理解“泛型是编译时行为”这一核心,是掌握泛型各类特性的关键。
Java泛型不支持协变,即容器内元素的类型继承关系,与容器本身的类型继承关系相互独立、无关联。