
1. 泛型到底是什么先搞清楚它是编译器工具还是运行时工具很多Java开发者在日常写代码时都会遇到这样一种情况List、Map这些容器类用着用着突然有一天发现List里面塞了一大堆不同类型的对象取值的时候还要拼了命地做instanceof和强转稍微没注意就给你抛个ClassCastException。这种糟糕的体验在Java 5之前是常态直到泛型出现才把这个问题从根上解决掉。泛型即“参数化类型”。它允许我们在定义类、接口、方法的时候把“类型”也作为一种参数传进去。比如ListString里的String就是一个类型参数它表示这个List只能装String对象。从Java 5开始泛型成了Java语言的一部分也是所有Java面试中绕不开的基础考点。这个特性的本质并不是给JVM增加什么复杂的运行时机制而是在编译阶段加了一道类型安全检查。换句话说泛型的主要工作发生在javac编译期间一旦编译通过泛型信息在运行时会被抹掉——这就是后面要重点说的“类型擦除”。如果你是刚开始学Java或者准备面试这篇文章会把泛型的语法、原理、通配符、易错点和面试题一起梳理清楚。如果这些内容你都懂了泛型这块基本就不用再怕了。2. 泛型的基础语法手写一遍比看十遍管用泛型的语法不算复杂但真正写起来容易犯迷糊尤其是在泛型类、泛型接口、泛型方法之间切换时。这一节我把三种最常用的写法逐个拆开每个都给一个能直接跑的例子。2.1 泛型类最常见的使用姿势定义一个泛型类就是在类名后面加一对尖括号里面放一个或多个类型参数。类内部所有用到该类型的地方都用这个参数代替。public class BoxT { private T value; public Box(T value) { this.value value; } public T getValue() { return value; } public void setValue(T value) { this.value value; } }使用的时候有两种方式。一种是直接指定类型BoxString stringBox new Box(hello); String s stringBox.getValue();注意new Box()右边的尖括号里可以留空这是Java 7引入的菱形语法编译器能根据左边的声明自动推断类型。不过有个细节如果你用的是Java 6或更早版本右边必须写成new BoxString(hello)否则编译报错。另一种是使用原生类型也就是不带任何类型参数的Box。比如Box box new Box(hello)。这种写法在编译时会给出一个“unchecked”警告虽然不影响运行但实践中强烈不建议这么写因为一旦写了原生类型编译器就失去了类型检查的能力泛型等于白用。2.2 泛型接口策略模式的天然搭档泛型接口和泛型类的声明方式几乎一样唯一的区别是class换成interface。一个很典型的例子就是Comparable接口public interface ComparableT { int compareTo(T o); }实现泛型接口时有两个选择。如果实现类是确定的类型比如始终比较String那就直接指定类型参数public class Person implements ComparablePerson { private int age; Override public int compareTo(Person o) { return this.age - o.age; } }另一种情况是实现类也想保留泛型能力那就继续保留类型参数public class GenericComparableT implements ComparableT { Override public int compareTo(T o) { return 0; } }这里有个很容易踩的坑如果实现Comparable时忘记写泛型参数比如public class Person implements Comparable那么compareTo(Object o)方法进来的参数是Object你就必须自己做强转代码会变得丑陋且容易出错。我在项目里见过不少人因为偷懒省掉泛型参数导致后面代码越写越别扭最后不得不回头补上。2.3 泛型方法比你想的更灵活泛型方法可以在普通类里定义也就是类本身不一定是泛型的但方法可以自己声明类型参数。类型参数放在方法的修饰符之后、返回类型之前public class GenericMethodExample { public static T T getMiddle(T... a) { return a[a.length / 2]; } }调用时通常不需要显式指定类型参数编译器会从传入的参数推断String mid GenericMethodExample.getMiddle(A, B, C); // 推断出 T 为 String泛型方法最容易出问题的地方是类型推断失败。比如你写getMiddle(1, 2.0, three)三个参数类型分别是Integer、Double、String编译器会尝试找一个共同的父类型最终可能是Number Comparable这种交集类型导致你接的时候类型对不上。解决办法是显式指定类型参数GenericMethodExample.ObjectgetMiddle(1, 2.0, three)。泛型方法还有一个常见用途就是写工具类。比如写一个数组转List的方法public static T ListT asList(T... elements) { ListT list new ArrayList(); for (T element : elements) { list.add(element); } return list; }这类方法在很多框架源码里频繁出现如果你的代码里也有类似的重复逻辑抽成泛型方法能节省大量模板代码。表三种泛型写法的对比写法声明位置典型场景注意事项泛型类类名后容器类、实体包装类静态成员不能引用类类型参数泛型接口接口名后策略模式、比较器实现时可选指定或保留参数泛型方法方法声明前工具类、类型转换助手类型推断失败时需显式指定3. 类型擦除理解JVM里泛型的真实形态面试中问到泛型十有八九会绕不开类型擦除。这个概念理解不透后面很多奇奇怪怪的编译错误和运行时异常都会让你一头雾水。3.1 类型擦除到底做了什么泛型是在编译期进行类型检查的编译通过之后字节码里的泛型信息会被擦掉。具体来说编译器会做三件事第一把类型参数替换为它的限定类型。如果泛型参数没有显式指定边界比如T那么T会被替换成Object如果指定了边界比如T extends ComparableT那么T会被替换成Comparable。第二在必要的地方插入强制类型转换以保证类型安全。因为运行时容器里存的可能是Object取出来时编译器会自动帮我们插入强转指令。第三生成桥方法。这是为了保证多态能正常工作。举一个例子public class ParentT { public T getValue() { return null; } } public class Child extends ParentString { Override public String getValue() { return child; } }擦除之后Parent的getValue()返回类型变成Object而Child重写的方法返回类型是String。从Java语言规范的角度看这两个方法并不是同一个签名但为了保持多态编译器会在Child里生成一个桥方法返回Object内部调用真正的String版本方法。你可以在编译后的class文件里看到这两个方法同时存在。这意味着什么意味着getClass()拿到的都是普通的List.class而不是ListString.class。所以在运行时你写if (list instanceof ListString)就会直接编译报错因为JVM根本无法区分ListString和ListInteger。这一条在面试里被问到无数次属于必背基础。3.2 泛型对重载的影响类型擦除带来的一个典型连锁反应就是重载受限。看这段代码public class OverloadExample { public void print(ListString list) {} // 编译错误擦除后与 print(ListString) 冲突 public void print(ListInteger list) {} }两个方法的参数类型ListString和ListInteger擦除之后都变成List签名相同编译器直接拒绝。这在语言设计上有争议因为从开发者视角来看这两个方法的逻辑完全可以不一样。但JVM层面做不到区分所以Java语言干脆禁止这种重载。解决办法之一是改成不同名称的方法比如printStringList和printIntList。虽然代码不那么优雅但这是最直接的绕法。还有一个容易混淆的知识点List?和ListObject是不同的。List?表示“某种未知类型的列表”而ListObject表示只能装Object及其子类的列表。前者是通配符类型后者是具体类型。擦除之后两者都变成List但在编译期间编译器对它们实施了完全不同的检查规则这个放到下一节讲。类型擦除对代码的影响我整理了一个速查表操作是否允许原因instanceof ListString不允许运行时无法区分具体泛型类型new T()不允许类型参数已被擦除无法确定构造器new T[10]不允许同上且数组运行时会检查组件类型static T field不允许同一泛型类的不同实例共享静态成员重载method(ListString)和method(ListInteger)不允许擦除后签名相同4. 通配符泛型体系里最容易绕晕的部分如果说类型参数是泛型的主干那么通配符就是泛型的枝叶。通配符用?表示它代表一种“未知的类型”。在类型安全和平凡便利之间通配符提供了一个非常巧妙的折中方案。4.1 上限通配符和下限通配符上限通配符写成? extends T表示该类型的上限是T。比如List? extends Number表示一个元素类型是Number某个子类的列表可能是ListInteger也可能是ListDouble但绝不会是ListObject。这个写法在读取场景下特别有用。因为不管底层到底是Integer还是Double它们都一定满足“是Number的子类”这一条件所以读出来的元素可以安全地赋值给Number变量。public static double sum(List? extends Number numbers) { double total 0.0; for (Number n : numbers) { total n.doubleValue(); } return total; }调用时既可以传ListInteger也可以传ListDouble非常灵活。但需要注意上限通配符的列表不能往里添加元素。因为编译器只知道里面的元素是某个Number子类但不知道具体是哪个子类往里面放Integer可能造成类型不匹配放Double也可能不匹配所以只能读不能写。下限通配符写成? super T表示类型的下界是T。比如List? super Integer表示元素类型是Integer的某个父类的列表可能是ListInteger也可能是ListNumber还可能是ListObject。这个写法在写入场景下很常用因为不管实际类型是什么往里放一个Integer总是安全的——Integer是所有可能类型的子类。public static void addNumbers(List? super Integer list) { list.add(1); list.add(2); }反过来读取的时候就比较受限。因为列表的实际类型可能是Number也可能是Object读出来的元素只能赋值给Object类型。4.2 PECS原则生产者用extends消费者用super这个概念来自Joshua Bloch的《Effective Java》是泛型通配符最著名的指导原则。PECS的全称是“Producer Extends Consumer Super”。意思是如果泛型容器是“生产者”即它向我们提供数据就用? extends T如果泛型容器是“消费者”即它接收我们写入的数据就用? super T。用一个实际例子感受一下。比如Collections.copy方法的签名public static T void copy(List? super T dest, List? extends T src)dest是要写入的目标列表是“消费者”所以用? super Tsrc是数据来源是“生产者”所以用? extends T。这一行签名把PECS原则体现得淋漓尽致。实际项目中最容易犯的错误是把? extends T用在应该写入的场景。比如你想写一个方法把所有Integer加入一个列表但参数写了List? extends Integer编译直接报错因为编译器不允许向一个未知具体类型的列表里添加任何元素。这时候应该改成List? super Integer。PECS原则虽然看起来简单但真的是面试常客。一般面试官会直接问“你知道PECS原则吗解释一下。”如果你能沿着通配符的上限、下限一直讲到生产者和消费者最后再拿Collections.copy举例这个回答基本能拿满分。5. 泛型面试高频题为什么基本类型不能直接作为泛型参数这个问题在Java面试里被问到的概率非常高。很多人背过答案说“因为泛型擦除后要变为Object类型”但并没有真正理解其中的因果关系。实际原因要从泛型擦除机制说起。编译器在编译期会把类型参数擦除为它们的上界如果没有指定上界就擦除为Object。int、double这些基本类型并不是Object的子类无法被擦除为Object。所以才有了包装类型的存在int对应Integerboolean对应Boolean这些包装类型都是引用类型可以被Object接收。如果非要用基本类型的集合怎么办只能写ListInteger然后依赖Java的自动装箱拆箱机制来完成向基本类型的转换。比如ListInteger numbers new ArrayList(); numbers.add(42); int n numbers.get(0);表面上看起来直接操作的是int实际上编译器背后帮你完成了Integer.valueOf(42)和intValue()的转换。这里有一个特别容易忽视的小坑那就是当泛型遇到自动装箱时可能会因为Integer的缓存范围产生意外的引用相等判断。比如Integer a 100; Integer b 100;a b返回true因为-128到127之间的Integer默认走缓存但如果你换成了Integer c 200; Integer d 200;c d返回false因为超出了缓存范围产生了两个不同的对象。在泛型代码里如果习惯用比较包装类型很容易踩到这个坑应该改用equals。类似这种泛型与自动装箱结合的问题在真实项目的性能调优中也会出现。比如大量写入ListInteger的循环代码每一次add操作都伴随装箱每一次get操作都伴随拆箱。在高频场景下这些隐式转换会带来可观的性能损耗虽然不算大问题但在追求极致性能的框架代码里不少团队会用IntList这样的专门集合类来规避。6. 泛型在真实项目中让人头秃的几种用法泛型本身不难难的是它在复杂场景下的限制和隐式行为。这一节把我实际开发里踩过的坑集中列一下你照着避免就行了。6.1 静态成员与类型参数不能共存在一个泛型类中静态成员不属于任何一个具体的泛型实例。因为BoxString和BoxInteger共享同一个静态变量静态代码中根本没有办法确定T到底是什么类型。所以下面的代码直接编译报错public class BoxT { private static T sharedValue; // 编译错误 }如果你确实想维护一个静态的工具方法应该在方法上声明独立的类型参数而不是使用类的类型参数public class BoxT { private T value; public static U BoxU empty() { return new Box(null); } }这个区别在写工厂方法或工具方法时特别重要。我第一次写泛型工厂方法时就因为没注意到这一点直接把类的类型参数用在了静态方法里编译器报错后还一头雾水。6.2 泛型异常和catch块泛型类可以继承Exception吗理论上你可以声明class MyExceptionT extends Exception但这会在编译阶段被IDE立刻标红。原因还是落在类型擦除上。如果异常类可以泛型化那么一个catch (MyExceptionString e)的写法在擦除后会变成catch (MyException e)JVM在执行异常匹配时根本不知道应该匹配哪种具体类型无法判定这段逻辑是否可靠。所以Java规范明确禁止泛型类直接或间接继承Throwable。// 编译错误泛型类不能继承 Throwable public class MyExceptionT extends Exception {}如果确实需要携带泛型数据的异常变通办法是让异常类本身保留一个类型为T的字段但不让类本身泛型化。比如定义一个BusinessException内部持有Object payload抛出时再强转。虽然不是类型安全的做法但比整个异常体系泛型化要实用得多。6.3 泛型数组不能创建但可以声明Java不支持直接创建泛型数组比如new ArrayListString[10]是编译错误。但反直觉的是你完全可以声明一个泛型数组类型的变量甚至可以通过强转绕过去创建。这种绕行的本质是借助了数组的协变特性和类型擦除的漏洞但用到这一步就意味着你在走钢丝了。实际开发中遇到泛型数组需求最通用的解决方案是使用ArrayList集合类来替代数组。比如你要一个PairString, Integer[]直接用ListPairString, Integer就能满足绝大多数场景还避开了一堆麻烦。真正必须用到泛型数组的地方多半是在框架底层或者反射工具里。我记得有一次写一个基于反射的通用转换工具需要动态创建一个泛型数组来接收方法返回的结果。当时的做法是先创建Object[]再通过Arrays.copyOf和反射去处理。后来我想通了与其用这种复杂的手段不如调整接口设计用List替代数组代码瞬间干净了不少。6.4 泛型与继承的关系泛型不具备继承关系。这句话听起来有点绕但它非常重要ListString不是ListObject的子类型。下面的代码编译不通过ListString strings new ArrayList(); ListObject objects strings; // 编译错误按理说String是Object的子类为什么ListString不是ListObject的子类型因为如果允许这种赋值那么就能通过objects往同一个列表里放入一个非String的对象比如objects.add(123)这就会破坏strings列表的类型安全。所以Java在泛型层面切断了这种继承关系。理解了这条你就能明白为什么方法参数是ListObject时不能直接传入一个ListString。这也是很多开发者在调用别人的API时经常遇到编译错误的原因之一。正确姿势是使用通配符方法参数声明为List?或List? extends Object就能接收所有类型的泛型列表。7. 一个实战示例手写泛型版的缓存工具前面讲了那么多语法、原理和坑这一节我们落一下地写一个实际可用的泛型缓存工具。这个例子能帮你把泛型类、泛型方法、通配符、边界等知识串起来。需求很简单做一个基于时间和容量双重限制的缓存支持存任意类型的值取出来的值和存进去的类型完全一致不需要强转。第一版代码我写成这样public class GenericCacheK, V { private final MapK, CacheEntryV cache new HashMap(); private final long expireAfterMillis; private final int maxSize; public static class CacheEntryV { private final V value; private final long createTime; public CacheEntry(V value, long createTime) { this.value value; this.createTime createTime; } public V getValue() { return value; } public long getCreateTime() { return createTime; } } public GenericCache(long expireAfterMillis, int maxSize) { this.expireAfterMillis expireAfterMillis; this.maxSize maxSize; } public synchronized void put(K key, V value) { if (cache.size() maxSize) { evictExpiredEntries(); } if (cache.size() maxSize) { K oldestKey cache.entrySet().stream() .min(Comparator.comparingLong(e - e.getValue().getCreateTime())) .map(Map.Entry::getKey) .orElse(null); if (oldestKey ! null) { cache.remove(oldestKey); } } cache.put(key, new CacheEntry(value, System.currentTimeMillis())); } public synchronized V get(K key) { CacheEntryV entry cache.get(key); if (entry null) { return null; } if (System.currentTimeMillis() - entry.getCreateTime() expireAfterMillis) { cache.remove(key); return null; } return entry.getValue(); } private void evictExpiredEntries() { long now System.currentTimeMillis(); cache.entrySet().removeIf(e - now - e.getValue().getCreateTime() expireAfterMillis); } }这个缓存工具里用到了泛型类GenericCacheK, V、泛型内部类CacheEntryV以及泛型方法相关的Stream操作。使用的时候非常自然GenericCacheString, Integer cache new GenericCache(1000, 10); cache.put(key, 42); Integer value cache.get(key); // 这里不需要强转注意我在这里做了synchronized同步因为HashMap不是线程安全的。真实项目中如果用高性能缓存可以考虑ConcurrentHashMap加上原子操作来减少锁竞争但这类优化涉及另一个大话题了这里不展开。这个例子虽然在生产环境还不够严谨但足以演示泛型在实际工具类设计中的价值类型安全、调用简洁、可复用性高。你还可以继续扩展比如增加loadIfAbsent(K key, SupplierV loader)方法这就是一个典型的泛型方法结合函数式接口的使用场景public synchronized V getOrLoad(K key, Supplier? extends V loader) { V value get(key); if (value null) { value loader.get(); put(key, value); } return value; }这里Supplier? extends V用到了下限通配符的变体实际上是上限通配符允许任何能生成V或其子类的Supplier传入灵活性比写死SupplierV更高。8. 几个容易让人懵圈的小细节泛型越是基础越容易在某些角落卡住。这里把我在各种项目里碰到的、以及网上被问爆的几个细节整理一下。关于ListObject和List?的区别。ListObject可以添加任意对象因为你明确指定了元素类型是Object那任何非基本类型都能放进去。而List?只能读不能写因为编译器不知道具体类型是什么。这一点在很多接口设计里都有体现如果一个方法只读一个列表不修改它那么参数声明为List?比ListObject更宽容能用ListString、ListInteger等任何类型调用反过来如果声明为ListObject调用方就只能传ListObject非常不灵活。关于类型推断。Java 8加强了对泛型类型推断的支持但依然有边界。特别是链式调用的时候经常能碰上推断失败的情况。比如Collections.emptyList()赋值给ListString会推断成功但如果在方法参数位置直接写foo(Collections.emptyList())而foo的重载版本又比较多就可能推断失败。遇到这种情况不用纠结直接显式指定类型参数即可foo(Collections.StringemptyList())。关于通配符和类型参数的互相转换。List?可以理解成List? extends Object的简化写法它在读取时返回Object写入时完全禁止。如果你需要把一个ListString传给一个接收List?的方法这是允许的但在方法内部你不能添加任何元素只能读取。这种“只读不写”的设计在只读访问的场景下非常安全能让代码的意图更清晰。还有一个很有迷惑性的点class FooT extends ComparableT这种自递归的类型边界。第一次看到T extends ComparableT时不少人都觉得这是个无限循环的定义。实际上它的意思是T必须实现ComparableT接口也就是T本身可以和自己比较。这个约束在实现通用排序、查找算法时特别常用。例如写一个求集合元素最大值的泛型方法public static T extends ComparableT T max(ListT list) { T max list.get(0); for (T item : list) { if (item.compareTo(max) 0) { max item; } } return max; }这里的T extends ComparableT能够保证item.compareTo(max)是类型安全的调用因为编译器知道你传入的列表元素类型一定实现了比较自身的方法。如果去掉这个边界代码根本无法通过编译因为Object没有compareTo方法。9. 我在实际开发中对泛型的三点心得第一泛型是给调用者提供类型安全的接口契约而不是给自己增加代码负担。设计一个泛型方法时要想着让调用方的代码尽量简洁、无需强转、同时类型错误在编译期就被发现。如果一个泛型设计让调用方每次都要显式声明类型参数那很大概率是设计有问题该考虑通配符或者调整泛型的粒度。第二泛型的可读性不能忽视。泛型类型参数的名字直接用单个大写字母比如T、E、K、V是Java社区的通用约定但在复杂的泛型声明中有时用有意义的名字更好。比如interface RepositoryENTITY, ID就比RepositoryT, E清楚得多。这种命名上的小心思在团队协作时能明显降低理解成本。第三不要为了用泛型而用泛型。如果一个方法只会处理String类型的列表直接写ListString就足够了没必要画蛇添足搞成T。泛型的价值在于抽象和复用硬套泛型会让代码变难懂还容易引入不必要的限制。这个取舍需要在实际项目中慢慢体会看得多了自然就知道什么时候该上泛型。