泛型-来一起搞明白类型擦除~

前言

前几天了解到 Java 中的泛型是伪泛型,实际上是通过类型擦除实现的。

然后呢,我去了解了类型擦除的概念。发现很多文章,说明了他是啥,优缺点,但是很少讲明白具体的工作过程以及内部原理,所以有了这篇文章的诞生。

如果你有和我一样的困惑,并且非常期望能得到解答,那就继续看下去吧。

What-泛型&类型擦除是什么?

分析原理之前,先复习下泛型&类型擦除的知识,温故而知新嘛~

泛型是什么

泛型的本质是参数化类型(Parameterized Type)的应用,也就是说把所操作的数据类型指定为一个参数。这个参数类型可以用在类、接口、方法的创建中,分别称为泛型类、泛型接口、泛型方法。

这个概念,其实并不陌生,咱们常见的 List,Map 都使用了泛型,无形之中我们都有在使用。一般常用的泛型类型定义是有TEKV啥的。

为什么要出现了泛型

还没引进泛型的时候,类似的需求,就是通过2个技术点来实现类型泛化。

  • java.lang.Object是所有类型的父类,所以转型成任何对象都是有可能的。
  • 类型支持强制转换

但是呢,这种转换关系,只能在运行期间才能被验证是否正确,ClassCastException异常出现的概率就大大提高,而且只能通过人力避免。显然这种机制是有风险且很难改善的。所以就需要一个在编译期间就能保证类型安全的机制出现,所以就出现了泛型机制。

类型擦除是什么
类型擦除的本质就是将原有的类型参数替换成即非泛化的上界 (这个后面会详细介绍)

在编译成.class文件的时机,编译器擦除了泛型类型相关的信息,所以在运行时不存在任泛型类型相关的信息。这样Java就不需要产生新的类型字节码,
所有的泛型类型最终都是一种原始类型,在Java运行时根本就不存在泛型信息,所以说所以泛型技术实际上是java语言的一颗语法糖,Java实现的是一种
伪泛型机制,

例如:

//编译的时候,变为List<Object>
List<String> strList = new ArrayList<>();

//编译的时候,变为List<Object>
List<Integer> intList = new ArrayList<>();

Why-为什么泛型是由类型擦除来实现的?

这个涉及到泛型引入的历史。Java的泛型是在 jdk 1.5 引入的,在此之前已经过去了 10年,已经存在大量没有使用泛型的代码,所以为了能够让这些代码和泛型互用,所以采取了这种方式。总结来说,就是新功能考虑到移植兼容性,所做的决定。

这样做有缺点吗?
当然是有的呀,擦除之后失去了很多特性。
例如:

  • 不支持基本类型。
    原因是由于泛型类型擦除后,变成了java.lang.Object类型,这种方式对于基本类型如int/long/float等八种基本类型来说,就比较麻烦,因为Java无法实现基本类型到Object类型的强制转换。

  • 运行期间无法获取泛型实际类型

    • 无法通过 instanceof 判断
    • 无法通过 getClass 判断实际类型
  • 为了保证类型安全,不能使用泛型类型参数创建实例
    例如: T object=new T() ;不合法

  • 不能声明泛型实例数组,这会导致运行错误
    例如: T[] numbers= new T[capcity];不合法

  • 无法重载泛型参数的方法

  • 在静态的环境下不允许参数类型是泛型类型的
    由于泛型类的所有实例都有相同的运行时类,所以泛型类的静态变量和方法是被它的所有实例所共享的。既然是共享的你就没有必要再重新定义一样的泛型类型,那如果你不定义一样的泛型类型,又达不到共享(或者说是一致性),更没有必要让这种情况通过。所以,在静态环境了类的参数被设置成泛型是非法的。

  • 泛型类对象无法被抛出或捕获
    因为泛型类不能继承或实现Throwable接口及其子类。

How-具体是如何擦除泛型的?

该怎么探索这一切?

首先可以明确的是时机!

  • 擦除时机:.java->.class

这个工作,是编译器的工作,具体的执行方式一般是使用 javac命令是将源代码编程成class字节码文件

javac xxx.java

.class 文件是二进制格式,打开是下面这个样式的,正常人都只能看懂前面的的 cafe babe

cafe babe 0000 0039 001b 0a00 0200 0307
0004 0c00 0500 0601 0010 6a61 7661 2f6c
616e 672f 4f62 6a65 6374 0100 063c 696e
6974 3e01 0003 2829 5609 0008 0009 0700
//等等.....

所以还需要借助 javap 命令。

  • javap :JDK自带的反汇编器,可以查看java编译器为我们生成的字节码。通过它,我们可以对照源代码和字节码,从而了解很多编译器内部的工作

我们用到的命令是

//反编译class文件,并输出Java字节码,还有丰富的元数据
javap -c -s -p -l -verbose xxx.class

但是,有人会嫌这太麻烦了是不是!!!
如果你像我一样,用的IDE是Intellij idea, 会有更方便的方法。

  1. 选定要查看的 Java文件
  2. 确认已经在项目的out目录下生成了对应的 class 文件 (我的方式,是运行一下包下的随便一个可执行java类就OK了,肯定有其他方法吧但我不知道)
  3. View->Show Bytecode 就可以展示字节码啦

泛型类型被擦除成什么了?

看我们一个 Java 文件的例子实现

public class DRCommonGeneric<T> {
    T t;
}

查看字节码,发现泛型 T 被编译后,类型擦除为 Object

Java 源代码中的 List 就是使用的这种非限定泛型实现,

还记得上文说的么

类型擦除的本质就是将原有的类型参数替换成即非泛化的上界

类型擦除也是有规则的,非泛化的上界的意思是说,如果使用了泛型限定符 <T extends XClass>,会擦除为 XClass。多个的场景下<T extends AClass & BClass>,会默认擦除为第一个。

例如

public class DRGeneric<T extends Number> {
    T t;
}

擦除为:

也许你会问,那如果限定符<T super XClass>呢,哈哈,泛型类是不允许使用这种<T super XClass>修饰符的。

原因是只限定了下限,所以上限最高还是java.lang.Object, 类型擦除以上限为准,Object.class又是所有类型的父类型,所有类型就都可以作为 T,等于没限定,所以是没有意义的事情。

类型擦除是如果保证类型安全的

还是拿List<T> 举例,T 被擦除为 java.lang.Object,那代码逻辑中真正实例化的类型是怎么约束的呢?

例如:

List<String> list = new ArrayList();
list.add("aaaa");
System.out.println(list.get(0)); //list.get(0)得到是 Object,还是 String 呢?
list.add(1);//会报错,为什么呢?


看看字节码一探究竟

注意看图中标注的地方,编译器除了类型擦除,在必要之处,还插入类型转换以保持类型安全(checkCast()方法)

另外除了类型强转,如果涉及到使用泛型类型作为参数的方法,子类做了重写,编译器还会自动生成桥方法以在扩展时保持多态性(Bridge 关键字)

例如:


//父类
public class DRCommonGeneric<T> {
    T t;

    public void setT(T t) {
        this.t = t;
    }
}

//子类继承泛型类,将泛型实例化为String
public class DRString extends DRCommonGeneric<String> {

    //同时重写了setT
    @Override
    public void setT(String s) {
        super.setT(s);
    }
}

泛型真的全部被擦除了吗?

为啥有如此疑问,是因为泛型实际上是支持反射的!!!

拿上文中的 DRCommonGeneric.setT(T t)举例,虽然擦除为 object, 但是通过反射还是能拿到 T 这个我们定义的泛型类型,说明 T 这个参数类型也是在的。

    try {
        Method method = DRCommonGeneric.class.getMethod("setT", Object.class);
        Type[] types = method.getGenericParameterTypes();
        for (Type type : types) {
            System.out.println("参数类型:"+type);

        }
    } catch (NoSuchMethodException e) {
        e.printStackTrace();
    }

输出:

另外如果泛型声明的时候指定了具体的类型,也是可以拿到使用时定义的真实类型。
例如:

// Test.class 中声名了这个变量,T 被明切指定为String
DRCommonGeneric<String> dr = new DRCommonGeneric<>();

//反射获取 Test.class 中的 dr 变量
Field field = Test.class.getDeclaredField("dr");
//获取泛型参数真实类型
ParameterizedType pType = (ParameterizedType) field.getGenericType();
System.out.println(pType);

输出:
在这里插入图片描述

弱小无助的我,在对天呐喊:不是全部擦除了么?反射那是怎么拿到的!!!反射难道是牛到可以从无到有么?!!!

反射表示这个锅他不背。那咱们还是再仔细看看泛型的字节码实现。

通过搜索,我发现字节码文件中确实有泛型类型
在这里插入图片描述

都是signaturedeclaration 在用。原先看到添加了//注释,我就给忽略了,看来并非这么简单,再次触及知识盲区,我还是浅薄了啊。

截图中它放入注释中,实际上是 IDE 工具的优化,目的方便集中在 code 上去看了,实际上flags/signature都是属于元数据。(换个“反汇编”工具发现的这一点…),declaration 没有查询到,应该也是这个IDE的自我发挥,不用管,主要核心还是看 signature 的逻辑。

signature 是什么

在JVM中,所有的字段、方法、类都有属于自己的签名。比如方法签名由方法名、参数、访问修饰符等构成,用于确定唯一的方法。而类的签名主要是记录一些JVM类型系统以外的额外的类型信息,比如泛型的类型信息,JVM不支持泛型,但是提供了class signature来存储类泛型的类型信息。

在代码层面,实际上它是个属性,

JDK的源码中可以找到相关证据

  • java.lang.reflect.Field
  • java.lang.reflect.Method
  • java.lang.reflect.Constructor

其中都有一个成员变量 signature,注释都写的一摸一样,支持泛型和注解。

    // Generics and annotations support
    private transient String    signature;

可以看到 signature 就是个字符串,所以不同的签名有一定的解析规则,主要的逻辑在
sun.reflect.generics.parser.SignatureParser 类中。

签名的实现有两个,分别是方法签名MethodTypeSignature和类签名ClassSignature,变量虽然也有签名,但是相对来说比较简单,没有专门的解析类。

为了方便理解,咱们还用上文例子,进行介绍

方法签名:
方法声明:public void setT(T t)
签名字符串:(TT;)V

解析出来分为四个部分:

  • 泛型定义:无 (泛型方法才会有)
  • 参数类型: T
  • 返回值类型:V 无返回值
  • 异常类型:无

类签名:
类声明:public class DRCommonGeneric
签名字符串:<T:Ljava/lang/Object;>Ljava/lang/Object;
解析出来分为三个部分:

  • 泛型定义:<T:Ljava/lang/Object;>
  • 继承的类:无
  • 实现的借口:无

签名的创建和保存?

编译时就会生成签名。
这个就是例子的 class signature :
在这里插入图片描述

保存在常量池中

签名的解析时机?

咱们通过反射调用泛型有关的API时,例如 method.getGenericParameterTypes() 就会触发到签名的解析,以拿到正确的参数类型。

例如反射调用setT(T t)方法, 主要流程就是:

  1. 通过 hasGenericInformation 判断方法签名中(TT;)V是否有存在泛型
  2. 通过 getGenericInfo() 解析方法签名字符串,填充到对应的变量 tree 中 。
  3. computeParameterTypes 方法中,将解析好的方法签名 tree,通过 getParameterTypes 获取到方法参数类型数组,也就是 [T]。
  4. 此时还不知道T的实现类是什么,所以再通过解析类签名<T:Ljava/lang/Object;>Ljava/lang/Object; ,获取T的实际类型java/lang/Object
  5. 放入parameterTypes 数组中返回

以上就是T这个类型参数的保存和解释,上面的例子中其实还举例了 DRCommonGeneric<String> 反射可以拿到String 而不是 Object。也是一样的道理,将泛型信息放入了 signature 中,从而得到了保存。

在这里插入图片描述

GSON 针对类型擦除的处理设计

著名的json解析工具 Gson,在反序列化的时候,也是使用同样的原理来支持泛型的序列化。

例如泛序列化List<T> 这类泛型集合:

List<xxx> resultList = gson.fromJson(json, new TypeToken<List<xxx>>() {
        }.getType());

new TypeToken<List<xxx>>() {}.getType() 这行代码,重点是要注意到大括号 {},这是说明它不是一个实例化了一个对象,而是创建了一个匿名内部类,而且继承于TypeToken
在这里插入图片描述

TypeToken 内部也是使用泛型反射的API,区别在于使用了getClass().getGenericSuperclass(),先获得带有泛型的父类,然后再去拿泛型。
在这里插入图片描述

这里特殊设计的地方是使用了匿名内部类的方式,为什么要这么设计呢?

目的是为了实现完整的泛型声明。

  • 使用匿名内部类实现:TypeToken<List<xxx>> typeToken = new TypeToken的匿名子类(); 声明了泛型
  • 无匿名内部类实现: new TypeToken<List<xxx>> 未声明泛型

需要明确的是,编译器只会将声明类型的泛型信息进行保存,放在 signature 属性中。

//这是一个完整的泛型使用方式
List<xxx> list = new ArrayList<>();

相对比而言,下面就没有声明泛型,用的是原始类型List,编译器不认为这个是个泛型需要特别处理

//左侧没有声明泛型类型<xxx>
List list = new ArrayList<xxx>();

字节码验证如下:

在这里插入图片描述

是不是又get到了一些小细节~

总结

类型擦除的工作过程为:

  1. 检查泛型类型,获取目标类型
  2. 擦除类型变量,并替换为限定类型
    • 如果泛型类型的类型变量没有限定(),则用Object作为原始类型
    • 如果有限定(),则用XClass作为原始类型
    • 如果有多个限定(T extends XClass1&XClass2),则使用第一个边界XClass1作为原始类
  3. 在必要时,插入类型转换以保持类型安全
  4. 在必要时,为泛型类的子类,生成桥方法以在扩展时保持多态性
  5. 编译字节码中新增了Signature属性记录泛型信息,保存在常量池中,来解决泛型参数识别问题,所以通过反射手段可以获取到参数化类型

另外需要注意,编译器只会将声明类型的泛型信息进行保存。开源工具 Gson 处理泛型识别的时候借助匿名内部类的方式解决了这个细节问题。

最后的面经分享

泛型是日常开发中经常接触到的知识,所以算是面试必问题吧。而且有些大厂考的很细(特别是字节)。
一面的时候会通过代码改错的方式,考虑你对泛型的理解,避免你照本宣科背书。

例如:

//编辑报错
Set<Bean> set =  new HashSet()

//应该明确泛型的实现类型
Set<Bean> set =  new HashSet<>()
List<String> strList =  new ArrayList<>()
List<Bean>  list= new ArrayList<>()

//说出result的值
boolean result = strList.getClass() == list.getClass()

//实际上类型擦除后,都是List
//就算问反射获取List内部的 elementData ,结果也是一样,因为都被擦除为 java.lang.Object

以上比较经典的面试题,当然还有其他的变种,等我看到值得参考的再补充吧,其实呢,原理都是一样的。咱们只有真正理解了,才能融会贯通,举一反三,任它东南西北风。

与君共勉~

参考资料

Java 泛型,你了解类型擦除吗?

作业5:Java编译原理

泛型(泛型擦除、泛型可以反射、泛型的限制和问题)

Java泛型-4(类型擦除后如何获取泛型参数)

IDE中 代码提示与跳转的原理是什么?

Java泛型的类型擦除始末,找回被擦除的类型

如何理解ByteCode、IL、汇编等底层语言与上层语言的对应关系?

java里JSON使用中TypeToken为什么要用匿名内部类创建的真正原因(泛型擦除)

  • 0
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值