一、根本问题:如何写一份通吃多种类型的代码?
先看没有泛型的世界,写一个列表类需要多少重复代码:
// 存 String 的列表
public class StringList {
private String[] data;
public String get(int i) { return data[i]; }
public void add(String s) { ... }
}
// 存 Integer 的列表
public class IntegerList {
private Integer[] data;
public Integer get(int i) { return data[i]; }
public void add(Integer s) { ... }
}
// 存 User 的列表
public class UserList {
private User[] data;
public User get(int i) { return data[i]; }
public void add(User s) { ... }
}
每一种元素类型,都要重写一份几乎一模一样的代码:数组扩容逻辑完全一样、get/set 结构完全一样,唯一区别只是"元素类型不同"。
1.1 Java 1.4 的做法:用 Object 通吃
没有泛型时,JDK 的 ArrayList 是这样实现的:
// Java 1.4 的 ArrayList 内部结构
public class ArrayList {
private Object[] elementData; // 内部是 Object 数组
public void add(Object o) { ... } // 接 Object
public Object get(int index) { ... } // 返回 Object
}
使用时:
ArrayList list = new ArrayList();
list.add("hello");
list.add(123); // 什么都能加,没人拦着
String s = (String) list.get(0); // 每次取都要强转
String s2 = (String) list.get(1); // 编译通过,运行时 ClassCastException
两大致命问题:
| 问题 | 说明 |
|---|---|
| 不安全 | 任何类型都能往里塞,放错了编译不报错,运行时才炸 |
| 不方便 | 每次取元素都要手动强转,代码噪音大 |
Object 的万能性 = 类型安全的丧失。
用 Object 当"通吃类型",看似解决了代码重复,但把检查工作甩给了运行时。
1.2 泛型的本质:参数化类型
怎么解决这个矛盾?——把类型本身变成参数。
就像方法有参数一样,类也可以有类型参数:
方法参数: 类型参数:
int add(int a, int b) class List<T>
a、b 是参数 T 是类型参数
调用时传值: 使用时传类型:
add(3, 5) List<String>
List<Integer>
一句话定义:
泛型 = 把类型作为参数,让同一份代码可以安全地处理多种类型。
有了泛型之后:
List<String> list = new ArrayList<>();
list.add("hello"); // 只能加 String
list.add(123); // 编译报错,不兼容的类型
String s = list.get(0); // 不用强转,编译器已经做了
两个问题同时解决。
二、词源
Generic 来自拉丁语 genus(种类、属),字面意思是"属于一个种类的、通用的"。
在医学里,generic drug 是"仿制药"——和品牌药成分一样、效果一样,只是没有品牌。
在编程里,generic 就是:一份通用的配方,可以套在任何类型上,效果一致。
List 是通用配方,
List<String>和List<Integer>是两种具体用法——配方一样,只是类型参数不同。
三、泛型的三种形态
Java 里的泛型有三种用法,对应三个层级。
3.1 泛型类
把类型参数放在类定义上,最常见的用法:
public class Box<T> {
private T content;
public void put(T content) {
this.content = content;
}
public T get() {
return content;
}
}
// 使用:给 T 传入具体类型
Box<String> stringBox = new Box<>();
stringBox.put("hello");
String s = stringBox.get();
Box<Integer> intBox = new Box<>();
intBox.put(123);
Integer i = intBox.get();
典型代表:Java 集合框架全是泛型类——List<E>、Map<K,V>、Set<E>、Optional<T>。
3.2 泛型方法
不是整个类通用,而是某个方法通用:
public class Utils {
// <T> 声明类型参数,放在返回值前面
public static <T> T getFirst(T[] array) {
if (array == null || array.length == 0) {
return null;
}
return array[0];
}
// 两个类型参数
public static <K, V> void printPair(K key, V value) {
System.out.println(key + ": " + value);
}
}
// 使用
String first = Utils.getFirst(new String[]{"a", "b", "c"});
Utils.<String, Integer>printPair("年龄", 28);
关键规则:泛型方法的 <T> 必须写在返回值前面,告诉编译器"这个 T 是方法自己的类型参数"。
3.3 泛型接口
接口本身带类型参数,实现类可以指定具体类型:
public interface Comparable<T> {
int compareTo(T o);
}
public class User implements Comparable<User> {
private int age;
@Override
public int compareTo(User other) {
return this.age - other.age;
}
}
典型代表:Comparable<T>、Comparator<T>、Function<T,R>、Predicate<T>——函数式接口全是泛型接口。
四、通配符:? 的奥秘
泛型有一个关键特性:List<String> 不是 List<Object> 的子类,即使 String 是 Object 的子类。
4.1 一个反例
// 假设这能编译通过
void process(List<Object> list) {
list.add(123); // 往 Object 列表里加 Integer,合法
}
List<String> strs = new ArrayList<>();
process(strs); // 如果这里能通过
String s = strs.get(0); // 取出来的是 123,转 String 炸了
如果允许 List<String> 作为 List<Object> 的子类传入,那就可以往 String 列表里塞 Integer,类型安全就崩了。
结论:即使 String 是 Object 的子类,List<String> 也不是 List<Object> 的子类。
想写一个"接受任何类型的 List"的方法,怎么写?——通配符上场。
4.2 无界通配符 ?
void printList(List<?> list) {
for (Object o : list) {
System.out.println(o);
}
}
printList(new ArrayList<String>());
printList(new ArrayList<Integer>());
List<?> 读作"未知类型的 List"。它可以接任何参数化的 List,但代价是:除了 null,不能往里 add 任何东西——编译器不知道 ? 到底是什么类型。
void test(List<?> list) {
list.add(null); // 唯一合法的 add
list.add("hello"); // 编译报错
}
4.3 上界通配符 ? extends T
限制类型参数必须是 T 或 T 的子类(即"至少是 T")。
void sumNumbers(List<? extends Number> list) {
double sum = 0;
for (Number n : list) {
sum += n.doubleValue();
}
}
sumNumbers(new ArrayList<Integer>());
sumNumbers(new ArrayList<Double>());
sumNumbers(new ArrayList<String>()); // 编译报错
特点:可以安全地读(取出来的元素至少是 Number),但不能写(不知道具体子类是什么,无法安全 add)。
4.4 下界通配符 ? super T
限制类型参数必须是 T 或 T 的父类(即"最多是 T")。
void addIntegers(List<? super Integer> list) {
list.add(1);
list.add(2);
}
addIntegers(new ArrayList<Integer>());
addIntegers(new ArrayList<Number>());
addIntegers(new ArrayList<Object>());
addIntegers(new ArrayList<Long>()); // 编译报错
特点:可以安全地写(Integer 肯定能放进 Integer 或它的父类的列表里),但读出来只能是 Object。
4.5 PECS 原则
Producer Extends, Consumer Super。
- 如果容器是生产者(主要从里面读数据)→ 用
extends- 如果容器是消费者(主要往里面写数据)→ 用
super- 如果既要读又要写 → 不要用通配符,用确定的类型参数
这是 Java 集合框架的通用设计原则,比如 Collections.copy:
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
// src 是生产者(读)→ extends
// dest 是消费者(写)→ super
}
五、类型擦除:Java 泛型最大的争议点
Java 的泛型有一个臭名昭著的特征:编译后泛型信息会被擦掉。
5.1 擦除发生了什么
// 源码
public class Box<T> {
private T content;
public T get() { return content; }
public void set(T content) { this.content = content; }
}
编译后变成:
// JVM 看到的字节码
public class Box {
private Object content; // T -> Object(上界)
public Object get() { return content; }
public void set(Object content) {
this.content = content;
}
}
使用时,编译器会偷偷加一层强转:
// 源码
Box<String> box = new Box<>();
box.set("hello");
String s = box.get();
// 编译后
Box box = new Box();
box.set("hello");
String s = (String) box.get();
Java 泛型是一个编译期幻象——JVM 运行时根本不知道什么是 List
和 List ,它们都是同一个 List 类。
new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass();
// true
5.2 为什么要擦除:历史包袱
Java 5 引入泛型时,定下了一个死规矩:Java 5 的字节码必须 100% 兼容 Java 1.4 的字节码。
也就是说,Java 1.4 写的 ArrayList(存 Object)和 Java 5 写的 ArrayList<String>,在 JVM 里必须是同一个类,老代码完全不用改。
为了做到这一点,编译器只能在编译期做类型检查,然后把泛型信息全部擦掉,换成 Object。
这是典型的"向后兼容 vs 优雅设计"的取舍。C# 就没有这个包袱,它的泛型是运行时保留的——
List<string>和List<int>在 CLR 里就是两个不同的类型,没有擦除问题。
5.3 擦除带来的七条限制
| 限制 | 说明 | 例子 |
|---|---|---|
不能 new T() | 擦除后不知道 T 是什么 | 想写泛型工厂方法只能传 Class<T> 参数 |
不能 instanceof T | 运行时没有 T 的信息 | if (obj instanceof T) 编译报错 |
| 泛型类型不能是基本类型 | 擦除后统一是 Object,基本类型不是 Object | 没有 List<int>,只能 List<Integer> |
| Class 对象丢失泛型参数 | List<String> 和 List<Integer> 共用同一个 Class | 反射拿不到类型参数 |
| 泛型数组不安全 | new T[10] 编译报错 | 擦除后变成 Object[],可以塞任何东西 |
| 方法签名冲突 | 两个方法擦除后签名一样 | void test(List<String> a) 和 void test(List<Integer> a) 不能共存 |
| 静态成员不能用类型参数 | 静态成员和泛型实例无关 | static T field 编译报错 |
5.4 绕开擦除的反射技巧
类型虽然被擦除了,但在某些场景下可以通过反射绕出来:
// 技巧:创建匿名子类,父类的泛型信息会保留在字节码里
Type type = new TypeToken<List<String>>() {}.getType();
// type 里包含了 List<String> 的完整泛型信息
// Guava、Gson 等库大量用这个技巧
List<String> list = gson.fromJson(json, new TypeToken<List<String>>(){}.getType());
本质上是利用了"父类的泛型签名会被写入 class 文件"这个特性——绕过了擦除,不是真正的保留。
5.5 桥接方法
为了保持多态兼容性,编译器会生成额外的方法,叫做桥接方法:
// 源码
class IntList extends ArrayList<Integer> {
@Override
public Integer get(int i) { ... }
}
// 编译后,ArrayList 擦除后的方法是 Object get(int i)
// 为了保持多态,编译器自动生成桥接方法:
public Object get(int i) {
return (Integer) get(i);
}
桥接方法通常不需要开发者关心,除非在反射里调方法时遇到"怎么多了一个方法"的疑惑。
六、Java 泛型 vs C++ 模板:名字像,原理差远了
| 维度 | Java 泛型 | C++ 模板 |
|---|---|---|
| 机制 | 类型擦除,所有模板实例共用一个类 | 每个类型参数实例化一个独立的类 |
| 代码生成 | 编译后只有一份字节码 | 每个实例生成一套独立代码 |
| 代码膨胀 | 几乎没有 | 模板用多了代码体积会膨胀 |
| 特化 | 不支持(类型擦除) | 支持(可以针对特定类型做特殊优化) |
| 元编程 | 基本不行(擦除了) | 模板元编程非常强 |
C++ 模板是"代码生成器",Java 泛型是"编译期类型检查器"。
七、泛型的代价
| 代价 | 说明 |
|---|---|
| 类型擦除的七条限制 | 工程上经常遇到,尤其是 new T()、泛型数组、instanceof |
| 自动装箱的开销 | 不能用基本类型,只能用包装类 |
| 通配符的复杂性 | extends/super/? 容易混淆,PECS 原则容易忘 |
| 编译时间增加 | 编译器要做额外的类型检查和桥接方法生成 |
| 桥接方法 | 字节码里有额外方法,反射时可能疑惑 |
八、常见误解澄清
8.1 误解 1:"泛型就是语法糖,没什么用"
错误。泛型是"编译器糖",但价值极大:
- 编译期拦住 90% 的类型错误
- 代码里不用写大量强转,可读性提升
- 集合框架的 API 设计完全建立在泛型之上
它是糖,但不是"没用的糖"——它是"有安全价值的糖"。
8.2 误解 2:"通配符 ? 就是 Object"
错误。
List<Object> | List<?> | |
|---|---|---|
| 可以传什么 | 只能传 List | 可以传任何 List<String/Integer/User> |
| 能 add 吗 | 能 add 任何 Object | 除了 null 啥都不能 add |
| 读出来是啥 | Object | Object |
List<Object>是"确定是 Object 的列表",List<?>是"不知道是什么类型的列表"——语义完全不同。
8.3 误解 3:"擦除了就没用了,还不如不用"
不对。擦除只发生在运行时,但编译期的类型检查是实打实的:
List<String> list = new ArrayList<>();
list.add(123); // 编译报错,类型不兼容
编译期这一步检查,已经拦住了 99% 原本要在运行时抛 ClassCastException 的代码。
泛型的价值主要在编译期,运行时擦除了不代表它没工作过。
8.4 误解 4:"通配符 ? extends 可以读也可以写"
错误。? extends T 只能安全地读(取出来的元素至少是 T),但不能写(不知道具体子类是什么,无法安全 add)。
写要用 ? super T,读用 ? extends T——PECS 原则。
九、学习路径建议
| 阶段 | 学习内容 | 掌握标准 |
|---|---|---|
| 第一阶段:基础用法 | 泛型类、泛型方法、泛型接口的声明与使用 | 能看懂 List<Map<String, User>> 这种签名,写简单泛型类 |
| 第二阶段:通配符 | ?、? extends T、? super T 三个通配符 + PECS 原则 | 能按 PECS 选通配符,理解为什么 List<String> 不是 List<Object> 的子类 |
| 第三阶段:类型擦除 | 擦除过程、七条限制、桥接方法、与 C#/C++ 的对比 | 能解释为什么 new T() 不行、为什么两个 List 共享同一个 Class |
| 第四阶段:工程应用 | TypeToken 拿泛型参数、桥接方法的反射陷阱、泛型与序列化/反射的配合 | 能在 ORM、RPC 框架代码里看出泛型的使用,能诊断类型擦除相关 bug |
十、总结
泛型的根本动机,是解决"一份代码如何安全地通吃多种类型"的问题——把类型作为参数传进去,编译期检查类型正确,不用到处强转。
没有泛型,要么每个类型写一份列表类(代码爆炸),要么用 Object 当万能容器(类型安全全丢)。泛型是两者之间的平衡点。
Java 的泛型建立在类型擦除之上:编译期检查,运行时擦成 Object。这是向后兼容的历史选择,带来了七条运行时限制,和 C# 保留泛型信息的方案形成鲜明对比。
通配符体系(? / ? extends / ? super)+ PECS 原则,解决了"泛型类型不协变"带来的表达力问题——生产者读用 extends,消费者写用 super。
一句话记忆:泛型是参数化的类型——编译期给你安全,运行时擦成 Object,Java 1.4 的历史包袱挥之不去。