JVM速成手册

JVM相关内容
帅旋
关注
充电
IT宅站长,技术博主,共享单车手,全网id:arthinking。

深入探索Java泛型的本质 | 泛型

发布于 2023-12-01 | 更新于 2024-01-31

导读:

为什么泛型擦除后仍可以获取类型信息,如何获取泛型类型,Java泛型与C++、Python中的有何区别,本文将为您揭开泛型的内幕。

读完该篇文章,您可以了解到:

1.为什么需要泛型

2.Java代码在编译后是如何保存泛型信息的

3.Java泛型与C++、Python中的有何区别

4.如何动态获取泛型类型

1、Java为什么需要泛型?

泛型最众所周知的应用就是容器类,通常而言,我们只会用容器来存储一种类型的队形,泛型的主要目的之一就是用来指定容器要持有什么类型的对象,由编译器来保证类型的正确性。

Java在泛型出现之前,只能通过Object来实现类型泛化,手动转换导致只有程序员和运行期间的JVM才知道这个Object究竟是什么类型的对象。编译期间也无法检查Object强转是否成功的,这样不可避免的在运行期抛出更多的ClassCaseException。

如果有泛型,除了可以解决以上问题,同时也可以让不同的类型对象作用于同一个方法了。

2、泛型

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

Java中的泛型:

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

本文不详细讲解泛型的使用,感兴趣的朋友可以阅读这几篇文章:

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

3、探索Java泛型的本质

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

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:

image-20200201181343000

这个是方法的实例构造器的一个可选属性,如果方法中使用到了泛型,则会出现这个属性。

更多关于LocalVariableTypeTable的介绍:4.7.14. The LocalVariableTypeTable Attribute

接下来我们细看下LocalVariableTypeTable

3.2、Signature

LocalVariableTypeTable里面携带了一个具有类型的Signature签名。

对比可以发现,LocalVariableTable中也有Signature,不过LocalVariableTable中并不携带泛型信息。

这个Signature正是Java编译的时候生成的,用于标识对应的类、变量或者属性等的类型的签名。

这个Signature中除了原生类型,也保存了参数化类型(即泛型)的信息。

这样,即使在编译后,泛型的类型信息被擦除了,也能通过这个Signature获取到泛型的签名信息。

在Java中,不管使用到了具体的类型或者是泛型,都需要给定一个类型签名(Signature)。以下场景都会给定类型签名:

  • 具有通用或者具有参数化类型的超类或者超接口的类;
  • 方法中的通用或者参数化类型的返回值或者入参,以及方法的throw子句中的类型变量;
  • 任何类型、类型变量、或者参数化类型的字段、形式参数或者局部变量;

Signature的的命名:

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

FieldType term Type Interpretation
B byte signed byte
C char Unicode character code point in the Basic Multilingual Plane, encoded with UTF-16
D double double-precision floating-point value
F float single-precision floating-point value
I int integer
J long long integer
L ClassName ; reference an instance of class ClassName
S short signed short
Z boolean true or false
[ reference one array dimension

上面的:

Lcom/itzhai/jvm/executeengine/编译期优化/泛型/GenericClass<TGT1;>;

表示实例的类名。可以发现这个类最后面跟了一个泛型类型名称TGT1

在使用到了泛型的代码中,因为编译时并不知道最终执行的具体类型,所以会生成这个Signature用来表示泛型。Signature目前仅在Class的反射和编译阶段会用到。

3.2.1、Signature是怎么存储的

我们得重新复习下这篇文章了:Class文件十六进制背后的秘密,这篇文章里面,我们知道了Class文件的结构如下:

image-20200201194731846

JVM规范 4.7. Attributes 的**Table 4.7-C. Predefined class file attributes (by location)**表中,我们可以知道,Signature属性在ClassFile,field_info,method_info都可以存在,用于代表不同主体的类型签名信息。我们上面看到的是在method_info中的Signature。我们在上图标注一下,哪些地方会存储这个Signature:

image-20200201200511595

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]

发现这里只是获取到了泛型的名称,并没有获取到实际的类型。

为什么???

原来GenericClassClass文件里面只有

1
Signature: #24                          // <GT1:Ljava/lang/Object;>Ljava/lang/Object;

可以看到,这里的Signature只是保留了GT1这个泛型名称,以及泛型擦除后的实际类型,并不能感知到运行期究竟创建了什么类型,所以这里直接输出了泛型的名称。

在进行类型擦除时,如果泛型类的类型参数没有指定上限(T extends xxx),那么会被擦除为Object类型,如果指定了上限,那么会被擦除为相应的上限类型。

怎么才能获取到泛型的具体类型呢?

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
20
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)

Java笔记 – 泛型 泛型方法 泛型接口 擦除 边界 通配符(2)

Class文件十六进制背后的秘密

The Java® Virtual Machine Specification Java SE 8 Edition

本文作者: 帅旋

本文链接: https://www.itzhai.com/columns/jvm/exploring-the-nature-of-java-generics.html

版权声明: 版权归作者所有,未经许可不得转载,侵权必究!联系作者请加公众号。

×
IT宅

关注公众号及时获取网站内容更新。