谁在 Java 泛型上没踩过几个坑呢?我印象最深的一次,是新同事在代码里写了 List<? extends Number> nums = new ArrayList<Integer>();,然后紧接着想执行 nums.add(1),编译直接报错。他当场愣了:我明明是往里面放一个 Integer,它本来就是 Number 的子类,为什么不行?这个反直觉的问题背后,其实是很多人没有真正理解通配符的本质——? extends T 不是“可以放任意 T 子类的容器”,而是一个“知道自己装的是某种 T 的子类,但拒绝告诉你具体是哪种”的只读视图。
这篇内容我打算把 Java 泛型通配符一次性讲透:先讲为什么泛型需要通配符,再分别拆解上界通配符、下界通配符、无界通配符的语法边界和典型场景,最后用 PECS 原则把这三者串起来。另外还会附上我实测中遇到的高频报错和面试里常被追问的写法区别。无论你是在准备 Java 面试八股文,还是正被项目里那些 ? super T、? extends T 逼得头疼,这篇都值得看完。
1. 泛型的不变性:为什么 List 装不下 List
1.1 数组的协变与泛型的不变
Java 里数组是协变的,这是从 Java 1.0 就带回来的老设计:
java复制Number[] numbers = new Integer[10];
numbers[0] = 1.2; // 编译能过,运行期抛 ArrayStoreException
数组在运行期知道自己真实的元素类型,所以塞错类型时会在 JVM 层面兜底拦截。但泛型不一样。泛型是通过类型擦除实现的,编译完之后 ArrayList<Integer> 和 ArrayList<String> 在运行时都是同一个 ArrayList 类,JVM 根本不知道当时你塞进去的是什么类型。既然运行时兜不了底,Java 设计者为了编译期安全,就选择了让泛型保持“不变性(invariance)”:List<Integer> 和 List<Number> 没有任何父子关系。
所以 List<Number> list = new ArrayList<Integer>(); 这样写,从第一行开始就是编译错误。
1.2 “add 不了”和“传参被拒”是同一个根因
两个看起来不相关的报错,根因是同一个。
第一个场景是上一小节的直接后果:
java复制List<Number> nums = new ArrayList<Integer>(); // 编译错
第二个场景更隐蔽,很多人在写工具方法时遇到:
java复制static void printNumbers(List<Number> list) {
for (Number n : list) {
System.out.println(n);
}
}
List<Integer> ints = Arrays.asList(1, 2, 3);
printNumbers(ints); // 编译错
你可能会想:printNumbers 只是读数据,Integer 显然是 Number 的子类,为什么不能传?问题出在方法签名暴露了 List<Number>,编译器不能假设你只是读。万一我在 printNumbers 里面写一句 list.add(1.0);,而外面实际持有的是 List<Integer>,那运行期就乱了。编译器不知道你只读,它为了保住类型安全,宁可拒绝这次传参。
1.3 通配符解决的是“类型关系表达”的问题
通配符的价值,就是让你能够把“我只读,不写”这个意图告诉编译器。
List<? extends Number> 的意思是:元素类型是 Number 的某个子类型的 List,具体叫什么名字我不知道,也不关心。这个“不知道”不是缺陷,而是关键——因为编译器能基于这个“不知道”推断出哪些操作绝对安全、哪些操作绝对危险:
- 读取一定安全。不管实际是
List<Integer>还是List<Double>,读出来的元素一定是Number的子类型。 - 写入一定危险。编译器不知道具体是哪个子类型,所以除了
null之外不允许你写入任何值。
理解了这一层,后面所有通配符的规则都是这个思路的自然延伸。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 上界通配符 ? extends T:一个只读不写的“高处视图”
2.1 语法与安全规则
上界通配符写作 ? extends T,读作“? 是 T 的某个子类型”。它的作用是放宽方法参数的接收范围:
java复制static void sum(List<? extends Number> nums) {
double total = 0;
for (Number n : nums) {
total += n.doubleValue();
}
System.out.println(total);
}
// 调用
sum(Arrays.asList(1, 2, 3));
sum(Arrays.asList(1.5, 2.5));
sum(Arrays.asList(BigDecimal.ONE));
三种列表都能传进去。方法内部不关心具体元素是什么类型,只关心它们能不能通过 Number 类型的引用来访问。
构造这种变量时,可以指向任意具体的子类型:
java复制List<? extends Number> nums;
nums = new ArrayList<Integer>();
nums = new ArrayList<Double>();
nums = new ArrayList<BigDecimal>();
但一旦变量声明为 List<? extends Number>,编译器就会“忘记”具体类型,只保留“它是某种 Number 子类型”这个事实。这个“忘记”是编译期的安全机制,不是 bug。
2.2 为什么不能 add:把两个实例代入就知道了
回到开头那个场景:
java复制List<? extends Number> nums = new ArrayList<Integer>();
nums.add(1); // 编译错
初看时,nums 实际指向 ArrayList<Integer>,add(1) 好像完全没问题。但编译器不能只看这一行,它要保证的是:无论这个变量指向哪种具体类型,add 都安全。换一行再看:
java复制List<? extends Number> nums = new ArrayList<Double>();
nums.add(1); // 如果允许,Integer 就被塞进了 Double 列表
看到了吗?同一个变量,完全可能指向 ArrayList<Double>。所以编译器只能一刀切:你不知道具体是哪个子类型,就不能往里面 add 任何非 null 的对象。
这里顺带说一个大家容易忽略的点:add(null) 是允许的。因为 null 可以赋值给任何引用类型,把它放进一个 ArrayList<Number> 也好、ArrayList<Double> 也好,都不会破坏任何类型约束。
2.3 正确读取姿势:向上转型赋值
既然读取一定安全,那从 ? extends T 集合里 get 出来的元素,用 T 或 Object 引用接收就行:
java复制List<? extends Number> nums = new ArrayList<Integer>();
Number first = nums.get(0); // 安全
Object obj = nums.get(0); // 也安全
但你要是想直接赋值给具体子类型,比如:
java复制Integer first = nums.get(0); // 编译错,编译器只知道是 Number 子类,不能确定是 Integer
就必须强转。强转能过编译,但运行期不一定安全,因为实际可能是 ArrayList<Double>。所以我在实际代码里很少对 ? extends 的元素做强转,而是尽量让业务逻辑建立在 T 或 Object 之上。
2.4 上界通配符的典型应用场景
那么 ? extends T 到底用在哪?
最典型的场景是“方法只消费集合里的数据,不修改集合”,比如统计、求和、打印、序列化。你去看 JDK 源码,Collections.max 和 Collections.min 的签名都是:
java复制public static <T extends Object & Comparable<? super T>> T max(
Collection<? extends T> coll)
第一个参数 Collection<? extends T> 就是只读。调用方传 List<Integer>、List<String> 都能进来,方法内部按 T 读取即可。
我自己在项目里写通用工具方法时,也遵循这个习惯:
java复制static <T> double avg(List<? extends Number> nums) {
return nums.stream()
.mapToDouble(Number::doubleValue)
.average()
.orElse(0);
}
这个签名一写出来,调用方就知道:这个方法是只读的,不会篡改我传进去的列表。这本身就是一种 API 自文档化。
3. 下界通配符 ? super T:一个“接受写入”的消费端
3.1 语法与写入安全
下界通配符写作 ? super T,读作“? 是 T 的某个父类型”。它不限制调用的“生产”,而是限制调用方的“消费能力”。先看一个最常见的例子:
java复制class Fruit {}
class Apple extends Fruit {}
class BigApple extends Apple {}
static void addApple(List<? super Apple> basket) {
basket.add(new Apple());
basket.add(new BigApple()); // Apple 的子类也没问题
}
调用时可以传:
java复制List<Fruit> fruits = new ArrayList<>();
addApple(fruits);
List<Object> objects = new ArrayList<>();
addApple(objects);
List<Apple> apples = new ArrayList<>();
addApple(apples);
这说明 ? super Apple 让方法能接收“元素类型是 Apple 或者是 Apple 父类”的列表。为什么写入安全?因为不管实际是 List<Fruit>、List<Object> 还是 List<Apple>,它们都能容纳一个 Apple 对象。编译器只要求“能装得进去”,所以这个 add 是安全的。
注意,addApple 里如果写 basket.add(new Fruit()); 就会编译失败。为什么?因为变量实际可能指向 ArrayList<Apple>,Apple 这个类型的容器装不下 Fruit。编译器不知道具体是 Apple 自己还是更上层的父类型容器,所以只允许 add Apple 及 Apple 的子类型。
3.2 读取受限:只能看到 Object
下界通配符的“代价”在读取端:从 List<? super Apple> 中 get 出来的元素,最多只能保证它是 Object,因为实际元素类型可能是 Fruit、可能是 Object,编译器只能向上收敛到 Object。
java复制List<? super Apple> basket = new ArrayList<Fruit>();
Object obj = basket.get(0); // 安全
Fruit f = basket.get(0); // 编译错,编译器不能保证一定是 Fruit
所以 ? super T 适合“写多读少”的场景。如果一个方法既要写又要读,而且读取时还需要保持相对具体的类型,就不适合用下界通配符,而应考虑泛型方法。
3.3 下界通配符的典型应用:比较器与批量填充
下界通配符在 JDK 里最常见的位置是“比较器”相关 API。Collections.sort 的原型是:
java复制public static <T> void sort(List<T> list, Comparator<? super T> c)
为什么 Comparator 要用 ? super T,而不是直接 Comparator<T>?因为比较器往往是“越通用越好”。如果我需要对 List<Integer> 排序,一个 Comparator<Number> 是完全可以胜任的——Integer 是 Number 的子类,两个 Integer 之间的比较完全可以通过比较它们的父类型 Number 的规则完成。反过来,Comparator<Integer> 却未必能用于 List<Number> 的排序。
再比如 Collections.fill:
java复制public static <T> void fill(List<? super T> list, T obj)
这个方法就是往一个列表里反复填充同一个值。目标列表只被写入,不读取,所以用 ? super T 做消费端。调用时 List<Number> list = ...; Collections.fill(list, 0); 完全没问题,Number 类型的列表天然可以接收 Integer。
3.4 什么时候你会想到用 ? super T
实际开发中,我总结出一个判断信号:如果方法里有一段代码的主要动作是“把对象往某个容器里塞”,而这个容器是调用方传进来的,那这个参数大概率要设计成 ? super T。典型场景有:
- 批量导入、填充、收集结果;
- 排序、比较时传入的
Comparator; TreeMap/TreeSet的构造器参数Comparator<? super K>;- 把多个源集合往一个目标集合里 merge 的合并方法。
当你发现自己之前写的方法“只能接受 List<Object>,而不能接受 List<Number> 或 List<Integer>”,然后为了兼容性重载了好几个签名时,回过头来检查一下,多半应该是用 ? super T 收口。
4. 无界通配符 ?:连类型都不确定时,怎么安全操作
4.1 List<?> 和 raw type List 的本质区别
无界通配符 List<?> 是三种通配符里最容易被轻视的,因为它的限制最多。但它和原始类型 List 的区别非常关键:
java复制List rawList = new ArrayList<String>();
rawList.add(1); // 编译器不拦,运行期可能 ClassCastException
List<?> wildcardList = new ArrayList<String>();
wildcardList.add(1); // 编译错,编译器不允许
原始类型 List 相当于完全放弃了类型检查,add 任何对象都不会报警告;而 List<?> 虽然也不知道具体元素类型,但编译器仍会强制检查:向一个未知类型的列表里 add 非 null 对象,一律拒绝。所以如果你在代码库里看到历史遗留的 List、Map 裸泛型,一个低成本但很有效的类型安全修复就是改成 List<?> / Map<?, ?>。
4.2 无界通配符的读写边界
无界通配符的规则很简单:
- 可以读,但读出来的引用只能赋给
Object; - 不能写,只能写
null; - 可以调用不依赖具体元素类型的方法,比如
size()、clear()、isEmpty()、contains(null)。
它适用于“我这个方法只关心集合长度 / 是否为空 / 遍历后转成字符串”这类场景。比如:
java复制static int safeSize(List<?> list) {
return list.size();
}
用 List<?> 接收参数后,调用方传 List<Integer>、List<Double>、List<String> 都行,方法内部也不会误改数据。
4.3 通配符捕获:在 List<?> 里实现 swap
有基础的朋友知道 List<?> 不能 add,但有时方法逻辑确实需要交换两个元素,比如 JDK 里的 Collections.swap。为什么 swap 能工作?因为 JDK 内部做了一次“通配符捕获”。
直接写是过不了编译的:
java复制public static void swap(List<?> list, int i, int j) {
list.set(i, list.get(j)); // 编译错
}
编译器认为 list.get(j) 返回的是“某个未知类型”,而 list.set(i, ...) 要求传入同一个未知类型,它无法证明这两者是同一个类型,于是拒绝。
解决办法是用一个私有泛型方法把类型捕获下来:
java复制public static void swap(List<?> list, int i, int j) {
swapHelper(list, i, j);
}
private static <E> void swapHelper(List<E> list, int i, int j) {
E temp = list.get(i);
list.set(i, list.get(j));
list.set(j, temp);
}
在 swapHelper 里,List<E> 的泛型参数是确定的 E,所以 get 出来是 E、set 进去也是 E,编译器放行。这个 trick 在面试里经常被拿来考察对“通配符未知类型是固定类型,而非任意类型”的理解程度。
4.4 无界通配符的日常位置
无界通配符在项目里最常见的位置是 Class<?>:
java复制Class<?> clazz = Class.forName("java.lang.String");
Class.forName 返回 Class<?>,是因为我们不可能预知加载的类具体是什么。调用方如果需要具体类型,可以进一步 cast 或通过泛型方法桥接。
另外,instanceof 后面也建议用无界通配符:
java复制if (obj instanceof List<?>) {
// 安全,能判断是 List 家族
}
obj instanceof List<String> 是编译不过的,因为泛型被擦除了;但用 List<?> 就可以,既不用裸类型 List 去匹配,也不会产生 unchecked 告警。
5. PECS 原则:Producer Extends,Consumer Super
5.1 一句话说明白 PECS
PECS 是 Producer Extends, Consumer Super 的缩写,意思是:
- 如果参数集合是生产者(Producer),即“只从里面拿数据”,用
? extends T; - 如果参数集合是消费者(Consumer),即“只往里面放数据”,用
? super T; - 如果既要从里面读、又要往里面写,不能用通配符,回归
T本身。
为什么“又读又写”不能用通配符收敛?因为你一旦选了 ? extends T,写操作就废了;一旦选了 ? super T,读取时类型又会被压缩到 Object。两者都满足读取和写入的类型安全,只有用确定的 T。
5.2 用 Collections.copy 验证 PECS
理解 PECS 最好的教材是 Collections.copy 的源码签名:
java复制public static <T> void copy(List<? super T> dest, List<? extends T> src)
src是生产者,只读不写,所以是? extends T;dest是消费者,只写不读,所以是? super T。
这个签名带来的实际弹性是:我可以把 List<Integer> 的内容拷贝到 List<Number> 里,这在旧签名 copy(List<T> dest, List<T> src) 下是做不到的。
java复制List<Integer> src = Arrays.asList(1, 2, 3);
List<Number> dest = new ArrayList<>(Arrays.asList(0, 0, 0));
Collections.copy(dest, src);
编译通过。dest 不需要是 List<Integer>,只要是能容纳 Integer 的父类型容器(List<Number>、List<Object>)都行。这就是 PECS 对 API 弹性的意义。
5.3 泛型方法 vs 通配符,边界在哪
很多人分不清“什么时候用通配符,什么时候用泛型方法”。我给一个简洁的判断标准:如果一个类型参数在方法里只出现一次,大概率通配符就够了;如果一个类型参数在参数、返回值、方法内部多处引用,必须使用泛型方法。
举个例子,提取列表最后一个元素并返回它:
java复制static <T> T lastOf(List<T> list) {
return list.get(list.size() - 1);
}
这个场景 T 既出现在参数里,又出现在返回值里,必须用泛型方法。如果你试图用 static Object lastOf(List<?> list),调用方拿到 Object,再往下做业务还得强转,体验很差。
再看 Collections.max,它同时展示了泛型方法和通配符的协作:
java复制public static <T extends Comparable<? super T>> T max(Collection<? extends T> coll)
- 返回值需要
T,所以整体是泛型方法; - 集合只读不写,所以参数用
? extends T; - 比较器消费
T,所以Comparable用? super T。
三件事分别做对了,方法签名才能既安全又灵活。
5.4 PECS 的常见误用与两个记忆手段
我最常看到的两类误用:
第一类是把 ? extends T 当成“可以装任意 T 子类的容器”,这是开头那个报错的高频来源。记住:? extends T 只保证读取安全,不适合写入。
第二类是在方法返回值上滥用通配符。有人图省事,把工具方法写成 List<? extends Number> getData(),结果调用方拿到一个“元素类型未知”的列表,既不 add,也不 get 成具体类型,操作空间极窄。经验做法是:返回类型尽量用确定泛型,通配符主要用在参数、字段声明和局部变量上。
记忆手段有两个。一个是英文口诀 PECS(Producer Extends, Consumer Super);另一个是中文版的“读 extends,写 super,读写都用 T”。哪个顺手用哪个,但用法不能记混。
三类通配符的关键差异可以收敛成一张表:
| 通配符 | 语法 | 读取结果 | 写入限制 | 典型场景 |
|---|---|---|---|---|
| 上界 | ? extends T |
可赋给 T 或 Object |
只能写 null |
只读集合:求和、max、copy 的源端 |
| 下界 | ? super T |
只能赋给 Object |
可写 T 及其子类 |
只写容器:fill、copy 的目标端、Comparator |
| 无界 | ? |
只能赋给 Object |
只能写 null |
不依赖具体类型的操作:size、clear、反射 |
6. 面试与实战中的高频坑点盘点
6.1 面试高频问法速答
以下是 Java 面试里通配符相关的常见问题,我把要点列出来,方便你快速过一遍:
| 问题 | 核心答案 |
|---|---|
List<Object> 和 List<?> 有什么区别? |
List<Object> 可以 add 任意 Object,读出来也是 Object;List<?> 不能 add 非 null 对象,读出来也只能赋给 Object。List<?> 更保守 |
List<? extends Number> 为什么不能 add Integer? |
因为实际列表可能是 List<Double>,编译器无法保证安全,只能笼统拒绝 |
List<? super Integer> 能 add 什么? |
Integer 以及 Integer 的子类(如果存在),还有 null;不能 add Integer 的父类型 |
| 数组为什么协变而泛型不变? | 数组在运行时保存元素类型,能兜底;泛型通过擦除实现,运行时没有类型信息,只能在编译期严格 |
通配符 ? 和类型变量 T 什么时候用? |
? 适合“类型只出现一次”;T 适合“类型在参数、返回值、内部逻辑多处出现” |
为什么要用 List<?> 而不是裸 List? |
裸 List 放弃了编译器类型检查,List<?> 仍能防止危险写入 |
6.2 实战里常见的编译报错怎么读
我平时在 IDE 里看到大量跟泛型有关的报错,很多人一看带 “capture of ...” 就懵了。其实这类报错恰恰是在提示你“某个通配符未知类型”出问题了。
比如:
code复制required: capture of ? extends Number
found: Integer
这是在告诉你:目标类型是 ? extends Number 的捕获类型,而你提供一个 Integer。多数情况下是因为你在对 List<? extends Number> 做 add 操作,编译器认为不安全。
再比如:
code复制required: List<? super T>
found: List<Number>
常见于把 List<Number> 传给了一个参数是 List<? super Integer> 的方法——等一下,这个应该是可以传的。报错往往是因为你传的是 List<Double> 或者 List<Integer> 与 ? super 不匹配时的类型不一致。遇到这类报错,我建议先停下来把“读 / 写”意图重新对一遍:如果方法要往集合里写 Integer,那调用方传入的集合必须能装下 Integer;如果方法要从集合里读 Number,那传入集合的元素类型必须是 Number 的子类型。
6.3 一些“反直觉”但正确的写法
泛型通配符还有一些写法看起来“违反直觉”,但其实是正确的,知道一个少踩一个坑。
第一个是下界通配符的局部变量用途。假设下游接口要求 List<? super Integer>,而它内部需要往里面填充 Integer 数据,局部变量可以这样接收:
java复制List<? super Integer> sink = chooseSource(); // 可能是 List<Number>、List<Object>,也可能是 List<Integer>
sink.add(1);
sink.add(1) 是安全的。你不需要关心它是 List<Number> 还是 List<Object>,因为 Integer 放进任何父类型容器都不会出错。
第二个是原生类型与通配符的兼容性。裸 List 可以赋值给 List<?>,这是安全的:
java复制List raw = new ArrayList<String>();
List<?> wildcard = raw; // 允许,且不会产生 unchecked 警告
反过来 List<?> 不能直接赋给裸 List 的泛型子类,除非你确定里面的类型并强转,而强转后运行期可能会炸。所以看到老代码里裸泛型返回值,正确的接收姿势是先接到 List<?> 上,再在业务逻辑里按需分支处理。
第三个是返回类型带问号的情况,这也是很多朋友问过我的点。比如 Class<?> 以及某些第三方库返回 List<? extends T> 的 API。返回类型带 ? 意味着调用方无法直接拿到具体类型,只能按上界或 Object 使用。如果你需要进一步处理,通常要通过一个泛型方法做类型桥接:
java复制@SuppressWarnings("unchecked")
static <T> T castTo(Object obj) {
return (T) obj;
}
这种桥接的本质是把“运行时才知道的类型”再一次交给编译器。能少用就少用,但遇到老框架时,它确实能救场。
我自己的体会是,Java 泛型通配符就像一把双刃剑:用对了,API 的弹性会明显提升,调用方能传进各种合理的子类型容器;用错了,编译器和运行期会一起让你难看。建议平时写工具类时多观察 JDK 里那些高阶签名的写法,把 ? extends T、? super T、? 的边界刻在肌肉记忆里。尤其是 PECS 那一条,面试和代码评审时大概率会用到。
