深入理解Java泛型(一)
作者:小牛呼噜噜 | https://xiaoniuhululu.com
计算机内功、JAVA底层、面试相关资料等更多精彩文章在公众号「小牛呼噜噜 」
什么是Java泛型
Java 泛型(generics)是 Jdk 5 中引入的一个新特性, 泛型提供了编译时类型安全检测机制
, 该机制允许程序员在编译时检测到非法的类型。
比如 ArrayList<String> list= new ArrayList<String>()
这行代码就指明了该 ArrayList 对象只能 存储String
类型,如果传入其他类型的对象就会报错。
让我们时光回退到Jdk5的版本,那时ArrayList
内部其实就是一个Object[] 数组,配合存储一个当前分配的长度,就可以充当“可变数组”:
1 | public class ArrayList { |
我们来举个简单的例子,
1 | ArrayList list = new ArrayList(); |
我们本意是用ArrayList来装String类型的值
,但是突然混进去了Integer类型的值
,由于ArrayList底层是Object数组,可以存储任意的对象,所以这个时候是没啥问题的,但我们不能只存不用啊,我们需要把值给拿出来使用,这个时候问题来了:
1 | for(Object item: list) { |
结果:
Exception in thread “main” java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String
由于我们需要String类型的值,我们需要把ArrayList的Object值强制转型,但是之前混进去了Integer ,虽然编译阶段通过了,但程序的运行结果会以崩溃结束,报ClassCastException异常
为了解决这个问题,在Jdk 5版本中就引入了泛型的概念,而引入泛型的很大一部分原因就是为了解决我们上述的问题,允许程序员在编译时检测到非法的类型。不是同类型的就不允许在一块存放,这样也避免了ClassCastException异常
的出现,而且因为都是同一类型,也就没必要做强制类型转换了。
我们可以把ArrayList 变量参数化:
1 | public class ArrayList<T> { |
其中T叫类型参数 ,T
可以是任何class类型,现在ArrayList我们可以如下使用:
1 | // 存储String的ArrayList |
泛型其本质是参数化类型
,也就是说数据类型 作为 参数
,解决不确定具体对象类型的问题。
泛型的使用
泛型一般有三种使用方式,分别为:泛型类、泛型接口、泛型方法
,我们简单介绍一下泛型的使用
泛型类
1 | //此处T可以随便写为任意标识,常见的如T、E、K、V等形式的参数常用于表示泛型 |
如何实例化泛型类:
1 | Generic<Integer> genericInteger = new Generic<Integer>(666); |
泛型接口
1 | //定义一个泛型接口 |
泛型方法
1 | public class GenericMethods { |
结果:
java.lang.String
java.lang.Integer
泛型的底层实现机制
ArrayList源码解析
通过上文我们知道,为了让ArrayList存取各种数据类型的值,我们需要把ArrayList模板化,将变量的数据类型 给抽象出来,作为类型参数
1 | public class ArrayList<T> { |
但当我们查看Jdk8 的ArrayList源码,底层数组还是Object数组:transient Object[] elementData;
那ArrayList为什么还能进行类型约束和自动类型转换呢?
什么是泛型擦除
我们再看一个经典的例子:
1 | public class genericTest { |
结果竟然是true
,ArrayList
1 | public class genericTest { |
我们在对其反汇编一下:
1 | $ javap -c genericTest |
- 看第16、46处,add进去的是原始类型Object;
- 看第22、53处,get方法获得也是Object类型,String、Integer类型被擦出,只保留原始类型Object。
- 看25、55处,checkcast指令是
类型转换检查
,在结合class文件var1 = (String)var3.get(0);``var2 = (Integer)var4.get(0);
我们知晓编译器自动帮我们强制类型转换了,我们无需手动类型转换
经过上面的种种现象,我们可以发现,在类加载的编译阶段,泛型类型String和Integer都被擦除掉了,只剩下原始类型,这样他们类的信息都是Object,这样自然而然就相等了。这种机制就叫泛型擦除
。
我们需要了解一下类加载生命周期:
详情见:https://mp.weixin.qq.com/s/v91bqRiKDWWgeNl1DIdaDQ
泛型是和编译器的约定,在编译期对代码进行检查的
,由编译器负责解析,JVM并无识别的能力,一个类继承泛型后,当变量存入这个类的时候,编译器会对其进行类型安全检测
,当从中取出数据时,编译器会根据与泛型的约定,会自动进行类型转换,无需我们手动强制类型转换。
泛型类型参数化,并不意味这其对象类型是不确定的,相反它的对象类型 对于JVM来说,都是确定的,是Object或Object[]数组
泛型的边界
来看一个经典的例子,我们想要实现一个ArrayList对象能够储存所有的泛型:
1 | ArrayList<Object> list = new ArrayList<String>(); |
但可以的是编译器提示报错:
明明 String是Object类的子类,我们可以发现,泛型不存在继承、多态关系,泛型左右两边要一样
别担心,JDK提供了通配符?
来应对这种场景,我们可以这样:
1 | ArrayList<?> list = new ArrayList<String>(); |
通配符<?>
表示可以接收任意类型
,此处?
是类型实参,而不是类型形参。我们可以把它看做是String、Integer等所有类型的"父类"。是一种真实的类型。
通配符还有:
- 上边界限定通配符,如<? extends E>;
- 下边界通配符,如<? super E>;
?:无界通配符
?是开放限度最大的,可指向任意类型,但在对于其的存取上也是限制最大的:
- 入参和泛型相关的都不能使用, 除了null(禁止存入),比如ArrayList<?> list不可以添加任何类型,因为并不知道实际是哪种类型
- 返回值和泛型相关的都只能用Object接收
extends 上边界通配符
1 | //泛型的上限只能是该类型的类型及其子类,其中Number是Integer、Long、Float的父类 |
extends指向性被砍了一半,只能指向子类型
和父类型
,但方法使用上又适当放开了:
- 值得注意的是:这里的extends并不表示类的继承含义,只是表示泛型的范围关系
- extends不允许存入,由于使用extends ,比如
ArrayList<? extends Number> list
可以接收Integer、Long、Float,但是泛型本质是保证两边类型确定
,这样的话在程序运行期间,再存入数据,编译器可无法知晓数据的类型,所以只能禁止了。 - 但为什么
ArrayList<? extends Number> list
可以重新指向longList
来变向地”存储”值,那是因为ArrayList<Long> longList = new ArrayList<>();
这边的泛型已经约束两边的类型了,编译器知晓longList
储存的数据都是Long类型
- 但extends允许取出,取出来的元素可以往边界类型转
- extends中可以指定多个范围,实行泛型类型检查约束时,会以最左边的为准。
super 下边界通配符
1 | //泛型的下限只能是该类型的类型及其父类,其中Number是Integer、Long、Float的父类 |
super允许存入编辑类型及其子类型元素
,但取出元素只能为Object类型
PECS原则
泛型通配符的出现,是为了获得最大限度的灵活性。如果要用到通配符,需要结合业务考虑,《Effective Java》提出了:PECS(Producer Extends Consumer Super)
- 需要频繁往外读取内容(生产者Producer),适合用<? extends T>
- 需要频繁写值(消费者Consumer),适合用<? super T>:super允许存入子类型元素
?
表示不确定的 java 类型,一般用于只接收任意类型,而不对其处理的情况
泛型是怎么擦除的
Java 编译器通过如下方式实现擦除:
- 用 Object 或者界定类型替代泛型,产生的字节码中只包含了原始的类,接口和方法;
- 在恰当的位置插入强制转换代码来确保类型安全;
- 在继承了泛型类或接口的类中自动产生桥接方法来保留多态性。
擦除类定义中的无限制类型参数
当类定义中的类型参数没有任何限制时,在类型擦除中直接被替换为Object,即形如
擦除类定义中的有限制类型擦除
当类定义中的类型参数存在限制(上下界)时,在类型擦除中替换为类型参数的上界或者下界,
形如
<T extends Number>
和<? extends Number>
的类型参数被替换为Number,<? super Number>
被替换为Object
擦除方法定义中的类型参数
擦除方法定义中的类型参数原则和擦除类定义中的类型参数是一样的,额外补充 擦除方法定义中的有限制类型参数的例子
桥接方法和泛型的多态
1 | public class A<T>{ |
由于类型擦出机制的存在,按理说编译后的文件在翻译为java应如下所示:
1 | public class A{ |
但是,我们可以发现 @override
意味着B对父类A中的get方法
进行了重写
,但是依上面的程序来看,只是重载
,依然可以执行父类的方法,这和期望是不附的,也不符合java继承、多态的特性。
- 重写是子类对父类的允许访问的方法的实现过程进行重新编写, 返回值和形参都不能改变。即外壳不变,核心重写!
- 重载(overloading) 是在一个类里面,方法名字相同,而参数不同。返回类型可以相同也可以不同。
为了解决这个问题,java在编译期间加入了桥接方法。编译后再翻译为java原文件其实是:
1 | public class A{ |
桥接方法重写了父类相同的方法,并且桥接方法中,最终调用了期望的重写方法,并且桥接方法在调用目的方法时,参数被强制转换为指定的泛型类型。桥接方法搭起了父类和子类的桥梁
。
桥接方法是伴随泛型方法而生的,在继承关系中,如果某个子类覆盖了泛型方法,则编译器会在该子类自动生成桥接方法。所以我们实际使用泛型的过程中,无需担心桥接方法。
泛型擦除带来的限制与局限
泛型不适用基本数据类型
不能用类型参数代替基本类型(byte 、short 、int 、long、float 、 double、char、boolean)
比如, 没有 Pair<double>, 只 有 Pair<Double>
。 其原因是泛型擦除,擦除之后只有原始类型Object
, 而 Object 无法存储 double等基本类型的值。
但Java同时有自动拆装箱特性,可以将基本类型装箱成包装类型,这样就使用泛型了,通过中转,即可在功能上实现“用基本类型实例化类型化参数”。
数据类型 | 封装类 |
---|---|
byte | Byte |
short | Short |
int | Integer |
long | Long |
float | Float |
double | Double |
char | Character |
boolean | Boolean |
无法创建具体类型的泛型数组
1 | List<Integer>[] l1 = new ArrayList<Integer>[10];// Error |
上文我们知晓ArrayList,底层仍旧采用Object[]
,Integer,String
类型信息都被擦除
借助无限定通配符 ?
,可以创建泛型数组,但是涉及的操作都基本上与类型无关
1 | List<?>[] l1 = new ArrayList<?>[10]; |
如果想对数组进行复制操作的话,可以通过Arrays.copyOfRange()
方法
1 | public class TestArray { |
反射其实可以绕过泛型的限制
由于我们知晓java是通过泛型擦除来实现泛型的,JVM只能识别原始类型Object,所以我们只需骗过编译器的校验即可,反射是程序运行时发生的,我们可以借助反射来波骚操作
1 | List<Integer> l1 = new ArrayList<>(); |
结果:
111
骚气的我 又出现了
尾语
如果你了解其他语言(例如 C++ )的参数化机制,你会发现,Java 泛型并不能满足所有的预期。由于泛型出来前,java已经有了很多项目了,为了兼容老版本,采用了泛型擦除来“实现泛型”,这会遇到很多意料之外的麻烦,但这并不是说 Java 泛型毫无用处,它大多数情况能够让代码更加优雅,后面有机会我们会继续深入聊聊泛型擦除带来的麻烦及其历史渊源。
参考资料:
《On Java8》
《Effective Java》
https://www.liaoxuefeng.com/wiki/1252599548343744/1265102638843296
https://www.cnblogs.com/mahuan2/p/6073493.html
本篇文章到这里就结束啦,很感谢你能看到最后,如果喜欢的话,点赞收藏转发,欢迎关注!更多精彩的文章