泛型擦除到底发生在哪个阶段?

283 阅读11分钟

背景

最近在查阅泛型擦除文档时,发现有些文章中介绍泛型擦除发生在类加载的连接验证阶段,对此持怀疑态度并进行了本地验证,最终验证结果是:泛型擦除是发生在编译阶段。

本篇文章主要介绍泛型相关的理论知识以及验证泛型擦除过程。主要内容如下

理论部分:

  • 泛型基础理论知识;
  • 为什么会有泛型擦除;
  • class文件加载过程

实践部分:

  • 查看编译文件
  • 泛型参数擦除验证
  • Signature属性
  • LocalVariableTypeTable属性

理论

什么是泛型

泛型(Generics)是编程语言中的一种支持,它允许在定义类、接口、方法时不指定具体的数据类型,而是使用一个或多个类型参数(type parameters),这些参数在创建类,接口或方法的实例时再指定具体的类型。所以泛型的本质仍是参数化类型或者参数化多态的一种应用,泛型让程序员能够针对泛化的数据类型编写相同的算法,这极大的增强了编程语言的类型系统和抽象能力(定义参考《深入浅出Java虚拟机》)。

目前在Java、C#、C++等编程语言中均有支持泛型,泛型具体如下几个关键特点:

  1. 类型安全

    泛型提供了编译时的类型检查,可以防止运行时的ClassCastException(类转换异常)。

  2. 消除类型转换

    使用泛型可以减少或消除代码中的显式类型转换,使得代码更加简洁和易于维护。

  3. 代码复用

    泛型允许编写与数据类型无关的代码,这意味着同一段代码可以用于不同的数据类型,从而提高了代码的复用性。

  4. 性能

    泛型避免了装箱和拆箱操作,因为不需要将基本数据类型转换为它们的包装类,这可以提高程序的性能。

  5. 泛型擦除

    在Java中,泛型信息在运行时会被擦除,这是为了保持兼容性。这意味着在运行时,所有的泛型类型参数都会被替换为它们的边界(通常是Object类型),因此泛型类型信息在运行时不可用。

  6. 协变和逆变

    泛型还支持协变和逆变,这允许在子类型关系中使用泛型类型,例如,List<? extends Number>可以持有任何Number的子类型的列表,而List<? super Integer>可以持有任何Integer的超类型的列表。

为什么会有泛型擦除

泛型擦除除了跟兼容性有关,还主要跟运行时效率有关。

  1. 兼容性
  • Java泛型是在Java 5中引入的(2004年发布),而Java 5之前的版本并不支持泛型。为了确保新版本的Java 代码能够运行在旧版本的JVM上运行,Java设计者采用了泛型擦除机制。这样,泛型信息只在编译时存在,运行时被擦除,使得生成的字节码与Java 5之前的版本兼容
  1. 运行时效率
  • 如果JVM在运行时需要处理泛型信息,那么它必须为每个泛型实例化保留类型信息。这将导致JVM需要为每个泛型参数的不同实例创建不同的类,从而增加内存消耗和类加载的复杂性。泛型擦除允许JVM只处理一个类的单一版本,无论泛型参数是什么,这简化了类加载过程并提高了运行时效率
  1. 安全性
  • 泛型擦除确保了泛型代码在运行时不会引入新的安全问题。由于泛型信息在运行时不可用(在运行时全部替换成了Object),JVM不需要在运行进行复杂的类型检查,这降低了安全风险。
  1. 代码优化
  • 泛型擦除允许编译器对代码进行更多的优化。由于编译器可以假设所有的泛型类型都是Object,它可以应用一些优化技术,如内联等,这些技术在处理具体类型时更有效率。

内联:是一种编译器优化技术,在编译时将函数代码直接插入到调用该函数的地方,而不是在运行时进行函数调用。这样做的目的是为了减少运行时函数调用的开销,包括栈帧的创建和销毁、参数的传递等等,从而提高程序的执行效率。注意,内联也不是万能的,它也可能会导致程序体积增大,因为相同的代码会在多个地方重复。

class文件加载过程

要搞清楚泛型擦除发生在哪个阶段,那我们首先得知道java类的生命周期情况,如下图所示

3230688-20231220141558562-343933971.png
  1. 类加载

    JVM的类加载(ClassLoader)负责将字节码文件加载到JVM中。其具体有包括 加载、连接、初始化 3个阶段。

  • 加载: 查找和加载类的二进制数据。
  • 连接: 包括验证、准备、初始化3个阶段:
    • 验证: 确保加载的类信息符合JVM规范,没有安全问题。
    • 准备: 为类的静态变量分配内存,并设置默认初始值。
    • 解析: 将符号引用转换为直接引用。
  • 初始化: 执行类构造器() 方法,初始化静态变量和静态代码块。
  1. 使用
  • 一旦类被加载和连接,JVM就会执行类的代码,包括构造函数、方法调用等。
  • 程序的执行是由JVM的执行引擎管理的,它负责执行字节码指令。
  1. 卸载
  • 在程序运行过程中,不再被引用的对象会被JVM的垃圾回收器(Garbage Collector, GC)回收,释放内存资源。 一个类的所有对象都被垃圾回收,且没有静态引用指向该类时,类加载器可以卸载该类,释放其占用的内存。

关于类加载的详细介绍可以参考《深入理解Java虚拟机》第3版的第7章-虚拟机类加载机制。

以上属于纯理论知识部分,我们还需要实践去验证它。

实践

查看编译文件(.class文件)中的类型

  1. 首先我们新建一个泛型类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;
    }
}
  1. 通过javac 命令编译testT.java文件,

    执行:javac testT.java, 会在当前目录生成testT.class 字节码文件。

  2. 查看class文件信息

    我们可以通过IDE工具打开class文件,也可以通过javap反编译class文件查看更具体的明细信息。但是两者之间在表现上会有一定差异。

  • IDE打开class文件

    我们将上面生成的testT.class 通过AndroidStudio直接打开,如下图所示

    截屏2024-11-23 下午11.34.38.png

    截图中可以看到类名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个属性中:

  1. Signature

    这个属性包含了泛型签名,它描述了类、方法的泛型参数和泛型边界。这个签名使用一种称为泛型签名的语言来描述泛型信息,但它不包括具体的泛型参数类型,比如List 中的String。

  2. LocalVariableTypeTable

    LocalVariableTypeTable 属性用于描述栈帧中局部变量表的变量与Java源代码中定义的变量之间的关系,并且它保存了泛型信息。这在普通的LocalVariableTable属性中是不包含泛型信息的,因为Java泛型在编译后会进行类型擦除,LocalVariableTypeTable 通过使用Signature 来描述泛型类型,从而在运行时可以通过反射等机制获取泛型的具体类型信息。

    LocalVariableTypeTable属性主要用于调试,只有在编译时指定调试才会在class文件中生成,我们执行下面指令来查看class文件

    javac -g Test.java

    javap -v Test.class

    结果如下截图所示:

    截屏2024-11-24 上午1.51.42.png

总结

经过上面验证,我们可以得出下面4点结论

  1. java文件中的泛型参数在编译时均会被其边界对象替换,最常见的就是替换为Object类型;

  2. class字节码文件中仍会有部分泛型信息。主要存在于Signature 和 LocalVariableTypeTable 这2个属性中。

  3. Signature 属性中记录类、方法的签名信息。主要用于泛型的序列化和反射。

  4. LocalVariableTypeTable 属性中会记录局部变量泛型信息,该属性仅在调试时才会生成。

最后结论:泛型擦除是在编译时进行,而非类加载的连接验证阶段。

我们下篇文章分享泛型的序列化(分析GSON的序列化和反序列源码)。