搞 Java 也有年头了我发现在代码评审里最容易引起争论的往往不是业务逻辑而是一行泛型声明。尤其是? extends和? super很多人一看到就条件反射地背出“PECS生产者用 extends消费者用 super”可真到了设计一个方法签名的时候还是拿不准该用哪个面试的时候被问深一层就露馅了。PECSProducer Extends Consumer Super是《Effective Java》第31条提出的泛型通配符使用原则也是 Java 面试八股文里的常客。这篇文章我打算从“为什么要有通配符”讲起把这条原则彻底拆开结合 JDK 源码、面试题和实际开发场景帮你不仅记住口诀还能在写代码时真正用对。1. 从一道经典面试题说起ListDog为什么不是ListAnimal的子类型很多 Java 面试题的第一问往往是给定Animal和它的子类DogListAnimal能接收一个ListDog吗答案是明确的“不能”。但要是追问一句“为什么数组就可以”不少人就开始支支吾吾了。要理解 PECS得先理解这个问题。1.1 数组的协变与泛型的不变性数组在 Java 里是协变的covariant意思是说如果Dog是Animal的子类型那么Dog[]也是Animal[]的子类型。这段代码在语法上是合法的Animal[] animals new Dog[10];但数组在运行时保留了元素类型信息所以往里塞一个不属于该实际类型的对象会立刻抛出异常animals[0] new Cat(); // 编译通过运行时报 ArrayStoreException泛型恰恰相反它是不可变的invariant。ListAnimal与ListDog之间没有任何父子关系ListAnimal不能引用ListDog反过来也不行。这个设计不是拍脑袋决定的而是因为泛型使用类型擦除type erasure编译完成后类型参数信息在运行时基本不复存在JVM 无法像数组那样在运行时做类型校验。如果允许泛型协变编译期又查不出来的话后面就全是雷。1.2 如果泛型也协变会发生什么假设 Java 允许这样的写法// 以下代码如果成立纯粹是灾难 ListDog dogs new ArrayList(); ListAnimal animals dogs; animals.add(new Cat()); Dog dog dogs.get(0); // 运行时必然 ClassCastException编译器如果放行了第 3 行它就必须在dogs.get(0)处插入一次强制类型转换而那只猫会在这一刻原形毕露。泛型设计的初衷就是编译期类型安全把所有可能的类型错误尽量提前暴露而不是留到运行时爆炸。所以 Java 选择让泛型保持严格的不变性宁可让你在编译期麻烦一点也不让运行时出幺蛾子。1.3 通配符出场给类型系统撬开一道灵活的口子问题来了设计一个printAnimals方法希望既能接收ListAnimal也能接收ListDog、ListCat怎么写参数类型直接写ListAnimal是不行的因为不变性卡死了。于是通配符登场——List? extends Animal表示“某种 Animal 子类型的列表”具体的实际元素类型不知道但可以确定地是 Animal 的某种子类。这个问号加 extends就是我们常说的上界通配符。另一边的List? super Dog则表示“某种 Dog 父类型的列表”称为下界通配符。理解了这两个东西PECS 就完成了一大半。2. extends 与 super上界下界通配符各自的能力边界先建立直觉? extends T是“T 或 T 的子类”集合的边界? super T是“T 或 T 的父类”集合的边界。边界不同编译器允许你做的操作就完全不同。2.1 上界通配符? extends T适合只读场景来看一段代码List? extends Animal list new ArrayListDog(); list.add(new Dog()); // 编译错误 list.add(new Animal()); // 编译错误 Animal a list.get(0); // 合法按 Animal 读取list的编译时类型是List? extends Animal它实际可能指向ListDog、ListCat或者ListAnimal本身。编译器无法确定list背后的真实元素类型所以它不允许你往里添加任何元素——除非是null。但是读取是安全的无论实际元素类型是 Dog 还是 Cat它一定是一个 Animal所以用Animal去接收返回值完全没有问题。这就是“上界通配符只能读不能写”的由来。2.2 下界通配符? super T适合只写场景再看下界假设Husky是Dog的子类List? super Dog list new ArrayListAnimal(); list.add(new Dog()); // 合法 list.add(new Husky()); // 合法Husky 是 Dog 的子类 Object obj list.get(0); // 只能以 Object 接收List? super Dog的实际类型可能是ListDog、ListAnimal或者ListObject。往里面添加一个Dog或者它的子类无论哪一种实际类型都能装得下所以写操作在编译期是安全的。可一旦要从里面读取编译器就头疼了返回的元素到底该按什么类型看DogAnimal还是别的源头完全无法确认那就只能给出最保守的Object。所以“下界通配符只能写不能读”的准确说法应该是“只能以 Object 的身份去读”。2.3 无界通配符?我不关心元素类型List?表示元素类型未知的列表。它和裸类型List有本质区别List是放弃类型检查List?是让编译器仍然强制保证类型安全只是我们不关心具体元素是谁。典型的使用场景包括public static void printSize(List? list) { System.out.println(list.size()); }比如Class?、List?这类出现就是明确告诉编译器“我只想调用与元素类型无关的方法比如 size()、isEmpty()或者做一次泛型转换。”无界通配符本质上就是? extends Object的简写能力边界和上界通配符一致不能写入null 除外读取时以 Object 接收。把三种通配符的能力边界汇总一下方便对比记忆通配符读取元素写入元素典型用途? extends T可按 T 读取禁止null 除外读取数据源? super T只能按 Object 读取可写入 T 及其子类写入目标容器?只能按 Object 读取禁止null 除外只关心容器本身3. 源码不会骗人在 JDK 里看 PECS 原则如何落地PECS 是 Joshua Bloch 在 Effective Java 里总结的通配符使用原则如果参数是一个“生产者”即方法需要从中读取数据用? extends T如果参数是一个“消费者”即方法需要往里写入数据用? super T。怎么把这句话翻译成代码最直观的验证方式就是去看 JDK 源码里那些活跃了二十多年的老方法。3.1Collections.copy教科书级别的 PECS 案例Collections.copy的签名是public static T void copy(List? super T dest, List? extends T src)这个方法的作用是把 src 里的元素复制到 dest。src 是“生产者”因为它向方法提供数据方法要不断从它里面 get所以用? extends Tdest 是“消费者”因为方法要不断往里面 add所以用? super T。这个设计带来什么好处我想把ListDog复制到ListAnimal中是合法的反过来把ListAnimal复制到ListDog会在编译期就报错。如果没有 PECS你只能写void copy(ListT dest, ListT src)那ListDog和ListAnimal之间的复制就彻底没戏了。这就是为什么我说 PECS 解决的不仅是“能不能写”更是 API 复用边界的扩展。3.2Collections.max与Comparator? super T比较器为什么是 super再看看 JDK 的Collections.max的两个重载签名public static T extends Object Comparable? super T T max(Collection? extends T coll); public static T T max(Collection? extends T coll, Comparator? super T comp);第一个参数Collection? extends T是生产者方法只需要从集合里取元素做比较所以用 extends。第二个参数Comparator? super T是典型的“消费型参数”我们要把集合里的元素逐个交给比较器去比较比较器的类型必须是 T 的父类型或者 T 本身才能用统一的接口比较所有 T 的子类实例。举个例子你有一个ComparatorAnimal它可以用来比较ListDog因为 Dog 天然是 Animal但反过来如果参数写死成ComparatorT当一个ListDog遇到ComparatorAnimal时就失去了这种灵活性。你还能在Collections.sort、Arrays.sort以及Stream.sorted里看到同样的Comparator? super T这套模式在 JDK 里遍地都是。3.3 现代 Java API 里的 PECSStream、Optional新一点的 API 同样在遵循 PECS。比如Stream.collectR, A R collect(Collector? super T, A, R collector);collector要消费流里的每个元素所以是消费者用? super T。再看Optional.orElseGetpublic T orElseGet(Supplier? extends T other);Supplier是按需提供一个 T 类型的值它是生产者方法要从它那里 get 数据所以用? extends T。这种写法保证了你传一个SupplierDog到需要SupplierAnimal的地方也不会编译报错。把这些 API 串起来看你会发现 PECS 不是考试专用技巧而是整个 JDK 设计里一以贯之的类型思维。4. 把“不能写/不能读”说明白类型安全背后的编译器逻辑背口诀容易但真要回答“为什么 extends 不能 add”面试官其实想听的是类型推断。这一节我从编译器的视角把这两条规则讲透。4.1 extends 禁止 add 的真正原因编译器不知道实际类型当参数类型是List? extends Animal时编译器能确定的只有一件事这个列表的元素类型是 Animal 的某个子类型但究竟是哪一个编译期不知道。可能是ListDog也可能是ListCat。在不知道具体类型的情况下尝试执行list.add(new Dog())如果实际列表是ListCat那么这只 Dog 就混进了一群 Cat 里后面get出来转成Cat时必然出错。编译器的职责是在编译期防住所有可能出错的路径所以它干脆一刀切除了null谁都不许 add。null之所以是例外是因为它可以赋给任何引用类型放进任何列表都不会破坏类型约束。4.2 super 读取返回值为什么只能是 Object反过来List? super Dog的实际类型可能是ListDog、ListAnimal、ListObject中的任意一种。从中get一个元素静态类型可能是三种情况编译器拿不准返回类型。唯一的公共类型就是Object于是通配符的读取接口只好收敛到Object。很多初学者在这里会迷惑“我用List? super Dog里明明放的都是 Dog为什么从底层读出来还要强转”底层数据是什么不重要编译器的类型规则只看静态类型能推出什么结论这是理解整个章节的关键。换句话说通配符带来的是“类型安全前提下的灵活”而不是“随心所欲的便利”。4.3 类型擦除视角通配符在字节码层面变成了什么Java 泛型在编译后会发生类型擦除。? extends T的边界是 T所以List? extends Animal在擦除后会保留上界信息? super T的边界是向 Object 方向开放的所以擦除后大致等同于以 Object 为界的形态。这也解释了为什么反射拿到的方法签名里能看到通配符边界而实际调用的字节码里全是 Object 和强制类型转换。理解了擦除遇到那种“泛型方法重载怎么又冲突了”的编译错误也能更快反应过来——很多看似合理的重载组合擦除之后签名一模一样JVM 根本无法区分。这一点在面试里也是高频出题点。5. 面试高频考点通配符、泛型方法与类型擦除的恩怨情仇这一节专门整理面试里常被问到的泛型通配符问题以及容易翻车的细节。5.1ListObject和List?有什么区别ListObject可以往里 add 任意对象get 返回类型是 ObjectList?只能 add nullget 返回 Object。更重要的是ListObject是具体的参数化类型而List?是通配符类型它可以接收ListString、ListInteger等任何类型的 ListListObject却只能接收ListObject——这一点和泛型不变性是一致的。可以理解成List?描述的是“任意元素类型的列表”ListObject描述的是“元素类型恰好是 Object 的列表”。两者不是一回事面试时别绕晕了。5.2 泛型方法T和无界通配符?怎么选同一个场景两种写法// 通配符写法 public static void printList1(List? list) { for (Object o : list) { /* ... */ } } // 泛型方法写法 public static T void printList2(ListT list) { for (T t : list) { /* ... */ } }两者的区别在于泛型方法能捕获类型参数T在方法内部多个位置使用同一个 T而通配符不能捕获类型。如果需要把元素类型传给另一个泛型方法比如把 list 里的元素依次交给一个ConsumerT就必须用泛型方法public static T void consumeAll(ListT list, ConsumerT consumer) { list.forEach(consumer); }用通配符的话编译器在list.forEach(consumer)这行就不知道怎么把?和T对上号了。规则可以记成同一个类型参数要在方法体里出现两次以上时用泛型方法只是简单遍历而不关心类型时用无界通配符更简洁。除此之外还有一道常考的类型擦除题void foo(ListString)和void foo(ListInteger)为什么不能重载因为擦除后都是void foo(List)签名相同JVM 无法区分。5.3 一个综合案例实现通用的合并方法面试官如果让你“写一个方法把 src 中的所有元素添加到 dest 中并能处理 Dog 和 Animal 的集合”正确的签名应该长这样public static T void addAll(List? super T dest, List? extends T src) { dest.addAll(src); }这里 src 是生产者读dest 是消费者写。但如果面试官再追问“我不想引入类型参数 T能不能直接用List? super Object”——这时候就大方承认不行因为它的能力不够。这个方法不见得要多炫技能把 PECS 用对说明你真的理解了泛型边界这比背一百道题都有说服力。5.4 原始类型和 unchecked 警告看起来能用实际埋雷最后提醒一点遇到List、Map这种不带类型参数的裸类型编译器会给出 unchecked 警告但代码还是能跑。有些人图省事用裸类型绕过了通配符的报错结果一到取数据就 ClassCastException。我的建议是凡是编译阶段出现的泛型问题宁可改签名设计也别用裸类型硬堵因为运行时错误排查成本远高于编译期几行报错。6. API 设计实战什么时候该用 extends什么时候该用 superPECS 不只用来应付面试更是设计公共 API 时必备的判断工具。我自己做代码评审时基本就用一个标准来检查大家的泛型签名这个方法到底是要从参数里取数据还是要往参数里放数据6.1 参数方向的三个判断步骤拿到一个待设计的方法先三步走判断参数的用途是作为数据源被读取还是作为目标容器被写入还是两者都有只读 → 考虑? extends T主要写入 → 考虑? super T读后写或者写后读、类型需要保持一致 → 直接用ListT更简单。能不引入通配符就不引入别把简单问题复杂化。比如写一个统计函数average(List? extends Number nums)因为只需要从集合里读元素算平均值所以用 extends 是恰当的它让我们能同时接收ListInteger和ListDouble。但如果方法既要把元素加进去又要读出结果做处理比如实现一个通用的队列那直接使用ListT作为内部数据结构反而更舒服硬套 PECS 反而给自己找麻烦。方法意图推荐签名从参数中读取元素List? extends T向参数中写入元素List? super T读写都需要且类型一致ListT6.2 返回值尽量不要用通配符这是一个很实用的小贴士公共 API 的返回值尽量别声明成List? extends Something。原因在于调用方一旦拿到带通配符的返回值这个“未知类型”就会向调用方的代码传染——他们想继续操作集合时被迫背上同样的通配符限制代码变得非常难用。JDK 里也很少在返回值上滥用通配符。如果确实需要隐藏内部实现类型那是另一个“不可变集合视图”的话题不该靠通配符硬撑。6.3 评审时常见的三个反面教材我在评审里见过不少泛型翻车现场这里列三个典型的// 反面 1参数只读却用了 ListT导致 ListDog 传不进来 static Animal getFirst(ListAnimal list) { /* ... */ } // 反面 2往 List? extends Animal 里 add编译期直接报错 static void addDog(List? extends Animal list) { list.add(new Dog()); // 编译错误 } // 反面 3copy 的目标参数写成了 ListT导致 ListAnimal 无法作为 dest static T void copyTo(ListT dest, List? extends T src) { /* ... */ }反面 1 的修正方法是把ListAnimal改成List? extends Animal反面 2 的修正方法取决于意图若方法要往里添加 Dog参数应改成List? super Dog反面 3 的修正方法就是 JDK 里Collections.copy的做法把 dest 改成List? super T。这三个例子能覆盖日常 80% 的泛型签名问题。PECS 这套规则我从背口诀到真正理解中间隔了好几年。最后让我真正开窍的不是看了多少篇文章而是动手去 JDK 源码里翻那些熟悉的方法签名再亲手设计两三个公共 API被编译器教训几轮之后extends 和 super 的边界就长在肌肉记忆里了。如果你现在还在为通配符头疼我建议你别急着刷题先做一件事把你常用的Collections和Stream方法签名各抄两个对着 PECS 逐个分析分析完再回去改自己项目里的一个泛型方法。这个流程走完面试题和实际开发都不会再怵泛型了。另外还有个小技巧每次写方法签名之前先自问一句“这个方法是从参数里取数据还是往参数里放数据”比背任何口诀都管用。