你好呀,我是陪你刷面试题的老师。
先抛一个特别"反直觉"的问题,你先在心里答一下:下面这段代码,会输出什么?
List<String>a=newArrayList<>();List<Integer>b=newArrayList<>();System.out.println(a.getClass()==b.getClass());System.out.println(a.getClass().getName());一个装字符串、一个装整数,泛型明明不一样,结果运行起来第一行打印的居然是true,第二行是java.util.ArrayList——泛型信息好像凭空消失了。
很多同学面试被追问到这里就卡住了。今天我们就顺着这个现象,把泛型、类型擦除、通配符、PECS、桥接方法这一整套东西,像讲故事一样给你捋顺。这些全是阿里、字节、美团的高频考点,建议收藏慢慢看。
第一幕:没有泛型的年代,到底有多痛
要知道泛型解决了什么问题,得先回到 JDK 1.5 之前。那时候的集合,里面只能装Object——也就是一个"什么都能往里扔"的大筐。
// 没有泛型:什么都能装,取出来必须强转Listlist=newArrayList();list.add("张三");list.add(18);// 编译器完全不拦你,混装一时爽Stringname=(String)list.get(0);Stringage=(String)list.get(1);// 编译能过,运行直接崩运行结果非常扎心:
Exception in thread "main" java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String发现问题没?类型错误被一路拖到运行时才爆炸。一个真实项目里这种强转成千上万,谁也保证不了每个都转对,线上随时可能因为一个ClassCastException挂掉。
泛型就是来救场的。它的本质叫参数化类型:把"这个集合到底装什么类型"变成一个参数,在你写代码的时候就约定好,让编译器帮你盯着:
// 有了泛型:约定这个筐只能装 StringList<String>list=newArrayList<>();list.add("张三");list.add(18);// 编译就报错,根本不让你混装Stringname=list.get(0);// 取出来就是 String,不用再强转一句话总结:泛型把类型错误提前到编译期,同时消灭了到处都是的强制类型转换。
第二幕:泛型的三种"长相"
泛型不是只能写在集合上,它有三种标准用法,面试让你手写时别写错位置。
① 泛型类:类型参数声明在类名后面。
publicclassResult<T>{// T 是类型形参,惯例用 T/E/K/Vprivateintcode;privateTdata;// 类内部到处都能用 TpublicTgetData(){returndata;}publicvoidsetData(Tdata){this.data=data;}}Result<User>r1=newResult<>();// T 被替换成 UserResult<Order>r2=newResult<>();// 同一个类,复用到任意类型② 泛型方法:那个<T>必须写在返回值前面,用来声明"这是个泛型方法",这是最容易漏的地方。
// <T> 写在返回值 T 前面,缺一不可publicstatic<T>TgetFirst(List<T>list){returnlist.isEmpty()?null:list.get(0);}Strings=getFirst(newArrayList<String>());// T 自动推断为 String③ 泛型接口:最典型的就是Comparable<T>。
publicclassUserimplementsComparable<User>{publicintcompareTo(Usero){/* 比较逻辑 */}}顺便记一下这几个字母约定,面试别闹笑话:T=Type 类型、E=Element 元素、K/V=键/值、R=Return 返回值、?=未知类型的通配符。
第三幕:类型擦除——泛型最核心的底层机制
重点来了。Java 的泛型被称为“伪泛型”,因为它只活在编译期,一旦编译成字节码,泛型信息就会被"擦掉",这就是类型擦除(Type Erasure)。
擦除规则其实就两条,记死它:
| 类型参数声明 | 擦除后被替换成 |
|---|---|
<T>(没有上界) | Object |
<T extends Number>(有上界) | 上界Number |
所以开头那段代码就解释通了:<String>、<Integer>编译后全被擦成了Object,字节码里只剩一个光秃秃的ArrayList,它们的getClass()当然是同一个。
那取值的时候为什么不用我们自己强转?因为编译器偷偷在取值的地方插入了一条checkcast强转指令。整个过程是这样的:
源码阶段:ArrayList<String>(编译器严格校验类型) ↓ javac 编译,类型擦除 字节码:ArrayList(T 被替换成 Object) ↓ get 取值处 自动插入 checkcast,转回 String泛型信息是不是全丢光了?——Signature 还留了一份
这里有个特别容易答错的细节。虽然对象实例层面泛型被擦了,但类和方法上声明的泛型,会以 Signature 属性额外存进字节码,反射是能读到的。我用 JDK 11 实测:
ArrayList 声明的类型参数: [E] emptyList 泛型方法返回类型: java.util.List<T>所以一定要分清楚两件事:
- 对象实例层面:泛型被擦除,运行时 new 出来的都是同一个裸类;
- 类/方法声明层面:泛型签名被 Signature 保留,反射可见。
这也解释了为什么 Spring、Jackson、MyBatis 这些框架能靠反射知道你要的是List<User>而不是List。
桥接方法(Bridge Method):擦除之后打的"补丁"
这是大厂特别爱追问的加分点。你写的是implements Comparable<User>,方法签名是compareTo(User);可擦除之后接口要求的是compareTo(Object),俩签名对不上,多态不就失效了吗?
编译器的办法是偷偷生成一个桥接方法:
// 你自己只写了这一个方法publicintcompareTo(Usero){...}// 编译器额外生成的桥接方法(反编译可见,标记为 synthetic + bridge)publicintcompareTo(Objecto){returncompareTo((User)o);// 转一手,再去调你真正写的方法}所以你用反射getMethods()时,偶尔会看到"同名方法出现两次",那就是桥接方法,用method.isBridge()就能判断。它的存在,是为了保证类型擦除之后多态依然成立。
第四幕:通配符 ? extends / ? super 与 PECS
要理解通配符,先得接受一个反直觉的事实:泛型是不协变的。哪怕Integer extends Number,下面这行也编译不过:
List<Integer>ints=newArrayList<>();List<Number>nums=ints;// 编译报错!List<Integer> 不是 List<Number> 的子类为什么这么设计?假设允许,那你就能往一个"实际装 Integer"的List<Number>里add一个Double,类型安全当场崩盘。Java 的解法是用通配符来安全地表达"某一族类型"。
上界通配符 <? extends Number>:生产者,只能读、不能写
List<?extendsNumber>list=newArrayList<Integer>();Numbern=list.get(0);// 读没问题,取出来一定是 Numberlist.add(Integer.valueOf(1));// 编译报错!不让 add(除了 null)为什么不让写?因为编译器只知道"它是 Number 的某个子类",但到底是 Integer 还是 Double,它不确定,自然不敢让你 add 任何具体类型进去。
下界通配符 <? super Integer>:消费者,只能写、读出来是 Object
List<?superInteger>list=newArrayList<Number>();list.add(Integer.valueOf(1));// 写没问题,往父类容器装子类绝对安全Objecto=list.get(0);// 读只能当 Object,类型太宽,没意义PECS 原则:Producer-Extends,Consumer-Super
记一句全世界通用的口诀PECS:
- 生产者(往外吐数据、主要 get)用 extends;
- 消费者(往里收数据、主要 add)用 super。
JDK 里最经典的例子是Collections.copy,我用反射把它的真实泛型签名打出来给你看:
参数0 类型: java.util.List<? super T> 下界 super = T → dest 负责写入,是消费者 参数1 类型: java.util.List<? extends T> 上界 extends = T → src 负责读出,是生产者对照源码一看就懂:从 src 里get读出来(生产者用 extends),往 dest 里add写进去(消费者用 super)。
publicstatic<T>voidcopy(List<?superT>dest,// 目的地:消费 TList<?extendsT>src){// 来源:生产 Tfor(inti=0;i<src.size();i++){dest.add(src.get(i));// 从 src 读,往 dest 写}}三种通配符放一起对比,面试直接背这张表:
| 写法 | 别名 | 能 get 读 | 能 add 写 | 典型场景 |
|---|---|---|---|---|
<? extends T> | 上界 / 生产者 | 读出为 T | 不行 | 遍历、求和、查找 |
<? super T> | 下界 / 消费者 | 只能当 Object | 可以写 T | 拷贝目的地、批量 add |
<?>无界 | 等价 ? extends Object | 读出为 Object | 不行 | 只读、不关心元素类型 |
第五幕:泛型的 5 个高频坑,踩一个凉一个
这些是面试和写代码时最常见的坑,一条条记住:
坑 1:不能new T(),也不能new T[]。擦除后运行时根本不知道 T 是谁,没法实例化。要创建对象,得把Class<T>当参数传进来,用反射clazz.getDeclaredConstructor().newInstance()。
坑 2:基本类型不能当泛型实参。只能写List<Integer>,不能写List<int>,因为擦除后是 Object,而 int 不是对象——这也是为什么要有自动装箱。
坑 3:泛型数组不推荐。new T[]、new List<String>[10]会报错或警告,因为数组是协变的、运行时还要检查元素类型,跟类型擦除天生冲突。正确姿势是用集合套集合:List<List<String>>。
坑 4:instanceof 后面不能带泛型。obj instanceof List<String>编译报错,运行时泛型都没了还判断啥,只能写obj instanceof List<?>。
坑 5:静态成员不能用类的类型参数 T。静态字段/静态方法属于类、早于对象创建,那时候 T 还没被指定。静态场景要写成自己声明泛型的泛型方法:
classBox<T>{staticTx;// 报错:静态字段不能用类的 TstaticTget(){returnnull;}// 报错:同上static<E>Eok(Ee){returne;}// 正确:静态泛型方法自己声明 E}第六幕:回到项目里,泛型到底香在哪
说个后端项目里几乎人人都写过的东西——统一接口返回结果。如果没有泛型,你得给每种 data 都写一个返回类;有了泛型,一个R<T>通吃:
publicclassR<T>{privateintcode;privateStringmsg;privateTdata;publicstatic<T>R<T>ok(Tdata){R<T>r=newR<>();r.code=200;r.data=data;returnr;}}// 调用处类型一目了然,前端拿到的 data 类型由这里决定R<User>user=R.ok(userInfo);R<List<Order>>orders=R.ok(orderList);再比如写一个通用的"安全取配置"方法,一套逻辑适配所有返回类型,还保证类型安全:
publicstatic<T>TgetConfig(Stringkey,Class<T>clazz){Objectval=configMap.get(key);returnclazz.cast(val);// 一处方法,适配任意类型}所以面试问你"泛型有什么好处",标准回答是这四点:编译期类型安全、消除强转与代码复用、配合通配符提升 API 灵活性、运行时擦除不产生额外对象开销。
一句话总结(背就完了)
| 知识点 | 一句话记忆 |
|---|---|
| 泛型本质 | 参数化类型,把类型检查提前到编译期 |
| 类型擦除 | 运行时 T 擦成上界(Object),取值处自动 checkcast |
| Signature | 声明处泛型保留在字节码,反射可读(框架靠它) |
| 桥接方法 | 编译器生成 Object 入参方法,保证擦除后多态 |
| PECS | 生产者 extends 读、消费者 super 写 |
| 五个坑 | 不 new T、不用基本类型、慎用数组、instanceof 用 ?、静态自带泛型 |
最后送你一段顺口溜:
泛型本质参数化,类型安全编译查;
运行擦除变上界,取值自动做强转;
extends 往外读、super 往里写,PECS 别搞反;
不 new T、不装基本类型,桥接方法补多态。
这篇如果帮你把泛型彻底理顺了,欢迎点个赞、点个关注,Java 集合框架与基础系列会持续更新下去。下一篇我们讲异常体系:受检异常和运行时异常到底怎么分、try-catch-finally 的执行顺序、项目里自定义异常的正确姿势,又是一个面试高频、初学者头大的点,我们继续用讲故事的方式把它掰开揉碎。
有任何疑问欢迎在评论区交流,我会逐条回复,咱们一起稳扎稳打拿下大厂面试,加油!
#Java #java面试 #后端开发 #泛型 #集合框架 #面试题 #程序员