背景
最近在查阅泛型擦除文档时,发现有些文章中介绍泛型擦除发生在类加载的连接验证阶段,对此持怀疑态度并进行了本地验证,最终验证结果是:泛型擦除是发生在编译阶段。
本篇文章主要介绍泛型相关的理论知识以及验证泛型擦除过程。主要内容如下
理论部分:
- 泛型基础理论知识;
- 为什么会有泛型擦除;
- class文件加载过程
实践部分:
- 查看编译文件
- 泛型参数擦除验证
- Signature属性
- LocalVariableTypeTable属性
理论
什么是泛型
泛型(Generics)是编程语言中的一种支持,它允许在定义类、接口、方法时不指定具体的数据类型,而是使用一个或多个类型参数(type parameters),这些参数在创建类,接口或方法的实例时再指定具体的类型。所以泛型的本质仍是参数化类型或者参数化多态的一种应用,泛型让程序员能够针对泛化的数据类型编写相同的算法,这极大的增强了编程语言的类型系统和抽象能力(定义参考《深入浅出Java虚拟机》)。
目前在Java、C#、C++等编程语言中均有支持泛型,泛型具体如下几个关键特点:
-
类型安全
泛型提供了编译时的类型检查,可以防止运行时的ClassCastException(类转换异常)。
-
消除类型转换
使用泛型可以减少或消除代码中的显式类型转换,使得代码更加简洁和易于维护。
-
代码复用
泛型允许编写与数据类型无关的代码,这意味着同一段代码可以用于不同的数据类型,从而提高了代码的复用性。
-
性能
泛型避免了装箱和拆箱操作,因为不需要将基本数据类型转换为它们的包装类,这可以提高程序的性能。
-
泛型擦除
在Java中,泛型信息在运行时会被擦除,这是为了保持兼容性。这意味着在运行时,所有的泛型类型参数都会被替换为它们的边界(通常是Object类型),因此泛型类型信息在运行时不可用。
-
协变和逆变
泛型还支持协变和逆变,这允许在子类型关系中使用泛型类型,例如,List<? extends Number>可以持有任何Number的子类型的列表,而List<? super Integer>可以持有任何Integer的超类型的列表。
为什么会有泛型擦除
泛型擦除除了跟兼容性有关,还主要跟运行时效率有关。
- 兼容性
- Java泛型是在Java 5中引入的(2004年发布),而Java 5之前的版本并不支持泛型。为了确保新版本的Java 代码能够运行在旧版本的JVM上运行,Java设计者采用了泛型擦除机制。这样,泛型信息只在编译时存在,运行时被擦除,使得生成的字节码与Java 5之前的版本兼容。
- 运行时效率
- 如果JVM在运行时需要处理泛型信息,那么它必须为每个泛型实例化保留类型信息。这将导致JVM需要为每个泛型参数的不同实例创建不同的类,从而增加内存消耗和类加载的复杂性。泛型擦除允许JVM只处理一个类的单一版本,无论泛型参数是什么,这简化了类加载过程并提高了运行时效率。
- 安全性
- 泛型擦除确保了泛型代码在运行时不会引入新的安全问题。由于泛型信息在运行时不可用(在运行时全部替换成了Object),JVM不需要在运行进行复杂的类型检查,这降低了安全风险。
- 代码优化
- 泛型擦除允许编译器对代码进行更多的优化。由于编译器可以假设所有的泛型类型都是Object,它可以应用一些优化技术,如内联等,这些技术在处理具体类型时更有效率。
内联:是一种编译器优化技术,在编译时将函数代码直接插入到调用该函数的地方,而不是在运行时进行函数调用。这样做的目的是为了减少运行时函数调用的开销,包括栈帧的创建和销毁、参数的传递等等,从而提高程序的执行效率。注意,内联也不是万能的,它也可能会导致程序体积增大,因为相同的代码会在多个地方重复。
class文件加载过程
要搞清楚泛型擦除发生在哪个阶段,那我们首先得知道java类的生命周期情况,如下图所示
-
类加载
JVM的类加载(ClassLoader)负责将字节码文件加载到JVM中。其具体有包括 加载、连接、初始化 3个阶段。
- 加载: 查找和加载类的二进制数据。
- 连接: 包括验证、准备、初始化3个阶段:
- 验证: 确保加载的类信息符合JVM规范,没有安全问题。
- 准备: 为类的静态变量分配内存,并设置默认初始值。
- 解析: 将符号引用转换为直接引用。
- 初始化: 执行类构造器() 方法,初始化静态变量和静态代码块。
- 使用
- 一旦类被加载和连接,JVM就会执行类的代码,包括构造函数、方法调用等。
- 程序的执行是由JVM的执行引擎管理的,它负责执行字节码指令。
- 卸载
- 在程序运行过程中,不再被引用的对象会被JVM的垃圾回收器(Garbage Collector, GC)回收,释放内存资源。 一个类的所有对象都被垃圾回收,且没有静态引用指向该类时,类加载器可以卸载该类,释放其占用的内存。
关于类加载的详细介绍可以参考《深入理解Java虚拟机》第3版的第7章-虚拟机类加载机制。
以上属于纯理论知识部分,我们还需要实践去验证它。
实践
查看编译文件(.class文件)中的类型
- 首先我们新建一个泛型类Test.java, 并且定义一个泛型成员变量var1、一个Object类型成员变量var2,、一个set泛型方法. 具体如下所示:
package com.yyt.memory;
public class Test<T>{
private T var1;
private Object var2;
public void set(T param) {
var1 = param;
var2 = null;
}
}
-
通过javac 命令编译testT.java文件,
执行:javac testT.java, 会在当前目录生成testT.class 字节码文件。
-
查看class文件信息
我们可以通过IDE工具打开class文件,也可以通过javap反编译class文件查看更具体的明细信息。但是两者之间在表现上会有一定差异。
-
IDE打开class文件
我们将上面生成的testT.class 通过AndroidStudio直接打开,如下图所示
截图中可以看到类名class Test, 变量 T var1都仍然是泛型,那是不是说明编译时并没有发生泛型擦除呢?答案是否定的。这是由于IDE工具的原因,通过文件的注释部分可以看出,为了便于阅读,testT.class文件被打开,然后通过解析和重新包装后展现在我们面前的仍然是泛型类型。我们不要被IDE欺骗而得出错误的结论。
-
javap反编译查看class文件
执行javap 命令
javap -v testT.class
内容如下:
public class com.yyt.memory.Test<T extends java.lang.Object> extends java.lang.Object
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #5.#21 // java/lang/Object."<init>":()V
#2 = Fieldref #4.#22 // com/yyt/memory/Test.var1:Ljava/lang/Object;
#3 = Fieldref #4.#23 // com/yyt/memory/Test.var2:Ljava/lang/Object;
#4 = Class #24 // com/yyt/memory/Test
#5 = Class #25 // java/lang/Object
#6 = Utf8 var1
#7 = Utf8 Ljava/lang/Object;
#8 = Utf8 Signature
#9 = Utf8 TT;
#10 = Utf8 var2
#11 = Utf8 <init>
#12 = Utf8 ()V
#13 = Utf8 Code
#14 = Utf8 LineNumberTable
#15 = Utf8 set
#16 = Utf8 (Ljava/lang/Object;)V
#17 = Utf8 (TT;)V
#18 = Utf8 <T:Ljava/lang/Object;>Ljava/lang/Object;
#19 = Utf8 SourceFile
#20 = Utf8 Test.java
#21 = NameAndType #11:#12 // "<init>":()V
#22 = NameAndType #6:#7 // var1:Ljava/lang/Object;
#23 = NameAndType #10:#7 // var2:Ljava/lang/Object;
#24 = Utf8 com/yyt/memory/Test
#25 = Utf8 java/lang/Object
{
public com.yyt.memory.Test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 9: 0
public void set(T);
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 var1:Ljava/lang/Object;
5: aload_0
6: aconst_null
7: putfield #3 // Field var2:Ljava/lang/Object;
10: return
LineNumberTable:
line 14: 0
line 15: 5
line 16: 10
Signature: #17 // (TT;)V
}
Signature: #18 // <T:Ljava/lang/Object;>Ljava/lang/Object;
SourceFile: "Test.java"
泛型参数擦除
-
第7行:#2 = Fieldref #4.#22 // com/yyt/memory/Test.var1:Ljava/lang/Object;
表示变量引用说明,从常量池中可以看出 #4表示Test类,#22表示var1变量,其类型是Object类型。从这里可以看到我们定义的泛型变量 T var1, 编译之后在class文件中已经擦除了泛型,转换为了Object类型。
-
第8行:#3 = Fieldref #4.#23 // com/yyt/memory/Test.var2:Ljava/lang/Object;
表示Object类型成员变量var2在class文件中的表示,从定义中可以看到var1 和 var2 变量是完全相同的变量类型(都被定义为Object类型了)。这也说明此时var1的类型确实已经被转为Object类型了。
-
第44行:descriptor: (Ljava/lang/Object;)V
void set 方法描述,可以看出其参数是Object类型,并且返回Viod,已经没有泛型信息了。
从上面描述可以看出,成员变量泛型类型,Set方法的泛型参数均已被擦除,转变为Object 类型。
签名信息
- 第61行:<T:Ljava/lang/Object;>Ljava/lang/Object;
这是类签名信息,表示Test类是一个泛型类,泛型参数都是Object的子类,Test继承自Object类,我们拆开来看
<T:Ljava/lang/Object;> 表示泛型参数T的边界,T是泛型类型参数,:表示边界的开始,Ljava/lang/Object;表示T的边界时java/lang/Object。这意味着T可以时任何继承自java/lang/Object的类型,包括Object本身。
Ljava/lang/Object:表示类的直接超类型。这里表示它继承自java/lang/Object。
-
第59行:(TT;)V
这是public void Set 方法的签名信息。(TT;) 表示方法有一个类型参数T,但它不包含T的具体类型信息。V表示方法返回void类型。
class字节码中的泛型信息主要存储在2个属性中:
-
Signature
这个属性包含了泛型签名,它描述了类、方法的泛型参数和泛型边界。这个签名使用一种称为泛型签名的语言来描述泛型信息,但它不包括具体的泛型参数类型,比如List 中的String。
-
LocalVariableTypeTable
LocalVariableTypeTable 属性用于描述栈帧中局部变量表的变量与Java源代码中定义的变量之间的关系,并且它保存了泛型信息。这在普通的LocalVariableTable属性中是不包含泛型信息的,因为Java泛型在编译后会进行类型擦除,LocalVariableTypeTable 通过使用Signature 来描述泛型类型,从而在运行时可以通过反射等机制获取泛型的具体类型信息。
LocalVariableTypeTable属性主要用于调试,只有在编译时指定调试才会在class文件中生成,我们执行下面指令来查看class文件
javac -g Test.java
javap -v Test.class
结果如下截图所示:
总结
经过上面验证,我们可以得出下面4点结论
-
java文件中的泛型参数在编译时均会被其边界对象替换,最常见的就是替换为Object类型;
-
class字节码文件中仍会有部分泛型信息。主要存在于Signature 和 LocalVariableTypeTable 这2个属性中。
-
Signature 属性中记录类、方法的签名信息。主要用于泛型的序列化和反射。
-
LocalVariableTypeTable 属性中会记录局部变量泛型信息,该属性仅在调试时才会生成。
最后结论:泛型擦除是在编译时进行,而非类加载的连接验证阶段。
我们下篇文章分享泛型的序列化(分析GSON的序列化和反序列源码)。