性能文章>深入探索Java泛型的本质>

深入探索Java泛型的本质原创

1年前
267245
导读

为什么泛型擦除后仍可以获取类型信息,如何获取泛型类型,Java泛型与C++、Python中的有何区别,本文将为您揭开泛型的内幕。读完该篇文章,您可以了解到1.为什么需要泛型
2.Java代码在编译后是如何保存泛型信息的
3.Java泛型与C++、Python中的有何区别
4.如何动态获取泛型类型

1、Java为什么需要泛型?

泛型最众所周知的应用就是容器类,通常而言,我们只会用容器来存储一种类型的队形,泛型的主要目的之一就是用来指定容器要持有什么类型的对象,由编译器来保证类型的正确性。
Java在泛型出现之前,只能通过Object来实现类型泛化,手动转换导致只有程序员和运行期间的JVM才知道这个Object究竟是什么类型的对象。编译期间也无法检查Object强转是否成功的,这样不可避免的在运行期抛出更多的ClassCaseException。
如果有泛型,除了可以解决以上问题,同时也可以让不同的类型对象作用于同一个方法了。

2、泛型

泛型的本质是参数化类型(Parameterized Type),即可用通过参数指定操作的数据类型。促成泛型出现最引人注明的一个原因就是为了创造容器类。
Java中的泛型:
  • 泛型类:参数类型用于类中;

  • 泛型接口:参数类型用于接口中;

  • 泛型方法:参数类型用于方法中。

本文不详细讲解泛型的使用,感兴趣的朋友可以阅读这几篇文章:
  • Java笔记 – 泛型 泛型方法 泛型接口 擦除 边界 通配符(1)
    https://www.itzhai.com/java-bi-ji-fan-xing-fan-xing-fang-fa-fan-xing-jie-kou-ca-chu-bian-jie-tong-pei-fu.html

  • Java笔记 – 泛型 泛型方法 泛型接口 擦除 边界 通配符(2)
    https://www.itzhai.com/generic-method-interface-border-wildcard-2.html

  • Java基础笔记 - Java中的泛型使用详细介绍
    https://www.itzhai.com/java-based-notebook-java-generics-use-the-detailed.html

这里我们来探索我们的主题,Java泛型在代码编译和执行过程中的内幕,在字节码里面是怎么体现的。
 

3、探索Java泛型的本质

在引入了泛型之后,为了能够让虚拟机解析、反射等各种场景正确获取到参数类型,JCP组织修改了虚拟机规范,引入了 Signature LocalVariableTypeTable 新属性。

3.1、LocalVariableTypeTable

这里编写一个泛型类,探索其字节码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class GenericClass<GT1> {

private GT1 param1;

public GT1 getParam1() {
return param1;
}

public void setParam1(GT1 param1) {
this.param1 = param1;
}

}

class GenericClassTest extends GenericClass<Double> {
@Override
public Double getParam1() {
System.out.println(super.getParam1());
return super.getParam1();
}
}
编译之后,使用 javap -v 命令查看 GenericClass<GT1> 的反汇编信息,我们在实例构造器中的code中,发现有LocalVariableTypeTable:
LocalVariableTypeTable Attribute是方法的实例构造器的一个可选属性,如果方法中使用到了泛型,则会出现这个属性。
更多关于 LocalVariableTypeTable 的介绍: 4.7.14. The  LocalVariableTypeTable  Attribute
https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.7.14
接下来我们细看下 LocalVariableTypeTable

3.2、Signature

LocalVariableTypeTable 里面携带了一个具有类型的Signature签名。
对比可以发现,LocalVariableTable中也有Signature,不过LocalVariableTable中并不携带泛型信息。
这个Signature正是Java编译的时候生成的,用于标识对应的类、变量或者属性等的类型的签名。
这个Signature中除了原生类型,也保存了参数化类型(即泛型)的信息。
这样,即使在编译后,泛型的类型信息被擦除了,也能通过这个Signature获取到泛型的签名信息。
在Java中,不管使用到了具体的类型或者是泛型,都需要给定一个类型签名(Signature)。以下场景都会给定类型签名:
  • 具有通用或者具有参数化类型的超类或者超接口的类;

  • 方法中的通用或者参数化类型的返回值或者入参,以及方法的throw子句中的类型变量;

  • 任何类型、类型变量、或者参数化类型的字段、形式参数或者局部变量;

Signature的的命名:
使用遵循 JVM规范第4.3.1节 的语法指定签名。常见的字母简写含义如下:

上面的:
Lcom/itzhai/jvm/executeengine/编译期优化/泛型/GenericClass<TGT1;>;
表示实例的类名。可以发现这个类最后面跟了一个泛型类型名称 TGT1
在使用到了泛型的代码中,因为编译时并不知道最终执行的具体类型,所以会生成这个Signature用来表示泛型。Signature目前仅在Class的反射和编译阶段会用到。

3.2.1、Signature是怎么存储的

我们得重新复习下这篇文章了:Class文件16进制数字背后的秘密,这篇文章里面,我们知道了Class文件的结构如下:
JVM规范 4.7. Attributes 的Table 4.7-C. Predefined class file attributes (by location)表中,我们可以知道,Signature属性在ClassFile,field_info,method_info都可以存在,用于代表不同主体的类型签名信息。我们上面看到的是在method_info中的Signature。我们在上图标注一下,哪些地方会存储这个Signature:

3.3、擦除了泛型之后

上面 GenericClass<GT1> 类的 getParam1() setParam1() 方法编译完成后,得到的反汇编代码如下:
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
public GT1 getParam1();
descriptor: ()Ljava/lang/Object;
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field param1:Ljava/lang/Object;
4: areturn
LineNumberTable:
line 15: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/itzhai/jvm/executeengine/编译期优化/泛型/GenericClass;
LocalVariableTypeTable:
Start Length Slot Name Signature
0 5 0 this Lcom/itzhai/jvm/executeengine/编译期优化/泛型/GenericClass<TGT1;>;
Signature: #20 // ()TGT1;

public void setParam1(GT1);
descriptor: (Ljava/lang/Object;)V
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: aload_1
2: putfield #2 // Field param1:Ljava/lang/Object;
5: return
LineNumberTable:
line 19: 0
line 20: 5
LocalVariableTable:
Start Length Slot Name Signature
0 6 0 this Lcom/itzhai/jvm/executeengine/编译期优化/泛型/GenericClass;
0 6 1 param1 Ljava/lang/Object;
LocalVariableTypeTable:
Start Length Slot Name Signature
0 6 0 this Lcom/itzhai/jvm/executeengine/编译期优化/泛型/GenericClass<TGT1;>;
0 6 1 param1 TGT1;
Signature: #23 // (TGT1;)V
可以发现,方法中的泛型替换为了 Object,也就是进行类类型擦除,实际运行的时候,都是Object类型了。

3.4、通过反射从Signature中获取泛型信息

我们知道泛型在编译阶段编译器可以用来校验类型,一旦编译通过之后,会擦除泛型。到了运行期,泛型实际上是Object了,这个时候可以通过反射获取泛型信息。

3.4.1、获取泛型信息例子

我们尝试获取泛型:
1
2
GenericClass<Double> temp = new GenericClass<>();
System.out.println(Arrays.toString(temp.getClass().getTypeParameters()));
结果:
1
[GT1]
发现这里只是获取到了泛型的名称,并没有获取到实际的类型。
为什么???
原来 GenericClass Class文件里面只有
1
Signature: #24 // <GT1:Ljava/lang/Object;>Ljava/lang/Object;
可以看到,这里的Signature只是保留了GT1这个泛型名称,以及泛型擦除后的实际类型,并不能感知到运行期究竟创建了什么类型。
怎么才能获取到泛型的具体类型呢?
1
2
3
4
5
6
7
8
9
GenericClassTest genericClass = new GenericClassTest();
Class clazz = genericClass.getClass();
// getGenericSuperclass()获得带有泛型的父类
// Type是Java中所有类型的公共高级接口,包括原始类型、参数化类型、数组类型、类型变量和基本类型。
Type type = clazz.getGenericSuperclass();
ParameterizedType p = (ParameterizedType)type;
// getActualTypeArguments获取参数化类型的数组,泛型可能有多个
Class c = (Class) p.getActualTypeArguments()[0];
System.out.println(c);
结果:
1
class java.lang.Double
成功获取到了实际类型。
原因是 GenericClassTest 的ClassFile的attributes中包含了泛型的签名信息:
1
2
Signature: #20 // Lcom/itzhai/jvm/executeengine/编译期优化/泛型/GenericClass<Ljava/lang/Double;>;
SourceFile: "GenericClass.java"
这个签名信息是编译的时候可以根据 GenericClassTest 类生成的。
 

4、Java泛型与C++泛型有什么区别?

下面举一个《Thinking is Java》中的例子来说明。
查看下面的一段C++的泛型代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
using namespace std;

template<class T> class Manipulator {
T obj;
public:
Manipulator(T x) { obj = x; }
void manipulate() { obj.f(); }
};

class HasF {
public:
void f() { cout << "HasF::f()" << endl; }
};

int main() {
HasF hf;
Manipulator<HasF> manipulator(hf);
manipulator.manipulate();
} /* Output:
HasF::f()
C++编写的泛型,当模板被实例化时,模板代码知道其模板参数的类型,C++编译器将进行检查,如果泛型对象调用了一个当前实例化对象不存在的方法,则报一个编译期错误。例如上面的manipulate里面调用了obj.f(),因为实例化的HasF存在这个方法,所以不会报错。
而Java是使用擦除实现泛型的,在没有指定边界的情况下,是不能在泛型类里面直接调用泛型对象的方法的,如下面的例子:
1
2
3
4
5
6
7
8
9
10
public class Manipulator<T> {

private T obj;
public Manipulator(T x){
obj = x;
}
public void doSomething(){
obj.f(); // 编译错误
}
}
通过没有边界的obj调用f(),编译出错了,下面指定边界,让其通过编译:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Manipulator<T extends HasF> {

private T obj;
public Manipulator(T x){
obj = x;
}
public void doSomething(){
obj.f(); // 编译错误
}
}
class HasF{
public void f(){
System.out.println("HasF.f();");
}
}
上面的例子,把泛型类型参数擦除到了HasF,就好像在类的声明中用HasF替换了T一样。
 

5、Java泛型的弊端与改进思路

Java泛型中,当要在泛型类型上执行操作时,就会产生问题,因为擦除要求指定可能会用到的泛型类型的边界,以安全地调用代码中的泛型对象上的具体方法。这是对“泛化”概念的一种明显的限制,因为必须限制你的泛型类型,使他们继承自特定的类,或者特定的接口。在某些情况下,你最终可能会使用普通类或者普通接口,因为限定边界的泛型和可能会和指定类或接口没有任何区别。
某些编程语言提供的一种解决方法称为 潜在潜在机制 结构化类型机制 (鸭子类型机制:如果它走起来像鸭子,并且叫起来也像鸭子,那么你就可以将它当做鸭子对待。)
潜在类型机制 使得你可以横跨类继承结构,调用不属于某个公共接口的方法。因此,实际上一段代码可以声明:“我不关心你是什么类型,只要你可以speak()和sit()即可。”由于不要求具体类型,因此代码就可以更加泛化了。
两种支持潜在类型机制的语言:Python和C++。
下面一段选取自《Thinking is Java》的Python潜在类型机制的代码演示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Dog:
def speak(self):
print "Arf!"
def sit(self):
print "Sitting"
def repoduce(self)
pass

class Robot:
def speak(self):
print "Click!"
def sit(self):
print "Clank!"
def repoduce(self)
pass

def perform(anything):
anything.spead()
anything.sit()
perform的anything参数只是一个标示符,它必须能够执行perform()期望它执行的操作,因此这里隐含着一个接口,但是从来都不必显示地写出这个接口——它是潜在的。perform不关心其参数的类型,因此我们可以向它传递任何对象,只要该对象支持speak()和sit()方法,否则,得到运行时异常。
Java的泛型是JDK5之后才添加的,为了兼容旧版本,因此没有任何机会可以去实现任何类型的潜在类型机制。
 

6、Java中对缺乏类型机制的补偿

对于潜在类型机制的一种补偿,可以使用的一种方式是反射,《Thinking is Java》提供了如下的案例,其中perform()方法就是用了潜在类型机制:
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
class Mime {
public void walkAgainstTheWind() {}
public void sit() { System.out.println("Pretending to sit"); }
public void pushInvisibleWalls() {}
public String toString() { return "Mime"; }
}

class SmartDog {
public void speak() { System.out.println("Woof!"); }
public void sit() { System.out.println("Sitting"); }
public void reproduce() {}
}

class CommunicateReflectively {
public static void perform(Object speaker) {
Class<?> spkr = speaker.getClass();
try {
try {
Method speak = spkr.getMethod("speak");
speak.invoke(speaker);
} catch(NoSuchMethodException e) {
System.out.println(speaker + " cannot speak");
}
try {
Method sit = spkr.getMethod("sit");
sit.invoke(speaker);
} catch(NoSuchMethodException e) {
System.out.println(speaker + " cannot sit");
}
} catch(Exception e) {
throw new RuntimeException(speaker.toString(), e);
}
}
}

public class Chapter15_17_1 {

public static void main(String[] args) {
CommunicateReflectively.perform(new SmartDog());
CommunicateReflectively.perform(new Robot());
CommunicateReflectively.perform(new Mime());
}
}

/* Output:
Woof!
Sitting
Click!
Clank!
Mime cannot speak
Pretending to sit
*///:~

References

《Thinking in Java》
Java笔记 – 泛型 泛型方法 泛型接口 擦除 边界 通配符(1)
https://www.itzhai.com/java-bi-ji-fan-xing-fan-xing-fang-fa-fan-xing-jie-kou-ca-chu-bian-jie-tong-pei-fu.html
Java笔记 – 泛型 泛型方法 泛型接口 擦除 边界 通配符(2)
https://www.itzhai.com/generic-method-interface-border-wildcard-2.html?sdf
The Java® Virtual Machine Specification Java SE 8 Edition
https://docs.oracle.com/javase/specs/jvms/se8/html/index.html
欢迎关注微信公众号《Java架构杂谈》。
点赞收藏
分类:标签:
arthinking
请先登录,查看4条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步
5
4