Java 泛型是怎么实现的?从类型擦除讲起
Java 5 之前集合里存的都是Object放进去的时候没人管你放的是什么取出来的时候靠开发者自己保证强转的类型是对的ListlistnewArrayList();list.add(hello);Integeri(Integer)list.get(0);这三行能正常编译跑到第三行抛ClassCastException。编译器在这里帮不上任何忙因为它看到的只有Object。泛型要解决的就是这件事把类型检查从运行期提到编译期顺带把代码里的强转全干掉ListStringlistnewArrayList();list.add(hello);Integerilist.get(0);// 编译期就报错到这里都是表面的东西。真正值得讲的是这些类型信息在编译之后去哪了因为 Java 的处理方式和 C# 完全不一样。类型擦除擦除之后长什么样拿一段最简单的代码publicclassErasure{publicStringfirst(ListStringlist){returnlist.get(0);}publicstaticvoidmain(String[]args){ListStringlistnewArrayList();list.add(hello);Stringslist.get(0);System.out.println(s);}}用javap -c反编译看first方法的字节码javap-c-pErasurepublic java.lang.String first(java.util.Listjava.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这里能直接看出三件事方法签名那一行还写着Listjava.lang.String那是 class 文件里Signature属性还原出来的不是字节码本身真正的调用是List.get:(I)Ljava/lang/Object;返回值是ObjectString已经没了编译器在get后面补了一条checkcast java/lang/String这就是我们代码里没写的那个强转add那条同理参数类型是Ljava/lang/Object;说明擦除之后add(String)变成了add(Object)。整个过程叫类型擦除Type Erasure规则很简单无界的类型参数擦成Object有界的擦成它的上界。classBoxTextendsNumber{privateTvalue;publicTget(){returnvalue;}}擦完之后T变成Number字段是Number value方法是Number get()。真实类型Integer只在编译器脑子里存在用于插入强转。为什么要擦除Java 5 引入泛型的时候List、Map这些集合类已经存在五年了线上跑着大量用原始类型raw type写的代码还有一堆已经编译好的 class 文件。如果泛型做成运行期具现化reifiedC# 的做法ListString和List就是两个不同的类型老代码在新 JVM 上直接跑不了所有库都得重编译整个生态要跟着动。擦除绕开了这个问题ListString编译完还是List新代码和老代码在字节码层面是同一个东西互操作成本为零。代价就是运行期拿不到类型参数下面那一串限制全是从这里来的。C# 没有这个历史包袱它的泛型是运行期具现化的Listint和Liststring在运行时是不同的类型能typeof(Listint)基本类型还能直接当类型参数不用装箱。擦除带来的限制这一串限制看起来零散根因只有一个运行期不知道T是什么。写法为什么不行new T()擦除后变成new Object()不是想要的那个类型new T[10]数组在运行期要记住自己的元素类型ArrayStoreException就靠它擦除后记不住T.class、instanceof T运行期没有T这个类型对象Listint擦除后是ListObjectObject装不下基本类型只能用包装类static T field静态成员属于类而类型参数属于实例catch (MyExceptionT e)JVM 的异常表只认具体的类型用ListString和ListInteger重载擦除后两个方法的签名都是f(List)是同一个方法static那条单独说一下它最能体现擦除的本质。BoxString和BoxInteger在运行期是同一个类静态区只有一份。如果允许static T instance;那这个字段到底该是String还是Integer就说不清了因为两个不同的Box共用它。重载那条的报错很直白实际编译一下看看publicclassOverload{publicvoidf(ListStringlist){}publicvoidf(ListIntegerlist){}}error: name clash: f(ListInteger) and f(ListString) have the same erasurenew T[]有替代方案传一个ClassT进来用Array.newInstance创建或者干脆让调用方传数组进来。Collection.toArray(T[])用的就是后一种签名里那个数组参数不是为了装数据是为了把运行期的类型信息带进来。ListStringlistnewArrayList();String[]arrlist.toArray(newString[0]);// 必须传否则只能拿到 Object[]桥接方法这是擦除最容易被忽略的副作用。publicclassNodeT{privateTdata;publicTget(){returndata;}}publicclassStringNodeextendsNodeString{OverridepublicStringget(){returnx;}}擦除之后父类的方法签名是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 NumberInteger是Number的子类但ListInteger不是ListNumber的子类。泛型是不变的invariant。假设允许这么写会怎样ListIntegerintsnewArrayList();ListNumbernumsints;// 假设允许nums.add(3.14);// 往 ListNumber 里加 Double完全合法Integeriints.get(0);// 取出来是 Double炸了问题出在nums.add(3.14)这行它借nums这个名字绕过了ints的类型检查回头ints里就混进了Double。所以泛型选择了不变宁可严一点。对比数组数组是协变的Integer[]确实是Number[]的子类型Integer[]intsnewInteger[10];Number[]numsints;// 允许nums[0]3.14;// 运行时抛 ArrayStoreException数组把类型检查放在了运行期靠每条astore指令去核对出问题才抛异常。泛型把检查放在了编译期代码根本编译不过。同样的问题泛型更早发现这也是一般推荐用集合而不是数组的原因之一。通配符和 PECS不变带来的麻烦是一个接收ListNumber的方法没法接收ListInteger哪怕只是读一读。通配符就是来放松这个限制的。写法能读吗能写吗读出来的类型List? extends Number能不能NumberList? 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是照这个写的publicstaticTvoidcopy(List?superTdest,List?extendsTsrc)src是数据来源只读用extendsdest是落点只写用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 真正用来做方法解析的已经擦成了ObjectSignature是给编译器、反射和 IDE 看的里面TT;就是类型参数T。所以反射有两套 API一套拿擦除后的一套拿带泛型的publicclassReflect{publicListStringgetNames(){returnnull;}publicstaticvoidmain(String[]args)throwsException{MethodmReflect.class.getMethod(getNames);System.out.println(getReturnType() - m.getReturnType());System.out.println(getGenericReturnType() - m.getGenericReturnType());}}getReturnType() - interface java.util.List getGenericReturnType() - java.util.Listjava.lang.String同一个方法两个方法返回的东西不一样差的就是Signature属性。这个特性被拿来绕过擦除。最经典的是匿名子类捕获泛型Gson 的TypeToken就是这么做TypetypenewTypeTokenListString(){}.getType();new TypeTokenListString(){}创建的是TypeToken的一个匿名子类它 extends 的是TypeTokenListString这个父类签名带着泛型信息写进了子类的 class 文件里。所以getGenericSuperclass()能把它完整读出来Gson 就拿到了ListString。Jackson 的TypeReference、Spring 的ParameterizedTypeReference是同一个套路用法上也要求写成匿名子类的形式原因就在这。