☰
Java 泛型是怎么实现的?从类型擦除讲起
2026/9/26 4:04:26 网站建设 项目流程

Java 5 之前,集合里存的都是Object,放进去的时候没人管你放的是什么,取出来的时候靠开发者自己保证强转的类型是对的:

Listlist=newArrayList();list.add("hello");Integeri=(Integer)list.get(0);

这三行能正常编译,跑到第三行抛ClassCastException。编译器在这里帮不上任何忙,因为它看到的只有Object。

泛型要解决的就是这件事,把类型检查从运行期提到编译期,顺带把代码里的强转全干掉:

List<String>list=newArrayList<>();list.add("hello");Integeri=list.get(0);// 编译期就报错

到这里都是表面的东西。真正值得讲的是这些类型信息在编译之后去哪了,因为 Java 的处理方式和 C# 完全不一样。

类型擦除

擦除之后长什么样

拿一段最简单的代码:

publicclassErasure{publicStringfirst(List<String>list){returnlist.get(0);}publicstaticvoidmain(String[]args){List<String>list=newArrayList<>();list.add("hello");Strings=list.get(0);System.out.println(s);}}

用javap -c反编译,看first方法的字节码:

javap-c-pErasure
public java.lang.String first(java.util.List<java.lang.String>); Code: 0: aload_1 1: iconst_0 2: invokeinterface #7, 2 // InterfaceMethod java/util/List.get:(I)Ljava/lang/Object; 7: checkcast #13 // class java/lang/String 10: areturn public static void main(java.lang.String[]); Code: ... 11: invokeinterface #20, 2 // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z ... 19: invokeinterface #7, 2 // InterfaceMethod java/util/List.get:(I)Ljava/lang/Object; 24: checkcast #13 // class java/lang/String 27: astore_2

这里能直接看出三件事:

  • 方法签名那一行还写着List<java.lang.String>,那是 class 文件里Signature属性还原出来的,不是字节码本身
  • 真正的调用是List.get:(I)Ljava/lang/Object;,返回值是Object,<String>已经没了
  • 编译器在get后面补了一条checkcast java/lang/String,这就是我们代码里没写的那个强转

add那条同理,参数类型是Ljava/lang/Object;,说明擦除之后add(String)变成了add(Object)。

整个过程叫类型擦除(Type Erasure),规则很简单:无界的类型参数擦成Object,有界的擦成它的上界。

classBox<TextendsNumber>{privateTvalue;publicTget(){returnvalue;}}

擦完之后T变成Number,字段是Number value,方法是Number get()。真实类型Integer只在编译器脑子里存在,用于插入强转。

为什么要擦除

Java 5 引入泛型的时候,List、Map这些集合类已经存在五年了,线上跑着大量用原始类型(raw type)写的代码,还有一堆已经编译好的 class 文件。

如果泛型做成运行期具现化(reified,C# 的做法),List<String>和List就是两个不同的类型,老代码在新 JVM 上直接跑不了,所有库都得重编译,整个生态要跟着动。擦除绕开了这个问题:List<String>编译完还是List,新代码和老代码在字节码层面是同一个东西,互操作成本为零。

代价就是运行期拿不到类型参数,下面那一串限制全是从这里来的。

C# 没有这个历史包袱,它的泛型是运行期具现化的,List<int>和List<string>在运行时是不同的类型,能typeof(List<int>),基本类型还能直接当类型参数,不用装箱。

擦除带来的限制

这一串限制看起来零散,根因只有一个:运行期不知道T是什么。

写法为什么不行
new T()擦除后变成new Object(),不是想要的那个类型
new T[10]数组在运行期要记住自己的元素类型(ArrayStoreException就靠它),擦除后记不住
T.class、instanceof T运行期没有T这个类型对象
List<int>擦除后是List<Object>,Object装不下基本类型,只能用包装类
static T field静态成员属于类,而类型参数属于实例
catch (MyException<T> e)JVM 的异常表只认具体的类型
用List<String>和List<Integer>重载擦除后两个方法的签名都是f(List),是同一个方法

static那条单独说一下,它最能体现擦除的本质。Box<String>和Box<Integer>在运行期是同一个类,静态区只有一份。如果允许static T instance;,那这个字段到底该是String还是Integer就说不清了,因为两个"不同的"Box共用它。

重载那条的报错很直白,实际编译一下看看:

publicclassOverload{publicvoidf(List<String>list){}publicvoidf(List<Integer>list){}}
error: name clash: f(List<Integer>) and f(List<String>) have the same erasure

new T[]有替代方案:传一个Class<T>进来用Array.newInstance创建,或者干脆让调用方传数组进来。Collection.toArray(T[])用的就是后一种,签名里那个数组参数不是为了装数据,是为了把运行期的类型信息带进来。

List<String>list=newArrayList<>();String[]arr=list.toArray(newString[0]);// 必须传,否则只能拿到 Object[]

桥接方法

这是擦除最容易被忽略的副作用。

publicclassNode<T>{privateTdata;publicTget(){returndata;}}publicclassStringNodeextendsNode<String>{@OverridepublicStringget(){return"x";}}

擦除之后,父类的方法签名是Object get(),子类的是String get()。JVM 判断一个方法有没有被覆盖,看的是方法名加描述符,返回类型也算在描述符里,所以这两个不是同一个方法,子类的String get()并没有覆盖父类的Object get()。就这么放着的话,通过Node引用调get()会解析到父类那个返回data的实现上去,多态就断了。

编译器在StringNode里自动生成了一个桥接方法(Bridge Method)把这条路接回去。javap -v -p StringNode看到的是:

public java.lang.String get(); descriptor: ()Ljava/lang/String; Code: 0: ldc #7 // String x 2: areturn public java.lang.Object get(); descriptor: ()Ljava/lang/Object; flags: (0x1041) ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC Code: 0: aload_0 1: invokevirtual #9 // Method get:()Ljava/lang/String; 4: areturn

两个get。上面那个是我们写的,下面那个是编译器生成的,flags里的ACC_BRIDGE和ACC_SYNTHETIC就是标记它身份的东西。它的方法体只有一件事,就是转调真正的String get(),这样父类引用调Object get()的时候才能落到子类的实现上。

ACC_SYNTHETIC的意思是"编译器生成、源码里没有",所以反射遍历方法时能过滤掉它,getDeclaredMethods()返回的数组里其实两个都在。

泛型不变性与通配符

为什么 List< Integer> 不是 List< Number>

Integer是Number的子类,但List<Integer>不是List<Number>的子类。泛型是不变的(invariant)。

假设允许这么写会怎样:

List<Integer>ints=newArrayList<>();List<Number>nums=ints;// 假设允许nums.add(3.14);// 往 List<Number> 里加 Double,完全合法Integeri=ints.get(0);// 取出来是 Double,炸了

问题出在nums.add(3.14)这行,它借nums这个名字绕过了ints的类型检查,回头ints里就混进了Double。所以泛型选择了不变,宁可严一点。

对比数组,数组是协变的,Integer[]确实是Number[]的子类型:

Integer[]ints=newInteger[10];Number[]nums=ints;// 允许nums[0]=3.14;// 运行时抛 ArrayStoreException

数组把类型检查放在了运行期,靠每条astore指令去核对,出问题才抛异常。泛型把检查放在了编译期,代码根本编译不过。同样的问题,泛型更早发现,这也是一般推荐用集合而不是数组的原因之一。

通配符和 PECS

不变带来的麻烦是:一个接收List<Number>的方法,没法接收List<Integer>,哪怕只是读一读。通配符就是来放松这个限制的。

写法能读吗能写吗读出来的类型
List<? extends Number>能不能Number
List<? super Integer>能能,写Integer或其子类Object

? extends Number的意思是"这是元素类型为Number或其子类的 List,但具体是哪个我不知道"。既然不知道实际是Integer还是Double,往里add任何东西都可能违反真实类型,所以编译器索性把写操作禁掉。反过来读是安全的,不管实际是哪个子类,它一定是Number的子类。

? super Integer是倒过来的:实际类型是Integer或它的父类,往里加Integer一定安全,因为Integer是实际类型的子类。但读出来的类型不确定,只能保证是Object。

PECS 是 Producer Extends, Consumer Super 的缩写,意思是只往外读的(生产者)用extends,只往里写的(消费者)用super。

JDK 里的Collections.copy是照这个写的:

publicstatic<T>voidcopy(List<?superT>dest,List<?extendsT>src)

src是数据来源,只读,用extends;dest是落点,只写,用super。这样Integer的列表可以拷进Number的列表,反过来不行,正好对应"读出来的东西要能放进写进去的地方"。

泛型信息其实没全丢

有个说法是"擦除之后泛型信息就没了",这话不准确。擦除擦掉的是方法描述符和字段上的类型参数,但类的泛型签名还在,存在 class 文件的Signature属性里。

看Node的 class 文件就很清楚:

public T get(); descriptor: ()Ljava/lang/Object; Signature: #19 // ()TT; Signature: #23 // <T:Ljava/lang/Object;>Ljava/lang/Object;

descriptor是 JVM 真正用来做方法解析的,已经擦成了Object;Signature是给编译器、反射和 IDE 看的,里面TT;就是类型参数T。

所以反射有两套 API,一套拿擦除后的,一套拿带泛型的:

publicclassReflect{publicList<String>getNames(){returnnull;}publicstaticvoidmain(String[]args)throwsException{Methodm=Reflect.class.getMethod("getNames");System.out.println("getReturnType() -> "+m.getReturnType());System.out.println("getGenericReturnType() -> "+m.getGenericReturnType());}}
getReturnType() -> interface java.util.List getGenericReturnType() -> java.util.List<java.lang.String>

同一个方法,两个方法返回的东西不一样,差的就是Signature属性。

这个特性被拿来绕过擦除。最经典的是匿名子类捕获泛型,Gson 的TypeToken就是这么做:

Typetype=newTypeToken<List<String>>(){}.getType();

new TypeToken<List<String>>(){}创建的是TypeToken的一个匿名子类,它 extends 的是TypeToken<List<String>>,这个父类签名带着泛型信息写进了子类的 class 文件里。所以getGenericSuperclass()能把它完整读出来,Gson 就拿到了List<String>。

Jackson 的TypeReference、Spring 的ParameterizedTypeReference是同一个套路,用法上也要求写成匿名子类的形式,原因就在这。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询