面试官:说说什么是泛型的类型擦除?

先看一道常见的面试题,下面的代码的执行结果是什么?

public static void main(String[] args) {
    List<String> list1=new ArrayList<String>();
    List<Integer> list2=new ArrayList<Integer>();
    System.out.println(list1.getClass()==list2.getClass());
}

首先,我们知道getClas方法获取的是对象运行时的类(Class),那么这个问题也就可以转化为ArrayList<String>ArrayList<Integer>的对象在运行时对应的Class是否相同?

我们直接揭晓答案,运行上面的代码,程序会打印true,说明虽然在代码中声明了具体的泛型,但是两个List对象对应的Class是一样的,对它们的类型进行打印,结果都是:

class java.util.ArrayList

也就是说,虽然ArrayList<String>ArrayList<Integer>在编译时是不同的类型,但是在编译完成后都被编译器简化成了ArrayList,这一现象,被称为泛型的类型擦除(Type Erasure)。泛型的本质是参数化类型,而类型擦除使得类型参数只存在于编译期,在运行时,jvm是并不知道泛型的存在的。

那么为什么要进行泛型的类型擦除呢?查阅的一些资料中,解释说类型擦除的主要目的是避免过多的创建类而造成的运行时的过度消耗。试想一下,如果用List<A>表示一个类型,再用List<B>表示另一个类型,以此类推,无疑会引起类型的数量爆炸。

在对类型擦除有了一个大致的了解后,我们再看看下面的几个问题。

类型擦除做了什么?

上面我们说了,编译完成后会对泛型进行类型擦除,如果想要眼见为实,实际看一下的话应该怎么办呢?那么就需要对编译后的字节码文件进行反编译了,这里使用一个轻量级的小工具Jad来进行反编译(可以从这个地址进行下载:https://varaneckas.com/jad/

Jad的使用也很简单,下载解压后,把需要反编译的字节码文件放在目录下,然后在命令行里执行下面的命令就可以在同目录下生成反编译后的.java文件了:

jad -sjava Test.class 

好了,工具准备好了,下面我们就看一下不同情况下的类型擦除。

1、无限制类型擦除

当类定义中的类型参数没有任何限制时,在类型擦除后,会被直接替换为Object。在下面的例子中,<T>中的类型参数T就全被替换为了Object(左侧为编译前的代码,右侧为通过字节码文件反编译得到的代码):

2、有限制类型擦除

当类定义中的类型参数存在限制时,在类型擦除中替换为类型参数的上界或者下界。下面的代码中,经过擦除后T被替换成了Integer

3、擦除方法中的类型参数

比较下面两边的代码,可以看到在擦除方法中的类型参数时,和擦除类定义中的类型参数一致,无限制时直接擦除为Object,有限制时则会被擦除为上界或下界:

反射能获取泛型的类型吗?

估计对Java反射比较熟悉小伙伴要有疑问了,反射中的getTypeParameters方法可以获得类、数组、接口等实体的类型参数,如果类型被擦除了,那么能获取到什么呢?我们来尝试一下使用反射来获取类型参数:

System.out.println(Arrays.asList(list1.getClass().getTypeParameters()));

执行结果如下:

[E]

同样,如果打印Map对象的参数类型:

Map<String,Integer> map=new HashMap<>();
System.out.println(Arrays.asList(map.getClass().getTypeParameters()));

最终也只能够获取到:

[K, V]

可以看到通过getTypeParameters方法只能获取到泛型的参数占位符,而不能获得代码中真正的泛型类型。

能在指定类型的List中放入其他类型的对象吗?

使用泛型的好处之一,就是在编译的时候能够检查类型安全,但是通过上面的例子,我们知道运行时是没有泛型约束的,那么是不是就意味着,在运行时可以把一个类型的对象能放进另一类型的List呢?我们先看看正常情况下,直接调用add方***有什么报错:

当我们尝试将User类型的对象放入String类型的数组时,泛型的约束会在编译期间就进行报错,提示提供的User类型对象不适用于String类型数组。那么既然编译时不行,那么我们就在运行时写入,借助真正运行的class是没有泛型约束这一特性,使用反射在运行时写入:

public class ReflectTest {
    static List<String> list = new ArrayList<>();

    public static void main(String[] args) {
        list.add("1");
        ReflectTest reflectTest =new ReflectTest();
        try {
            Field field = ReflectTest.class.getDeclaredField("list");
            field.setAccessible(true);
            List list=(List) field.get(reflectTest);
            list.add(new User());
        } catch (Exception e) {
            e.printStackTrace();
        }        
    }
}

执行上面的代码,不仅在编译期间可以通过语法检查,并且也可以正常地运行,我们使用debug来看一下数组中的内容:

可以看到虽然数组中声明的泛型类型是String,但是仍然成功的放入了User类型的对象。那么,如果我们在代码中尝试取出这个User对象,程序还能正常执行吗,我们在上面代码的最后再加上一句:

System.out.println(list.get(1));

再次执行代码,程序运行到最后的打印语句时,报错如下:

异常提示User类型的对象无法被转换成String类型,这是否也就意味着,在取出对象时存在强制类型转换呢?我们来看一下ArrayListget方法的源码:

public E get(int index) {
    rangeCheck(index);
    return elementData(index);
}

E elementData(int index) {
    return (E) elementData[index];
}

可以看到,在取出元素时,会将这个元素强制类型转换成泛型中的类型,也就是说在上面的代码中,最后会尝试强制把User对象转换成String类型,在这一阶段程序会报错。通过这一过程,也再次证明了泛型可以对类型安全进行检测。

类型擦除会引起什么问题?

下面我们看一个稍微有点复杂的例子,首先声明一个接口,然后创建一个实现该接口的类:

public interface Fruit<T> {
    T get(T param);
}

public class Apple implements Fruit<Integer> {
    @Override
    public Integer get(Integer param) {
        return param;
    }
}

按照之前我们的理解,在进行类型擦除后,应该是这样的:

public interface Fruit {
    Object get(Object param);
}

public class Apple implements Fruit {
    @Override
    public Integer get(Integer param) {
        return param;
    }
}

但是,如果真是这样的话那么代码是无法运行的,因为虽然Apple类中也有一个get方法,但是与接口中的方法参数不一致,也就是说没有覆盖接口中的方法。针对这种情况,编译器会通过添加一个桥接方法来满足语法上的要求,同时保证了基于泛型的多态能够有效。我们反编译上面代码生成的字节码文件:

可以看到,编译后的代码中生成了两个get方法。参数为Objectget方法负责实现Fruit接口中的同名方法,然后在实现类中又额外添加了一个参数为Integerget方法,这个方法也就是理论上应该生成的带参数类型的方法。最终用接口方法调用额外添加的方法,通过这种方式构建了接口和实现类的关系,类似于起到了桥接的作用,因此也被称为桥接方法,最终,通过这种机制保证了泛型情况下的Java多态性。

总结

本文由面试中常见的一道面试题入手,介绍了java中泛型的类型擦除相关知识,通过这一过程,也便于大家理解为什么平常总是说java中的泛型是一个伪泛型,同时也有助于大家认识到java中泛型的一些缺陷。了解类型擦除的原因以及原理,相信能够方便大家在日常的工作中更好的使用泛型。

作者Hydra,一个参与过风云卫星项目的后端程序员,现任世界500强企业后端技术Leader,资深后端开发。
只想与你有趣、深入、直接地聊聊技术。
个人公众号『码农参上』,一个热爱分享的公众号。欢迎小伙伴们关注,第一时间获取推送。

#面试##学习路径#
全部评论

相关推荐

juc和分布式这一块不熟,被拷打麻了😰1.自我介绍巴拉巴拉一大堆2.项目里说的redis与mysql的数据一致性和缓存击穿是怎么实现的讲了先更新数据库,再删缓存3.项目里的线程池是怎么用的每个线程都负责一部分的文件片合并,最后在执行一次合并3.线程池的参数有哪些,任务队列满了之后会怎样简单说了下参数,满了之后会执行拒绝策略,又讲了下四种拒绝策略4.项目里用了定时任务,如何保证定时任务在分布式多实例系统内只生效一次不会5.项目里说的异步失效问题是怎么解决的?声明式事务的代理是用AOP实现的,AOP最后调用的都是真实对象,导致异步失效6.项目里说的登录态的身份校验是怎么做的session里存用户信息,配合AOP做校验7.不使用第三方库,自己写代码如何做到分布式多实例的轮询?说了下可以用nacos的轮询,自己写不会8.volatile是什么,CAS底层怎么实现的简单讲了下JMM,volatile修饰的变量被修改会立刻写回主存,CAS底层不知道9.原子类介绍下,底层是怎么实现的简单讲了下原子类,其它的不知道10.@Async注解开的线程是通过线程池开的吗这里我一开始大脑短路了,觉得只是简单的new了一个thread,后来面试官提示了才突然想起来美团的一篇文章https://mp.weixin.qq.com/s/T7P2-tiroXWI9xd8FhsuFA。这篇推文我印象还挺深刻的,就介绍了这篇文章里的问题和原因以及解决方案11.讲讲mysql从查询流程到索引、事务、锁还有日志,巴拉巴拉讲了一大堆12.讲讲java里的集合简单说了下常见的集合,重点讲了HashMap13.说说mq简单讲了点14.你简历说参与了Dubbo的开源,讲讲你都做了什么这个月才刚加入,只是看了几个good&nbsp;first&nbsp;issue,大概讲了讲面试之前看这家公司的面经就发现他们公司对于并发和锁用的比较多,但是自己太菜了,也没准备充分,连redis分布式锁都不知道
点赞 评论 收藏
转发
2 8 评论
分享
牛客网
牛客企业服务