
每次看到public E ListE get()这种声明留言区总有人问这个E放在返回类型前面到底是干什么的为什么T有时候写在类名后面有时候又写在方法名前面还有? extends和? super背了八百遍PECS规则一写代码还是报错这篇文章不打算装高深就用我平时真实改代码的例子把Java泛型从类到方法、从通配符到类型擦除一层层剥开。看完你至少能搞明白三件事一类泛型该怎么设计二方法是泛型怎么读懂和写出来三为什么擦除之后还那么多坑。全程配合可运行的代码片段你照着敲一遍比光看强十倍。1. 泛型到底解决什么问题先回到没有泛型的年代很多讲泛型的文章喜欢直奔语法结果新手看完只知道T是随便一个类型根本不懂为什么要设计这些东西。所以我们先回到JDK 1.5之前看看没有泛型的世界有多拧巴。1.1 没有泛型的日子强转、脏数据和运行期爆炸在泛型出现之前Java的集合类内部存的全是Object。这意味着你往List里塞一个字符串、塞一个整数、塞一个自定义对象编译器全部放行因为它们都能安全地向上转型成Object。看起来灵活实际是灾难的温床。List list new ArrayList(); list.add(hello); list.add(Integer.valueOf(42)); String first (String) list.get(0); // 侥幸成功 String second (String) list.get(1); // 运行期ClassCastException这段代码编译期一切正常只有运行到第5行才炸出异常。更麻烦的是报错的位置往往离真正的脏数据写入点十万八千里。实际项目里可能是一个工具类把Integer放进了本该只存字符串的list另一个模块两百行之后去取取完强转然后整个接口就500了。排查成本极高。那种先存Object、取出来再强转的模式把类型安全的检查责任完全压到了程序员身上。人都会犯错这种错误还特别隐蔽、特别难复现。大家之所以烦它核心原因在这里。1.2 泛型带来的三个价值检查、省代码、抽象复用泛型出来之后上面的代码变成了这样ListString list new ArrayList(); list.add(hello); // list.add(Integer.valueOf(42)); // 编译报错类型不匹配 String first list.get(0); // 不需要强转看起来只是加了个String但编译器替你把了一道关写错类型当场报错取出来直接就是目标类型。总结下来泛型的价值集中在三个方面编译期类型检查把原本运行期的ClassCastException提前到编译期让错误在最早阶段暴露。这是泛型最核心的价值。消除强制类型转换代码不再到处是(String)这种强转读起来干净也不容易出现转错类型这种低级失误。类型层面的代码复用和抽象一个BoxT可以装String、装Integer、装任意自定义类型同一套逻辑对不同类型复用而且这种复用仍然保留类型精度。第三点尤其重要它把面向对象的多态和泛型的类型抽象区分开来。继承也能复用代码但继承要求类型之间有父子关系泛型不要求ListString和ListInteger之间没有继承关系但List的代码逻辑可以服务两者。这是两种完全不同维度的抽象。2. 泛型类从最简单的Box到复杂继承关系泛型类是比较容易理解的部分因为它和普通类的写法差别不大只是多了个类型参数列表。但万万不要觉得简单就跳过类上的泛型和后面的泛型方法、类型擦除、通配符全都缠绕在一起。2.1 基础写法类型参数就是类里的类型占位符如果你写一个最简单的盒子类可以这样public class BoxT { private T item; public void set(T item) { this.item item; } public T get() { return item; } }这里T就代表将来使用Box时指定的那个类型。使用的时候BoxString stringBox new Box(); stringBox.set(hello); String s stringBox.get(); BoxInteger intBox new Box(); intBox.set(42); Integer n intBox.get();编译器看到BoxString之后会认为这个实例内部的set方法参数必须是Stringget方法的返回值也是String。如果往里面塞Integer编译直接报错。T这个名字没有任何魔法它只是一个类型变量你叫它T、E、K、V都行行业内约定俗成而已T代表Type、E代表Element、K和V代表Key和Value、R代表Result。2.2 泛型类的继承这里藏着最大的误区和隐藏规则泛型类之间的继承常见的写法有下面两种第一种子类也保留类型参数public class StringProcessorT extends BaseProcessorT { // 子类和父类用的是同一个T }这种场景适合子类想沿用父类的类型处理逻辑并且增加自己的一些行为。例如BaseProcessorT实现公共的验证、序列化逻辑子类StringProcessorT在此基础上扩展。第二种子类直接确定父类的类型参数public class StringProcessor extends BaseProcessorString { // 父类的T在继承时被确定成String }这种写法意味着StringProcessor本身不再是一个泛型类它就是一个普通类专门处理String。用起来也更简单new StringProcessor()就行。但真正让很多人掉坑里的是这个逻辑BoxString和BoxObject之间没有任何继承关系。很多人觉得String是Object的子类那BoxString也应该是BoxObject的子类吧不是。考虑下面这段代码如果BoxString是BoxObject的子类会怎样BoxString stringBox new Box(); BoxObject objectBox stringBox; // 假设可以 objectBox.set(100); // 把一个Integer放进了本该是String的盒子里 String s stringBox.get(); // 取出时强转失败这就是泛型的不变性。数组是协变的String[]可以赋给Object[]所以才会出现著名的数组存储异常问题泛型特意改成了不变就是为了堵住这个漏洞——所有关于类型的正确性都在编译期锁定。那如果确实想表达某种T的父类关系怎么办答案是通配符后面第五章专门讲。2.3 类型参数的上界给T加个约束条件有时候你不想让类型参数完全放飞希望它们必须继承某个父类或实现某个接口这时候用extends关键字public class NumberBoxT extends Number { private T number; public double doubleValue() { return number.doubleValue(); } }有了这个上界NumberBoxInteger可以用NumberBoxDouble可以用NumberBoxString直接编译失败。同时在类内部编译器还知道T至少是个Number所以可以放心调用doubleValue()这些Number上的方法。如果没有上界T会被擦除成Object编译器压根不允许你调用Number的非Object方法。这里记住一个关键extends后面既可以跟类也可以跟接口甚至可以用连接多个限制比如T extends ComparableT Serializable类必须排在第一个。这在后面的泛型方法里同样是规则。3. 泛型方法T T和T到底差在哪现在到标题里最核心的部分了。很多朋友就是卡在这里类名上已经有了T方法前面怎么还有个T这个T和类名上的T是不是同一个不搞清楚这个后面public E ListE get()永远是看着眼熟、写起来手生。3.1 方法声明里没写T时T来自类上先看没有在方法前声明类型参数的场景public class WrapperT { private T value; public T getValue() { return value; } public void setValue(T value) { this.value value; } }这里的getValue()返回类型是T但这个T不是方法自己发明的它来自类名上的WrapperT。也就是说当你在类上声明了类型参数T之后类里的实例方法天然就能使用这个T不需要再在方法前面重复声明一遍。这就是很多人混淆的起点看到public T getValue()以为T是凭空出现的关键字。其实它只是类的一个成员作用范围是整个类体包括实例字段、实例方法、内部类。但注意静态方法不能用类上的T原因后面说。3.2 方法前写T时T属于方法自己再来看另一种public class Util { public static T T getMiddle(T[] array) { if (array null || array.length 0) { return null; } return array[array.length / 2]; } }注意public static和T之间的顺序先修饰符再声明类型参数再写返回类型。这个T声明了一个方法级别的新类型变量它的作用域仅限于当前方法。方法参数里的T、返回值里的T都指向这个新声明的方法级T与任何类上的类型参数无关即使这个类恰好也叫UtilT。这是理解标题里T T的关键第一个T是声明第二个T是返回类型中使用这个声明。3.3 逐字拆解public E ListE get()现在拿到经典这句public E ListE get()一个词一个词拆public访问修饰符。E声明这是一个泛型方法方法内部使用一个叫E的类型变量。ListE返回类型。返回的不是裸List或ListObject而是元素类型为E的List。E到底是什么由编译时的上下文推断。get()方法名无参数。所以这个方法的含义是该方法在执行过程中会产生某种类型E的元素最终把这些元素放进一个ListE返回调用方不需要强转直接拿到目标类型。举个例子假设我要从一个Set里拷贝元素到新的Listpublic E ListE copyFromSet(SetE set) { ListE list new ArrayList(); if (set ! null) { list.addAll(set); } return list; }调用的时候编译器会根据SetString自动推断出E是String返回的ListString直接赋值SetString names new HashSet(); names.add(张三); names.add(李四); ListString nameList copyFromSet(names); // 类型推断无需强转如果方法没有写E而是直接写public List get()则返回的是裸List调用方就得自己强转一旦里面混入不同类型元素运行期可能炸ClassCastException。这就是为什么写工具类时泛型方法比裸类型返回更可靠。3.4 类型推断和显式类型实参编译器怎么知道E是什么其实是通过方法参数推断出来的实参names是SetString而形参是SetE于是E String返回值ListE自然就成了ListString。这种机制叫目标类型推断。但如果你希望强制指定某类型可以这样显式写ListString result Util.StringgetMiddle(new String[]{a,b,c});不过Java 8之后推荐上下文推断大多数场景根本不需要显式写类型实参写多了反而啰嗦。还有一种情况是返回值和方法参数无法建立约束关系。比如public T T build(ClassT clazz) throws Exception { return clazz.getDeclaredConstructor().newInstance(); }调用时User user build(User.class);这里T是通过ClassT这个参数推断出来的。反射场景特别常见很多框架的底层工厂方法就是这么写的。3.5 静态方法为什么必须自带泛型声明前面埋了个伏笔静态方法不能用类上的T。原因是静态方法不依赖类的实例它在类加载阶段就存在而类型参数T是跟着实例走的——WrapperString和WrapperInteger是不同的类型。静态方法不属于任何实例自然访问不到实例级别的T。所以如果想在静态方法里用泛型唯一的办法就是自己声明一个方法级的类型变量public class Factory { public static T T create(ClassT clazz) throws Exception { return clazz.getDeclaredConstructor().newInstance(); } }这也是java.util.Collections里大量工具方法static T void sort(...)、static T ListT unmodifiableList(...)能在静态上下文中使用泛型的原因。3.6 泛型方法的边界约束和重载陷阱泛型方法同样可以加边界public T extends ComparableT T max(T[] array) { if (array null || array.length 0) { return null; } T max array[0]; for (int i 1; i array.length; i) { if (array[i].compareTo(max) 0) { max array[i]; } } return max; }这个约束告诉编译器T必须是可比较的这样就能放心调用compareTo。如果不加边界T被擦除成ObjectObject上没有compareTo方法编译器直接拒绝。泛型方法还有一个很隐蔽的坑——重载冲突。想写两个方法public void print(ListString list) {} public void print(ListInteger list) {}编译直接报错类型擦除后冲突。因为ListString和ListInteger在擦除后都是原生List两个方法签名相同。这个和泛型方法本身无关是泛型擦除导致的重载误伤第四章会展开说。想区分不同类型你只能换方法名。4. 类型擦除泛型为什么只是编译期的幻觉如果只停留在会用泛型的层面类型擦除可以不深究。但当你阅读框架源码、写底层工具或者调试泛型各种诡异编译错误时不理解擦除原理基本等于瞎子摸象。4.1 编译之后类型参数去哪了Java的泛型只在编译阶段生效。编译器用类型参数完成类型检查后会执行一个擦除操作把代码中所有的类型参数替换成它的上界如果没有上界就是Object然后在我们需要强转的地方自动插入类型转换。来看一个例子public class BoxT extends Number { public T get() { /* ... */ } public void set(T item) { /* ... */ } }编译成字节码后T会被替换成Number。方法内部看起来就像public class Box { public Number get() { ... } public void set(Number item) { ... } }而使用端BoxInteger box new Box(); Integer n box.get();编译器发现get()实际返回Number而目标是Integer就自动插入了(Integer)强转。所以从字节码层面看强转还是存在的只不过编译器帮我们放好了位置而且保证不会转错。4.2 用反射验证擦除的存在写一段代码就能直观感受到擦除public class ReflectionTest { public static void main(String[] args) throws Exception { ListString stringList new ArrayList(); ListInteger integerList new ArrayList(); System.out.println(stringList.getClass() integerList.getClass()); // 输出 true二者在运行时都是 ArrayList.class } }ListString和ListInteger在Java虚拟机里就是同一个类ArrayList。你没法在运行时判断一个List是String的还是Integer的擦除已经把类型参数信息从字节码里抹掉了。很多人刚知道这一点时会很不爽那泛型有什么用其实结论恰好相反正因为擦除Java的泛型才能保持向后兼容老的JDK代码、老的第三方库、老的内存布局都不需要改变。这也是Java选择擦除方案的原因和C#那种在运行时保留类型参数真实信息的reified泛型是两种不同的设计哲学。4.3 擦除带来的六个具体限制擦除绝不是无代价的它带来一堆硬性的语法限制每一条背后都是实际崩溃现场总结出来的不能new T()因为擦除后T是Objectnew Object()和你想创建的T类型完全对不上。遇到想创建T实例的需求通常改成传入SupplierT或者ClassT反射创建。不能用instanceof T擦除后T变成Objectobj instanceof T会变成obj instanceof Object这个判断永远为true毫无意义。不能直接new T[]泛型数组创建在运行期会因为类型信息丢失而无法保证数组存储的类型安全。替代方案是用ArrayListT或者用Array.newInstance(ClassT, int)反射创建。静态字段不能使用类级类型参数刚才说过静态成员属于类而类型参数属于实例天然冲突。类型参数不能用于异常捕获你不能写catch (T e)因为异常系统的运行期匹配需要精确类型而泛型被擦除了。重载陷阱void m(ListString)和void m(ListInteger)擦除后签名相同无法并存。擦除本身并不复杂但它解释了Java泛型里80%的为什么这么设计。了解这些限制以后再看框架源码里的ClassT参数、SupplierT参数就会明白那都是为了绕过擦除。5. 通配符让泛型在继承和复用之间找到平衡第二章说过BoxString和BoxObject没有继承关系这是泛型的不变性。但这个极度的安全也带来一个副作用代码灵活度降低。如果有一个方法只需要读取Box里的元素不管它是BoxString还是BoxInteger总不能写两个重载吧这时候就轮到通配符登场。5.1?无界通配符表示某种类型最简单的通配符是?代表某种未知的类型。注意是未知不是任何类型都行随便往里塞。public static void printList(List? list) { for (Object obj : list) { System.out.println(obj); } }这个方法可以接收ListString、ListInteger、ListFoo。因为元素类型未知循环变量统一按Object处理。但如果想往List?里添加元素呢List? list new ArrayListString(); // list.add(hello); // 编译错误无法确定add的参数类型 list.add(null); // 唯一允许的“值”因为null可以赋给任何类型原因很直接list的实际类型可能是ListString也可能是ListInteger编译器只知道某种类型但无法确定哪种干脆禁止任何非null的写入。所以List?适合只读场景。这里有一个常见的操作误区List?和ListObject不一样。ListObject能接收任何Object子类但它本身只能赋给ListObjectList?是个更宽泛的类型可以接收所有ListT的引用但在读取时你只知道元素是Object。5.2? extends T上界通配符安全读取禁止写入如果希望方法接收元素类型是T的某个子类的List用? extends Tpublic static double sum(Collection? extends Number numbers) { double total 0; for (Number n : numbers) { total n.doubleValue(); } return total; }这个方法能接收ListInteger、ListDouble、SetBigDecimal因为它们都是Number的某种子类。读取的时候可以保证元素至少是Number所以直接按Number遍历。但是还是不能写入。想想为什么入参可能是ListInteger如果你往里面add(new Double(1.5))对于真正的ListInteger来说就是写入了Double违类型安全。编译器不知道具体子类型所以一律禁止。5.3? super T下界通配符可以写入读取却受限反过来? super T表示某种T的父类型。典型场景是往集合里添加T及其子类对象public static void addNumbers(List? super Integer list) { list.add(Integer.valueOf(1)); list.add(2); // list.add(Double.valueOf(1.5)); // 编译错误Double不是Integer }这里入参可以是ListInteger、ListNumber、ListObject总之是Integer的某个父类类型。因为目标集合的元素类型是Integer的父类往里面放Integer肯定安全——Integer一定是这个父类的子类型。但读取就尴尬了由于集合元素类型可能是Number、Object或Integer你只知道它是Object无法确定更具体的类型。所以? super在处理写入时好用读取时通常只能按Object取值。5.4 PECS规则Producer Extends, Consumer Super这章是整篇文章里最值得背的一句话。PECS规则是Effective Java作者Joshua Bloch提出的它精确回答了什么时候用extends、什么时候用super如果你只需要读取集合里的元素来生产数据用? extends TProducer。如果你只需要写入元素到集合让集合消费这些数据用? super TConsumer。如果既读又写那就老老实实直接用T别用通配符。看一个最经典的Collections.copy思路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是生产者读元素出来所以用? extends Tdest是消费者收元素进去所以用? super T。这种声明既灵活又安全。其实不用死记口诀每次写完通配符后问自己一个问题这个集合里的元素会被读出来还是被塞进去读出来用extends塞进去用super两边都有就直接T。5.5 通配符和类型参数的选择灵活度与复杂度的博弈既然通配符这么方便是不是能看见?就往上套不是。实际编码中我喜欢按以下经验判断优先使用类型参数T当你有以下需求返回值里有T且调用方想直接拿到具体类型。多个参数之间有关联void put(K key, V value)中两个参数需要成对的同一类型。方法内部需要把参数传给另一个泛型方法类型参数比通配符更容易传播。优先使用通配符?当满足方法内部只读不写。方法的逻辑和具体类型无关比如打印所有元素、统计数量。你想表达这个参数可以是家族中的任意一员而不是某个固定但尚未确定的类型。举个反例如果你写public T T getFirst(ListT list) { return list.get(0); }其实完全可以用?配合类型推断来减少声明public static Object getFirst(List? list) { return list.get(0); }但这样返回的就是Object调用方拿不到精确类型。所以如果你希望返回String、Integer这些具体类型就必须用泛型方法而不是通配符。通常我会让返回类型需要精确的方法走T只读辅助工具走?。6. 实战中的高频坑泛型编译错误速查与排查思路前面理论讲了不少真正写代码时还是容易被编译器和各种怪问题教训。这章整理一下我个人在项目中踩过、也被读者问过最多的几个泛型问题附排查思路和解决方案。6.1 常见错误速查表常见报错/现象根本原因解决办法Cannot instantiate the type T擦除后无法new T()传入SupplierT或ClassT反射创建Incompatible types: ListString cannot be converted to ListObject泛型不变性改用? extends Object或List?incompatible types: Object cannot be converted to String使用? super读取只能拿到Object读取时明确接受Object或者改用泛型方法Exception ... is never thrown in body of corresponding try statement泛型不能catch不能捕获未知类型异常避免在泛型方法里catch具体异常Method m(ListString) has the same erasure as m(ListInteger)擦除后重载冲突改名或用不同参数数量/类型Cannot create a generic array of T泛型数组不支持使用ArrayListT或反射Array.newInstanceType argument T is not within bounds类型参数超出上界检查T extends XX约束6.2 踩坑实录调用泛型方法时类型参数推断失败有一次我在项目里写了一个批量转换方法public static T ListT convert(List? source, FunctionObject, T mapper) { return source.stream().map(mapper).collect(Collectors.toList()); }调用的时候写了这样一段ListString names convert(rawList, obj - obj.toString());编译器竟然报类型推断失败。原因是rawList是List?传入后它与mapper的Object类型推断互相纠缠导致T推不出来。我最后把方法改成public static T ListT convert(List? source, FunctionObject, T mapper)其实签名没问题问题出在调用方的rawList用了裸类型。改成List? rawList ... ListString names convert(rawList, (FunctionObject, String) String::valueOf);显式指定FunctionObject, String之后T就确定为String就过了。这种推断失败在Java 8前后差异很大新版编译器普遍更聪明但遇到聚在一起的多层泛型时显式加个类型实参或者类型转换往往比硬看编译器脸色快。6.3 踩坑实录通配符两端误用导致只读变只写有一次我在封装一个缓存查询工具想支持从缓存取出某种类型的列表写了如下代码public List? extends Object getCacheList(String key) { return (List? extends Object) redisTemplate.opsForValue().get(key); }这个返回声明看着没问题但调用方想把它直接赋给ListString就报错。因为List? extends Object虽然能读但编译器不肯把它当ListString用。后来我改成泛型方法public T ListT getCacheList(String key, ClassT clazz) { Object val redisTemplate.opsForValue().get(key); List? raw (List?) val; ListT result new ArrayList(); for (Object item : raw) { result.add(clazz.cast(item)); } return result; }通过clazz.cast(item)逐一强转编译器能确认返回的是ListT调用方直接拿ListString接收清晰多了。这个小例子也说明当通配符阻塞了返回类型的精确传递别硬扛换回泛型方法通常是最短路径。6.4 踩坑实录泛型类和泛型方法重名导致的自我覆盖还有一种比较刁钻的情况类上有类型参数方法里又声明了一个同名的类型参数编译器优先使用方法内的类型变量。看代码public class ExampleT { public T T shadow(T input) { System.out.println(T inside method); return input; } }方法内的T把类上的T遮蔽了方法实现里用的完全是另一个类型变量。这种写法不报错但可读性极差容易误导人。我的建议是类级类型参数和方法级类型参数遵循不同命名习惯比如类上用T方法上用E、R等能让代码意图清晰很多。7. 一个完整的泛型类泛型方法通配符的练习案例如果你已经把前面内容读到这里不如直接看一个综合案例把零散的知识点串起来。我设计一个极简的通用的缓存仓库它同时用到泛型类、泛型方法、类型擦除的绕行方案和通配符边界。public class GenericRepositoryT { private final MapString, T store new HashMap(); // 泛型方法带独立类型变量把外部DTO转成T后存入 public D void saveFromDto(String id, D dto, FunctionD, T converter) { T entity converter.apply(dto); store.put(id, entity); } // 普通方法使用类级类型参数T public T get(String id) { return store.get(id); } // 泛型边界只处理T的某个父类映射 public R super T void saveAsParent(String id, R value) { // 如果是JDK 21Preview允许super通配符用于类型参数中 // 稳定版本中不推荐一般改用 T或? super T做参数 } // 通配符读取合并多个仓库中的所有数据 public void mergeFrom(GenericRepository? extends T source) { // 由于是 ? extends T可以安全读取 // 但不能直接 put 回 source下面只能加到本仓库 MapString, ? extends T sourceData source.store; for (Map.EntryString, ? extends T entry : sourceData.entrySet()) { this.store.put(entry.getKey(), entry.getValue()); } } }不过R super T那个方法在Java标准版本中并不推荐只是我看了JDK 21的预览特性才想起来提一嘴。大多数情况下我们要在方法里同时写T和T的父类时直接使用? super T作为参数类型更现实。我改一下案例避免读者踩到不成熟语法的坑public class GenericRepositoryT { private final MapString, T store new HashMap(); public D void saveFromDto(String id, D dto, FunctionD, T converter) { T entity converter.apply(dto); store.put(id, entity); } public T get(String id) { return store.get(id); } // 下界通配符允许把T或T的子类放入容器 public void addAll(List? extends T items) { for (T item : items) { store.put(UUID.randomUUID().toString(), item); } } // 消费者通配符允许把本仓库内容输出到某个T的父类型集合 public void exportTo(List? super T target) { target.addAll(store.values()); } }这里addAll接收? extends T因为我们需要从中读取元素是生产者exportTo接收? super T因为我们需要往它写入本仓库的T是消费者。一个类里的两种通配符各司其职恰好呼应了PECS规则。代入一个实际场景GenericRepositoryNumber numberRepo new GenericRepository(); ListInteger ints List.of(1, 2, 3); numberRepo.addAll(ints); // ? extends Number ListObject bucket new ArrayList(); numberRepo.exportTo(bucket); // ? super Number这个例子中GenericRepositoryNumber既能合并ListInteger又能把Number导出到ListObject。如果不用通配符这两个调用直接编译不过。这也是通配符在实际封装里真正的价值在类型安全前提下把不变性破开一个口子让类型家族可以互相协作。8. 关于泛型我最后的几点实战心得写了这么多年Java每次用泛型都会有些新的体会。把我的个人经验放在这里大家可以少走弯路。第一泛型不是银弹不要为了炫技而滥用。我看到过有人把简单的工具方法强行套三层泛型调用方解类型都要解半天。泛型的核心价值是类型安全和抽象复用如果业务里所有类型都是明确的、不会变化的那就直接写具体类别硬套。泛型最适合应用在通用库、框架、公共工具类这些不知道未来会被什么类型调用的地方。第二读框架源码时先看懂类型参数声明的位置再关注方法内部逻辑。因为类型参数声明的位置决定了变量的作用域和作用。每看到public T ListT xxx(...)这种签名默念一遍之后的是声明返回类型里的是使用很多困惑就自动消失了。第三遇到编译错误先怀疑擦除和通配符方向再去怀疑业务逻辑。七个常见的错误我在第六章列了表其中一大半都和泛型不变性擦除通配符读写限制有关。把这些原理背熟编译错误处理速度会直线上升。第四List裸类型能不用就不用。即使你的代码在旧系统里暂时不得不使用裸集合也尽量在边界处转换成带类型的集合再往上层传。我在实际项目里清理过不少裸List引发的运行期ClassCastException每次都深刻体会到类型安全这东西编译期不堵住运行期就一定找上门。实话讲泛型这个主题看起来语法点很多但核心线索非常清晰泛型类解决的是类级别的类型抽象泛型方法解决的是方法级别的类型抽象类型擦除解释了这些抽象在JVM里如何落地通配符则解决了泛型不变性带来的灵活性不足。把这四条线理清楚任何泛型代码拿到手都不会再发怵。以后再看到public E ListE get()你应该能条件反射地读出来声明一个方法级类型变量E返回值是一个元素类型为E的List具体E由调用方推断决定。