为什么 Java 泛型这么聪明,结果运行时却像什么都不知道?
开篇语
哈喽,各位小伙伴们,你们好呀,我是喵手。运营社区:C站/掘金/腾讯云/阿里云/华为云/51CTO;欢迎大家常来逛逛
今天我要给大家分享一些自己日常学习到的一些知识点,并以文字的形式跟大家一起交流,互相学习,一个人虽可以走的更快,但一群人可以走的更远。
我是一名后端开发爱好者,工作日常接触到最多的就是Java语言啦,所以我都尽量抽业余时间把自己所学到所会的,通过文章的形式进行输出,希望以这种方式帮助到更多的初学者或者想入门的小伙伴们,同时也能对自己的技术进行沉淀,加以复盘,查缺补漏。
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦。三连即是对作者我写作道路上最好的鼓励与支持!
前言
写 Java 的同学,谁没跟泛型打过交道?List<String>、Map<String, Integer> 一顿敲,IDE 自动补全用得飞起,编译器也相当贴心地给你各种类型检查。
但一到运行时,你再一看——怎么感觉泛型全“失忆”了?
List<String> 和 List<Integer> 居然在运行时看起来一模一样?instanceof List<String> 还不给写?数组可以写 new String[10],结果 new List<String>[10] 直接红了?
别急,这一切背后,其实都指向一个关键词:泛型擦除(Type Erasure)。
再加上你经常看到的 ? extends T、? super T、“PECS 原则”,这套组合拳要是真正搞明白了,你对 Java 泛型的认知会直接从“能用”升到“掌控”。💪
一、泛型到底是干嘛的?——编译器的“贴心保姆”
先把气氛放轻松一点:泛型的本质,其实是编译期的类型检查 + 自动转型工具。
一个最常见的例子:
List list = new ArrayList();
list.add("hello");
String s = (String) list.get(0); // 手动强转
VS
List<String> list = new ArrayList<>();
list.add("hello");
String s = list.get(0); // 不用强转,IDE 还帮你补类型
区别在哪?
-
没有泛型时:
- 编译器不管你往
List里加啥,全是Object; - 你自己负责强转,错了就运行时
ClassCastException。
- 编译器不管你往
-
有泛型时:
-
编译器会在 编译阶段 帮你检查类型:
- 不让你往
List<String>里塞Integer;
- 不让你往
-
生成的字节码里,依然是用
Object存,只不过自动插入强转; -
换句话说:泛型帮你把一些本该运行时报错的事情,提前到了编译期。
-
重点来了:泛型主要是编译器玩的东西,JVM 本身并不知道 List<String> 和 List<Integer> 有啥差别。
这就是——类型擦除。
二、类型擦除(Type Erasure):所谓“运行时失忆”是怎么搞出来的?
1. 擦除的基本思想
一句话概括:
编译期间:泛型超级聪明,类型信息很丰富;
编译之后:泛型类型参数被“抹掉”,变回普通类型。
比如有这么一段代码:
public class Box<T> {
private T value;
public void set(T value) {
this.value = value;
}
public T get() {
return value;
}
}
如果我们用它:
Box<String> box = new Box<>();
box.set("hello");
String s = box.get();
从源码层面看,T 的类型是 String。
但在编译之后,JVM 看到的大概长这样(伪代码,仅用于理解):
public class Box {
private Object value;
public void set(Object value) {
this.value = value;
}
public Object get() {
return value;
}
}
注意几点:
- T 被擦除成了 Object(如果有上界约束,就擦成上界类型,比如
T extends Number会变成Number); - 泛型类型参数在 class 文件里没有保留(部分场景会以“签名信息”形式保留给反射用,但运行时类型系统不参与)。
所以运行时你问 JVM:
“这个 Box 里装的是 String 还是 Integer?”
JVM 一脸无辜:
“我只知道是个 Object,你自己看着办。”🤷♂️
2. 擦除的具体步骤(简化理解版)
对一个带泛型的类 / 方法,编译器大致会做几件事:
-
用上界替换类型参数
- 如:
class Box<T extends Number>→ 所有T位置都换成Number; - 没写上界默认是
Object。
- 如:
-
插入必要的强制类型转换
- 源码中看起来返回的是
T,但擦除后返回Object或上界类型; - 调用时编译器会在调用处生成强转。
- 源码中看起来返回的是
-
生成桥接(bridge)方法(多态兼容用)
- 涉及泛型协变时,会生成“桥方法”,保证重写关系正常,这里先不展开(知道有这回事就行)。
三、泛型擦除带来的各种“你不能”:这些限制不是你笨,是 JVM 真做不到 😅
既然运行时不保存真实的泛型类型,那一堆看起来“理所当然”的操作就做不了了。
下面一条条说:
1. 不能 new T():因为 T 在运行时没有具体类型
public class Box<T> {
private T value;
public Box() {
// 编译错误:Cannot instantiate the type T
// this.value = new T();
}
}
为什么?
- 擦除后
T只变成了Object或上界类型,JVM 不知道你到底想要new String()还是new Integer(); new需要一个具体类型,而不是“某种未知的 T”。
常见替代方案:
- 传入
Class<T>:
public class Box<T> {
private T value;
public Box(Class<T> clazz) throws Exception {
this.value = clazz.getDeclaredConstructor().newInstance();
}
}
2. 不能创建泛型数组:new List<String>[10] 不行
// 编译错误:
List<String>[] lists = new List<String>[10];
为什么?
-
数组是真正的运行时类型安全结构(会记录自己的组件类型);
-
但泛型在运行时被擦除成原始类型:
List<String>和List<Integer>都只是List;
-
如果允许
List<String>[]存在,就会出现下面这种诡异情况:
Object[] arr = new List<String>[10]; // 假设允许
arr[0] = new ArrayList<Integer>(); // 运行时数组检查只看到是 List,放行
// 结果你以为第 0 个位置是 List<String>,其实里面塞了 Integer,彻底乱套
所以 Java 干脆直接禁止创建泛型数组,宁可你烦一点,也不让你作死。
常用替代:
List<String> list = new ArrayList<>(); // 用集合代替数组
List<List<String>> outer = new ArrayList<>(); // 多层嵌套用集合组合
3. 不能在静态上下文使用类型参数
public class Box<T> {
// 编译错误:Cannot make a static reference to the non-static type T
// private static T value;
// 也不允许:
// public static T create() { ... }
}
原因也很直观:
- 泛型参数
T是和实例绑定的; - 而
static是和类绑定的,跟具体实例无关; - 类加载时还不知道会不会有人用
Box<String>还是Box<Integer>,所以静态上下文没有办法知道 T 是啥。
4. 不能 instanceof 精确判断泛型参数
List<String> list = new ArrayList<>();
// 编译错误:
/*
if (list instanceof List<String>) {
}
*/
// 正确写法只能是:
if (list instanceof List) {
// ...
}
因为擦除后,运行时只有 List 这个信息,<String> 早就没了。
5. 不能捕获具体泛型异常
// 编译错误:Generic class may not extend java.lang.Throwable
// class MyException<T> extends Exception {}
// 也不能:catch MyException<String> e
JVM 的异常系统只认“具体类型的 Throwable 子类”,不可能在运行时根据 <String> <Integer> 区分不同异常类,泛型异常直接被禁止。
6. 重载与擦除冲突:看起来不一样,其实一个样
public class Demo {
// 编译错误:name clash
public void test(List<String> list) {}
public void test(List<Integer> list) {}
}
编译器抗议说:“对不起,擦除之后你这两个方法长得一模一样。”
因为擦除后变成:
public void test(List list) {}
public void test(List list) {}
方法签名重复,JVM 无法区分,直接报错。
四、通配符 ?、extends、super:你以为只是语法糖,结果是门哲学课 😆
1. 为什么需要通配符?
先看一个很多同学一开始都会踩的坑:
List<String> strings = new ArrayList<>();
List<Object> objects = new ArrayList<>();
// 编译错误:incompatible types
// objects = strings;
很多人心里会想:
“
String是Object的子类,那List<String>不就是List<Object>的子类吗?”
——不是!
泛型在 Java 里是不变的(invariant):
String是Object的子类 ✅- 但
List<String>和List<Object>没有继承关系 ❌
这会导致:你想写一个方法“接收任何类型的 List”,结果写成这样:
public void printList(List<Object> list) { ... }
然后你传入 List<String>,会直接编译错误。
于是 Java 引入了一个东西:通配符 ?。
2. ?:啥都行,但你就别乱写了
如果你只想说“这里是一个装着某种什么类型的 List,具体是什么我不关心”,可以写:
public static void printList(List<?> list) {
for (Object o : list) {
System.out.println(o);
}
}
你现在可以开心地传任何类型的 List:
printList(new ArrayList<String>());
printList(new ArrayList<Integer>());
printList(new ArrayList<Double>());
但请注意——你基本上不能往 List<?> 里加东西(除了 null):
public static void addSomething(List<?> list) {
// 编译错误:
// list.add("abc");
// list.add(123);
list.add(null); // 只允许这个
}
原因很简单:
- 编译器不知道
?真实类型是啥; - 有可能是
List<String>,也可能是List<Integer>; - 你往里面加
Object,也许会破坏原本的类型安全; - 所以索性禁止写入(除了
null,对谁都安全)。
一句话:List<?> → 我什么都能看(读),但我尽量少动(写)。
五、extends 与 super:PECS 原则是怎么来的?
终于到了经典口诀时间——PECS 原则:
Producer Extends, Consumer Super
生产者用extends,消费者用super
这是记住 ? extends T 和 ? super T 的最好方法之一。
1. ? extends T:上界通配符 → “我只生产,不消费”
List<? extends Number> list = new ArrayList<Integer>();
意思是:
- list 是一个“装着某种 Number 子类型 的 List”;
- 具体是
List<Integer>、List<Double>,编译器不知道,也不在乎; - 你可以安全地“读出来当 Number 用”;
- 但你不能随便往里写。
例子:
public static double sum(List<? extends Number> list) {
double result = 0;
for (Number n : list) {
result += n.doubleValue();
}
return result;
}
你可以传:
List<Integer> ints = List.of(1, 2, 3);
List<Double> doubles = List.of(1.1, 2.2);
System.out.println(sum(ints));
System.out.println(sum(doubles));
但你不能这样:
public static void addNumber(List<? extends Number> list) {
// 编译错误:
// list.add(1);
// list.add(1.0);
list.add(null); // 依旧只允许 null
}
因为:
- 编译器不知道 list 里到底是 Integer 还是 Double 或者别的 Number 子类;
- 你贸然塞一个 Integer 进去,如果实际是
List<Double>,类型安全就挂了;
所以:
? extends T的集合适合“生产” T(从里面拿 T),不适合“消费” T(往里面放东西)。
2. ? super T:下界通配符 → “我只消费,不生产”
List<? super Integer> list = new ArrayList<Number>();
意思是:
- list 是一个“某种 Integer 的父类型 的 List”;
- 可能是
List<Integer>,也可能是List<Number>、List<Object>;
这时:
- 你可以安全地往里面加 Integer 或其子类;
- 但从里面读出来,只能当
Object看。
例子:
public static void addAllIntegers(List<? super Integer> list) {
list.add(1);
list.add(2);
list.add(3);
}
你可以这样用:
List<Number> nums = new ArrayList<>();
addAllIntegers(nums);
List<Object> objs = new ArrayList<>();
addAllIntegers(objs);
但你从里面读的时候:
Object o = list.get(0); // 只能是 Object
// Integer i = list.get(0); // 编译错误
因为编译器只知道这是某个“Integer 的父类型”,但不确定是啥,干脆按最安全的 Object 处理。
于是我们得到结论:
? super T的集合适合“消费” T(往里放 T),不适合“生产”精确类型的 T(读出来只能是 Object)。
3. PECS 一句记牢
Producer Extends, Consumer Super:
-
如果一个集合是数据的生产者,你只是从里面拿数据:
→ 用? extends T -
如果一个集合是数据的消费者,你只是往里面塞数据:
→ 用? super T
这个原则在很多 JDK 源码里都有体现,其中一个经典例子就是 Collections.copy。
六、综合例子:用一段 copy 方法串起泛型 + 通配符 + PECS
看这段方法定义(接近 JDK 的实现):
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
for (int i = 0; i < src.size(); i++) {
dest.set(i, src.get(i));
}
}
它在说什么?
src:数据来源,是一个“生产 T 的集合”,只从里面读 →? extends T;dest:数据目的地,是一个“消费 T 的集合”,只往里面写 →? super T。
使用方式:
List<Integer> src = List.of(1, 2, 3);
List<Number> dest = new ArrayList<>(Arrays.asList(0, 0, 0));
copy(dest, src); // OK:src 生产 Integer,dest 消费 Integer(Number 的子类)
这段签名如果你看懂了,那恭喜,你已经跨过了很多人卡住的那道“泛型理解门槛”。
七、? / extends / super 到底差在哪?来个对比小表格 ✅
| 写法 | 能接受的实参类型(以 List<...> 为例) |
能不能 add 元素? | 读取类型是什么? | 典型场景 |
|---|---|---|---|---|
List<?> |
任何 List<X> |
不能(除 null) |
Object |
只读遍历 |
List<? extends Number> |
List<Integer>、List<Double> 等 |
基本不能(除 null) |
至少是 Number |
只读 Number 数据(生产者) |
List<? super Integer> |
List<Integer>、List<Number>、List<Object> |
可以安全添加 Integer 及其子类 |
读取时只有 Object |
收集 / 插入 Integer(消费者) |
List<Integer> |
只能 List<Integer> |
可以添加 Integer |
Integer |
最正常的使用方式 |
配合 PECS 提醒自己:
- 要从集合里“拿 T” →
? extends T- 要往集合里“放 T” →
? super T- 什么都说不清,就先写
<?>(但记得你几乎不能往里写)。
八、最后整理一下:如何优雅地和 Java 泛型相处?💡
到这儿我们大概把你一开始的问题串了一遍:
-
泛型擦除是啥?
- 编译时照顾你,帮你检查类型、插转型;
- 运行时类型参数被擦除成上界(默认 Object),JVM 不知道
T是谁。
-
擦除带来了哪些限制?
- 不能
new T(); - 不能创建
new List<String>[]这样的泛型数组; - 不能在静态上下文里用类型参数;
- 不能
instanceof List<String>; - 不能搞泛型异常、按泛型类型重载方法。
- 不能
-
通配符
?、extends、super到底怎么用??:我什么类型都能接受,但基本不给你写;? extends T:生产者,帮我“产出” T(只读);? super T:消费者,帮我“消费” T(只写)。
-
PECS 原则怎么落地?
- 数据来源(只读):
List<? extends T>; - 数据目的地(只写):
List<? super T>; - 像
copy这类方法的签名就是经典示范。
- 数据来源(只读):
如果你看到这里,还愿意再抬头回去看看自己项目里的各种 List<?>、Map<String, ? extends Something>,你会发现:
以前那些“看着就头大”的写法,其实每一个都有脾气、有性格、有理由。
下一次当你写出一个带 <? super T> 的方法签名时,不妨在心里小小地得瑟一句:
“这玩意儿不是 IDE 自动生成的,是我知道它为什么要这么写。”😉
… …
文末
好啦,以上就是我这期的全部内容,如果有任何疑问,欢迎下方留言哦,咱们下期见。
… …
学习不分先后,知识不分多少;事无巨细,当以虚心求教;三人行,必有我师焉!!!
wished for you successed !!!
⭐️若喜欢我,就请关注我叭。
⭐️若对您有用,就请点赞叭。
⭐️若有疑问,就请评论留言告诉我叭。
版权声明:本文由作者原创,转载请注明出处,谢谢支持!
- 点赞
- 收藏
- 关注作者
评论(0)