Java泛型食用笔记(二) -- 类型擦除
在使用别人已经创建的泛型类时,你可能会感觉到泛型给你带来的诸多方便。但当你真正自己需要去实现一个泛型类,也许你会遇到许多令人惊讶的问题。理解 Java 泛型的实现有助于我们认识 Java 泛型的局限,以免浪费时间在无法实现的死胡同里。
1. 类型擦除的魔法
先来看一段代码
public class GenericTest01 {
public static void main(String[] args) {
Class c1 = new ArrayList<String>().getClass();
Class c2 = new ArrayList<Integer>().getClass();
System.out.println(c1 == c2);
}
}
output:
true
如果你没有了解 Java 泛型的原理,可能对运行结果有些疑惑。在之前使用的过程中,我们发现 ArrayList<String>
和 ArrayList<Integer>
行为是不一样的,比如你往 ArrayList<String>
中放入 Integer
是不合法的,这些表现很容易让人认为二者是不同的类,然而冰冷的结果却证明这两个类是同一个类。
Java 的泛型是通过 擦除 来实现的,事实上,ArrayList<String>
和 ArrayList<Integer>
类都被擦除称为原生类 ArrayList
。这也就意味着,在泛型代码内部,无法获取任何有关泛型参数的信息,比如你无法知道你的参数类型有那些成员和构造函数等。
Java 采用擦除的方式来实现泛型是一种折中。在 JDK5 出现前,Java 已经广泛应用了,而 Java 一直强调二进制向后兼容,也就是低版本 JVM 上能正常运行的 Class 文件,在高版本的 JVM 上也能正常运行,擦除使这种兼容性成为可能,采用擦除后编译出的字节码几乎没有变化,这就保证的之前的二进制文件也能在支持泛型的 Java 版本中运行。
你只能在静态类型检查期间感觉到泛型类型的存在,而在运行时,所有的泛型类型都被替换为上界类型。例如 List<T> 擦除为 List,所有 T 都被替换为 Object。
2. 擦除的痕迹
在来看一段代码
public class GenericTest02<T> {
public List<T> list;
public Map<String, T> map;
public <U> U genericMethod(Map<T, U> m) {
return null;
}
public static void main(String[] args) throws NoSuchFieldException {
System.out.println(GenericTest02.class.getField("list").toGenericString());
System.out.println(GenericTest02.class.getField("map").toGenericString());
}
}
output:
public java.util.List<T> GenericTest02.list
public java.util.Map<java.lang.String, T> GenericTest02.map
在经过之前 Java 的泛型通过擦除实现的洗脑之后,看到这个输出又会再次陷入疑惑。这个时候为什么能拿到类型参数的具体类型呢,不应该所有类型信息都已经擦除了吗。
我们来看下这段代码的字节码,(JDK8 下用 java -p -s -c
生成):
public class GenericTest02<T> {
public java.util.List<T> list;
descriptor: Ljava/util/List;
public java.util.Map<java.lang.String, T> map;
descriptor: Ljava/util/Map;
public <U> U genericMethod(java.util.Map<T, U>);
descriptor: (Ljava/util/Map;)Ljava/lang/Object;
...
}
JDK5 之后,字节码中虽然进行了类型擦除,但还保留了类型参数的信息,只是这里保留的是源码里写的类型参数信息,例如你用的 <T>
保留的就是 T,并不是保留运行时的实际类型。
按照 R大 的描述,Java 的泛型规律是:
- 位于声明一侧,源码里写了什么运行时就能看到什么
- 位于使用一侧,源码里写了什么运行时都丢失了
所谓声明一侧包括,泛型类型(泛型类与泛型接口)声明、带有泛型参数的方法和域的声明,这些信息在 class 文件中都有保留。这些信息的保留原因我没有很确定的答案,如果有高手能知道依据麻烦告知一下,我初步猜想是在序列化和反序列化的时候可能可以用上。
但在使用一侧,泛型类型的信息都没有保留,我们看一个例子
public class GenericTest03 {
public static <U> void genericMethod(U m) {
List<U> list2 = new ArrayList<U>();
return;
}
public static void main(String[] args) throws NoSuchFieldException {
GenericTest03.genericMethod("test");
}
}
字节码:
public class GenericTest03 {
public static <U> void genericMethod(U);
descriptor: (Ljava/lang/Object;)V
Code:
0: new #2 // class java/util/ArrayList
3: dup
4: invokespecial #3 // Method java/util/ArrayList."<init>":()V
7: astore_1
8: return
public static void main(java.lang.String[]) throws java.lang.NoSuchFieldException;
descriptor: ([Ljava/lang/String;)V
Code:
0: ldc #4 // String test
2: invokestatic #5 // Method genericMethod:(Ljava/lang/Object;)V
5: return
...
}
可以看到,方法体内泛型局部变量,泛型方法的调用的泛型信息编译后都完全擦除了。
上述讨论可以看出,声明一侧会保留源码里使用的类型参数,例如声明 List<T>
, 只记录了 T
,但并没有地方记录 T
本身的实际类型。因此对带有未绑定的泛型变量的泛型类型获取其实际类型是不现实的。同时使用一侧不会保留任何泛型的类型信息,因此即使你使用时绑定了具体类型,任然无法获取泛型类型信息。
3. 案发地点
擦除移除了方法体内的类型信息,因此在边界上,即进入方法体时和离开方法体的地点,编译器进行了类型检查和类型转换操作。
再来一段代码:
public class GenericTest04<T> {
private T obj;
public T get() {
return this.obj;
}
public void set(T t) {
this.obj = t;
}
public static void main(String[] args) {
GenericTest04<String> holder = new GenericTest04<>();
holder.set("test");
String s = holder.get();
}
}
字节码:
public class GenericTest04<T> {
private T obj;
descriptor: Ljava/lang/Object;
...
public T get();
descriptor: ()Ljava/lang/Object;
Code:
0: aload_0
1: getfield #2 // Field obj:Ljava/lang/Object;
4: areturn
public void set(T);
descriptor: (Ljava/lang/Object;)V
Code:
0: aload_0
1: aload_1
2: putfield #2 // Field obj:Ljava/lang/Object;
5: return
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
Code:
0: new #3 // class GenericTest04
3: dup
4: invokespecial #4 // Method "<init>":()V
7: astore_1
8: aload_1
9: ldc #5 // String test
11: invokevirtual #6 // Method set:(Ljava/lang/Object;)V
14: aload_1
15: invokevirtual #7 // Method get:()Ljava/lang/Object;
18: checkcast #8 // class java/lang/String
21: astore_2
22: return
}
类型检查在字节码里没有表现,因为这是编译器在编译期间的工作;编译器在方法结束后,插入了类型转换的操作。即 main
函数中的第 18 行 checkcast
指令。
小结
本章我们讨论了泛型的实现是一种编译器擦除的魔术。并通过字节码分析了编译器擦除的信息及保留的信息。在掌握这些原理和基础后,就可以理解一些 Java 泛型的局限,比如,“拿不到 T 的实际类型”,“不能对 T 类型使用 instanceof”等问题。