首页 最新 热门 推荐

  • 首页
  • 最新
  • 热门
  • 推荐

  • 24-11-26 09:06
  • 3391
  • 221279
juejin.cn

背景

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

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

理论部分:

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

实践部分:

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

理论

什么是泛型

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

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

  1. 类型安全

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

  2. 消除类型转换

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

  3. 代码复用

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

  4. 性能

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

  5. 泛型擦除

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

  6. 协变和逆变

    泛型还支持协变和逆变,这允许在子类型关系中使用泛型类型,例如,List可以持有任何Number的子类型的列表,而List可以持有任何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泛型方法. 具体如下所示:
js
代码解读
复制代码
package com.yyt.memory; public class Test{ 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

内容如下:

js
代码解读
复制代码
public class com.yyt.memory.Testextends 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."":()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 #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 // "":()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."":()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 // 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行:Ljava/lang/Object;

这是类签名信息,表示Test类是一个泛型类,泛型参数都是Object的子类,Test继承自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的序列化和反序列源码)。

注:本文转载自juejin.cn的程序员的自律生活的文章"https://juejin.cn/post/7440468601641631796"。版权归原作者所有,此博客不拥有其著作权,亦不承担相应法律责任。如有侵权,请联系我们删除。
复制链接
复制链接
相关推荐
发表评论
登录后才能发表评论和回复 注册

/ 登录

评论记录:

未查询到任何数据!
回复评论:

分类栏目

后端 (14832) 前端 (14280) 移动开发 (3760) 编程语言 (3851) Java (3904) Python (3298) 人工智能 (10119) AIGC (2810) 大数据 (3499) 数据库 (3945) 数据结构与算法 (3757) 音视频 (2669) 云原生 (3145) 云平台 (2965) 前沿技术 (2993) 开源 (2160) 小程序 (2860) 运维 (2533) 服务器 (2698) 操作系统 (2325) 硬件开发 (2492) 嵌入式 (2955) 微软技术 (2769) 软件工程 (2056) 测试 (2865) 网络空间安全 (2948) 网络与通信 (2797) 用户体验设计 (2592) 学习和成长 (2593) 搜索 (2744) 开发工具 (7108) 游戏 (2829) HarmonyOS (2935) 区块链 (2782) 数学 (3112) 3C硬件 (2759) 资讯 (2909) Android (4709) iOS (1850) 代码人生 (3043) 阅读 (2841)

热门文章

140
Android
关于我们 隐私政策 免责声明 联系我们
Copyright © 2020-2025 蚁人论坛 (iYenn.com) All Rights Reserved.
Scroll to Top