浅谈JVM泛型

kotlin中的reified

最近公司开了一个新项目,让尝试用Kotlin来编写。所以这段时间一直在看Kotlin,其中在学习泛型的时候,看到reified关键字(具体化参数)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// kotlin code
inline fun <reified T> f0(): Class<T> {
return T::class.java
}

inline fun <reified T> f1(any: Any): Boolean {
return any is T
}

fun main() {
println(f0<String>()) // class java.lang.String
println(f0<Int>()) // class java.lang.Integer
println(f1<Int>("hello")) // false
println(f1<String>("world"))// true
}

上面的例子中被reified修饰的泛型T在运行时可以拿到具体化类型信息,瞬间有种真泛型的感觉。很遗憾是reified关键字只能在inline函数种使用。熟悉inline函数的朋友已经大概知道了实现的原理。被inline修饰的函数调用处在编译时都会对其进行展开。上面的代码编译展开后类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// kotlin code
inline fun <reified T> f0(): Class<T> {
return T::class.java
}

inline fun <reified T> f1(any: Any): Boolean {
return any is T
}

fun main() {
println(String::class.java)
println(Integer::class.java)
println("hello" is Int)
println("world" is String)
}

我们可以看出被编译后的代码,完全不存在函数f0/f1的调用,随着inline函数的编译时展开,其泛型T的调用处编译后也被具体类型所替代reified完全依赖inline函数的编译时展开所实现。我们在普通函数中并不能使用reified
关于kotlin的inline关键字,可以参见《kotlin中的inline》

Java中的泛型

Java从1.5版本开始引入泛型,泛型的引入使我们有了参数化类型,有了类型安全的容器。一直以来Java所使用的编译时擦除的方式来实现的泛型,在社区中经常被开发者诟病。泛型在编译时是如何进行擦除的?Java的泛型是否真的如此不堪?

现在假设程序员A开发了一个带有泛型的Pair

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//java code
public class Pair<K, V> {
private K key;
private V value;

public void put(K key, V value) {
this.key = key;
this.value = value;
}

public K getKey() {
return key;
}

public V getValue() {
return value;
}

@Override
public String toString() {
return String.format("[%s : %s]", key, value);
}
}

由于泛型的擦除上面的Pair类编译后其泛型都会被其上限的具体类型所替代
由于K,V没有指定上限,其上限则是Object

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Pair {
private Object key;
private Object value;

public void put(Object key, Object value) {
this.key = key;
this.value = value;
}

public Object getKey() {
return key;
}

public Object getValue() {
return value;
}

@Override
public String toString() {
return String.format("[%s : %s]", key, value);
}
}

这就是很多人通常意义上所理解的Java泛型擦除。

声明处的泛型信息会得到保留

如果程序员APair类构建为一个jar包扔给了程序员B,让其使用。那么Pair类中所定义的泛型能否对程序员B做到约束作用呢?有过开发经验的朋友一定知道这是可以的。程序员A程序员B的是jar包也就是编译后的字节码而不是不是源代码,所以我们可以得出一个结论:所有定义处的范型信息编译后在字节码中都会得到保留,一个很简单的例子,如果被擦除了,你所用到的第三方框架中定义的泛型信息将对你起不到约束作用。

在定义处的泛型被编译后都会通过signature保留

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// access flags 0x21
// signature <K:Ljava/lang/Object;V:Ljava/lang/Object;>Ljava/lang/Object;
// declaration: com/ybq/java/generic/type/Pair<K, V>
public class com/ybq/java/generic/type/Pair {

// access flags 0x1
// signature (TK;TV;)V
// declaration: void put(K, V)
public put(Ljava/lang/Object;Ljava/lang/Object;)V

// access flags 0x1
// signature ()TK;
// declaration: K getKey()
public getKey()Ljava/lang/Object;

signature中泛型信息是给编译器看的,用于检查代码。JVM在运行时不会用signature中泛型信息做类型检验检查。但是我们在运行时可以通过Class元数据读取signature中的泛型信息

1
2
3
4
5
6
System.out.println(Pair.class.getTypeParameters()[0]); //K
System.out.println(Pair.class.getTypeParameters()[1]); //V
System.out.println(Pair.class.getMethod("getKey", null).getGenericReturnType()); //K
System.out.println(Pair.class.getMethod("getValue", null).getGenericReturnType()); //V
System.out.println(Pair.class.getMethod("put", Object.class, Object.class).getGenericParameterTypes()[0]); //K
System.out.println(Pair.class.getMethod("put", Object.class, Object.class).getGenericParameterTypes()[1]); //V

上面的代码输出的结果

1
2
3
4
5
6
K
V
K
V
K
V

泛型在具体化时被擦除

1
2
3
Pair<String, Integer> pair = new Pair<String, Integer>();
pair.put("age", 18);
System.out.println(pair.getClass());\\ com.ybq.java.generic.type.Pair

上面的代码我们创建了一个Pair<K,V>类的实例,我们为泛型K V指定具体类型StringInteger。关于StringInteger的信息在编译后是真正的被彻底的擦除了,在运行时我们无法得到其任何相关信息。

Java中的泛型约束只是编译器在做,Jvm方面没有对泛型做任何支持,在运行时无论是new Pair<String, Integer>()还是new Pair<Integer,String>()或new Pair()三者无任何区别。我们可以通过反射或字节码注入等方式绕开编译器的限制,向new Pair<String, Integer>()put任何类型的数据。

C++中的模板

类似于Java的泛型在C++中叫做模板,其实现方式和宏定义inline的思想相同:编译时展开,也就是在编译时做代码膨胀。

1
2
Pair<String, Integer> pair0 = new Pair<String, Integer>();
Pair<Integer, String> pair1 = new Pair<Integer, String>();

上面的Java代码,倘若按照C++模版的实现方式做编译则是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public class SIPair {
private String key;
private Integer value;

public void put(String key, Integer value) {
this.key = key;
this.value = value;
}

public String getKey() {
return key;
}

public Integer getValue() {
return value;
}

@Override
public String toString() {
return String.format("[%s : %s]", key, value);
}
}

public class ISPair {
private Integer key;
private String value;

public void put(Integer key, String value) {
this.key = key;
this.value = value;
}

public Integer getKey() {
return key;
}

public String getValue() {
return value;
}

@Override
public String toString() {
return String.format("[%s : %s]", key, value);
}
}

...

SIPair pair0 = new SIPair();
ISPair pair1 = new ISPair();

按照C++模板的思想,编译时会对new Pair<String, Integer>()new Pair<Integer, String>()生成对应的其类型STPairTSPair。所以pair0pair1在运行时是两种不同类型。与Java所使用的擦除方式相比,模板方式在运行时有了具体类型,缺点则是生成的程序占用空间也会变大。

C#中的泛型

相对于Java和C++的实现,C#的泛型一直被成为真泛型,因为CLR(C#的编译后的中间码的运行环境,类似于JVM)认识泛型。在C#new Pair<string, int>()编译后的stringint都会得到保留。new Pair<string, int>()new Pair<int, string>()也是两种不同的类型,在运行由CLR为其生成不同的类型。不同于C++的编译时展开,C#的泛型则由CLR在运行时进行展开,也就是运行时膨胀。

手动做代码膨胀保留Java泛型具体化信息

上面我们提到Java的泛型在声明处的信息会得到保留,具体化时的信息会被完全擦除。那么我们有没有方法可以在运行时获取泛型的具体化信息呢?
我们可以通过匿名内部类的方式,手动做代码膨胀,把泛型的具体化信息转为泛型的声明信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class ReifiedTypePair<K, V> {
private K key;
private V value;

protected ReifiedTypePair() {
}


public Type getKeyType() {
return getGenericType(0);
}

public Type getValueType() {
return getGenericType(1);
}

public Type getGenericType(int index) {
Type superclass = getClass().getGenericSuperclass();
ParameterizedType parameterized = (ParameterizedType) superclass;
return parameterized.getActualTypeArguments()[index];
}

public void put(K key, V value) {
this.key = key;
this.value = value;
if (getKeyType() == String.class) {
this.key = (K) ((String) this.key).toUpperCase();
}
}

public K getKey() {
return key;
}

public V getValue() {
return value;
}

@Override
public String toString() {
return String.format("[%s : %s]", key, value);
}
}
public class Demo {
public static void main(String args[]) {
ReifiedTypePair reifiedTypePair = new ReifiedTypePair<String, Integer>() {};
reifiedTypePair.put("age", 18);
System.out.println(reifiedTypePair.getKeyType());
System.out.println(reifiedTypePair.getValueType());
System.out.println(reifiedTypePair);
}
}

上面的代码输出结果为:

1
2
3
class java.lang.String
class java.lang.Integer
[AGE : 18]

我们通过new ReifiedTypePair<String, Integer>(){}也就是匿名内部类的方式为ReifiedTypePair创建了一个实例。通过匿名类方式在编译时会为ReifiedTypePair<K,V>生成一个具体的匿名子类,相关的具体化泛型信息(<String,Integer>)也会伴随的子类的生成转化到声明处,上面的代码编译后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

...

class Demo$1 extends ReifiedTypePair<String, Integer> {
Demo$1() {
}
}

...

ReifiedTypePair reifiedTypePair = new Demo$1();
reifiedTypePair.put("age", 18);

...

我们在使用Gson对持有泛型信息的数据做反序列化的时候,使用的也是这种方式

1
2
List<String> data = new Gson().fromJson("", new TypeToken<List<String>>() {
}.getType());

Gson中我们通过TypeToken<T>获取泛型信息,TypeToken<T>的构造方法被定义为了protected,所以我们在使用时只能通过匿名内部类,或手动创建其子类来做实例化。目的就是为了让泛型信息保留在声明处。

总结

对于泛型,Java的实现是编译时擦除C++是在编译时膨胀,而C#则是在运行时膨胀。三种编程语言的实现方式各不相同。就Java为什么会采用编译时擦除的方式来实现,网上后很多讨论,我的主观看法是:有兼容性的考虑,最主要的原因还是简单省事,编译时擦除方式实现的泛型几乎没有虚拟机什么事,改改编译器就行了。当然泛型本身就是语言层面的概念,所以并没有真正意义上所谓的真泛型伪泛型。回到文章的标题,Jvm不认识泛型。