泛型与类型擦除
1. 这是什么
泛型是 Java 在编译期提供的类型约束机制,用来提升代码复用性和类型安全。
类型擦除则表示:大多数泛型信息主要服务于编译器,编译完成后不会以“完整泛型参数”的形式直接存在于运行时对象里。
一句话理解:
- 泛型解决的是“写代码时怎么更安全、更通用”。
- 类型擦除解决的是“JVM 运行时如何兼容老代码和已有字节码体系”。
2. 为什么重要
泛型几乎贯穿整个 Java 生态:
- 写集合时会频繁使用
List<String>、Map<String, Object>。 - 写工具类、公共组件、框架扩展点时,经常要设计泛型 API。
- Spring、MyBatis、Jackson、JPA 等框架大量依赖泛型声明和反射信息。
- 如果不理解类型擦除,就很难解释“为什么运行时拿不到
T”“为什么这里必须传Class<T>”“为什么不能直接new T()”。
换句话说,泛型不是一个只在语法题里出现的知识点,而是 Java 工程开发中的基础能力。
3. 先建立直觉:泛型到底解决了什么问题
在没有泛型约束时,集合能装任何对象,问题往往拖到运行时才暴露:
import java.util.ArrayList;
import java.util.List;
public class RawTypeDemo {
public static void main(String[] args) {
List list = new ArrayList();
list.add("hello");
list.add(123);
String first = (String) list.get(0);
String second = (String) list.get(1); // 运行时抛出 ClassCastException
System.out.println(first);
System.out.println(second);
}
}用了泛型后,错误会提前到编译期:
import java.util.ArrayList;
import java.util.List;
public class GenericDemo {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("hello");
// list.add(123); // 编译期直接报错
String value = list.get(0);
System.out.println(value);
}
}这里的关键结论是:
- 原始类型
List让编译器失去类型信息。 - 泛型
List<String>让编译器在写代码时就能帮你拦住错误。 - 泛型把一部分“晚出错”的问题,提前成了“早发现”的问题。
4. 核心内容
4.1 泛型类
public class Box<T> {
private T value;
public void set(T value) {
this.value = value;
}
public T get() {
return value;
}
}使用时:
Box<String> box = new Box<>();
box.set("Java");
String value = box.get();说明:
T是类型参数,占位符而不是具体类型。- 创建对象时把
T替换成String,编译器就会按String检查。 - 常见命名有
T、E、K、V、R,本质上都只是名称约定。
4.2 泛型方法
import java.util.List;
public class ListUtils {
public static <T> T first(List<T> list) {
return list.get(0);
}
}注意:
<T>必须写在返回值类型前面,表示这是一个泛型方法。- 泛型方法不要求所在类本身也是泛型类。
4.3 泛型接口
public interface Converter<S, T> {
T convert(S source);
}实现类可以在实现时指定具体类型:
public class StringToIntegerConverter implements Converter<String, Integer> {
@Override
public Integer convert(String source) {
return Integer.valueOf(source);
}
}4.4 上界通配符 ? extends T
? extends T 表示“某个未知类型,但它一定是 T 或 T 的子类型”。
import java.util.List;
public class ExtendsDemo {
public static void printNumbers(List<? extends Number> numbers) {
Number n = numbers.get(0); // 可以安全读取为 Number
System.out.println(n);
// numbers.add(1); // 编译错误
// numbers.add(1.5); // 编译错误
numbers.add(null); // 只有 null 能放进去
}
}理解重点:
- 你知道里面放的是
Number家族。 - 但你不知道它到底是
List<Integer>、List<Double>还是别的子类型。 - 因为“不确定具体子类型”,所以编译器不允许你写入具体值。
所以,extends 更适合“读多写少”的场景。
4.5 下界通配符 ? super T
? super T 表示“某个未知类型,但它一定是 T 或 T 的父类型”。
import java.util.List;
public class SuperDemo {
public static void addIntegers(List<? super Integer> list) {
list.add(1);
list.add(2);
Object value = list.get(0); // 读取时只能当成 Object
System.out.println(value);
}
}理解重点:
- 你可以确定往里放
Integer是安全的。 - 但读出来时不知道实际容器声明成了
List<Integer>、List<Number>还是List<Object>。 - 所以读取结果只能按
Object处理。
所以,super 更适合“写多读少”的场景。
4.6 PECS 原则
PECS 是最常见的泛型边界设计口诀:
- Producer Extends:如果参数负责“生产数据给你读”,用
extends - Consumer Super:如果参数负责“消费你写进去的数据”,用
super
一个经典例子:
import java.util.List;
public class CopyUtils {
public static <T> void copy(List<? extends T> source, List<? super T> target) {
for (T item : source) {
target.add(item);
}
}
}这段代码的含义是:
source只负责提供元素,所以用extendstarget只负责接收元素,所以用super
4.7 三种写法怎么选
| 写法 | 含义 | 读取时能当成什么 | 写入时能放什么 | 典型场景 |
|---|---|---|---|---|
List<T> | 明确知道元素类型就是 T | T | T | 类内部、入参与返回值都明确 |
List<? extends T> | 元素类型是 T 的某个子类型 | T | 通常不能写入 | 只读、遍历、统计 |
List<? super T> | 元素类型是 T 的某个父类型 | Object | T 及其子类 | 收集、写入、消费 |
5. 类型擦除到底发生了什么
5.1 一个最直观的现象
import java.util.ArrayList;
import java.util.List;
public class ErasureRuntimeDemo {
public static void main(String[] args) {
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass()); // true
}
}这说明:
List<String>和List<Integer>在运行时是同一个ArrayList类。- JVM 不会为每一种泛型参数都生成一份新类。
- 泛型的主要检查发生在编译期,而不是靠运行时保留完整参数化类型来完成。
5.2 类型擦除的基本规则
可以把类型擦除理解成“编译器在生成字节码前,先把泛型改写成普通类型表示”。
常见规则:
- 无边界的
T,通常擦除为Object - 有上界的
T extends Number,通常擦除为Number - 编译器会在需要的地方自动补充强制类型转换
- 编译器在某些继承场景下会补充桥接方法
例如:
public class NumberBox<T extends Number> {
private T value;
public T get() {
return value;
}
}从擦除的角度看,它更接近:
public class NumberBox {
private Number value;
public Number get() {
return value;
}
}这不是完整源码级等价转换,但足够帮助你理解擦除后的大方向。
5.3 编译器会偷偷帮你补类型转换
List<String> list = new ArrayList<>();
list.add("A");
String value = list.get(0);在概念上,编译器会把“取出并当作 String 使用”的逻辑补成类似下面的效果:
String value = (String) list.get(0);这也是为什么:
- 泛型能减少你手写强转
- 但底层并不是 JVM 真正理解了所有泛型参数
- 如果你绕过泛型检查,运行时仍然可能出现
ClassCastException
5.4 类型擦除不等于“运行时完全没有任何泛型痕迹”
这是一个很容易误解的点。
对象实例本身通常不会带着“我是 List<String>”这样的完整运行时类型参数,
但类、字段、方法声明上的泛型签名,仍然可能保存在字节码元数据里,供反射读取。
例如:
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
class UserGroup {
private List<String> names = new ArrayList<>();
}
public class ReflectionGenericDemo {
public static void main(String[] args) throws Exception {
Field field = UserGroup.class.getDeclaredField("names");
System.out.println(field.getType().getName());
System.out.println(field.getGenericType().getTypeName());
}
}你会看到类似结果:
java.util.List
java.util.List<java.lang.String>这说明:
field.getType()看到的是原始类型Listfield.getGenericType()还能读到声明时写下的泛型签名
这也是很多框架能分析字段、方法泛型信息的原因。
5.5 类型擦除带来的直接限制
下面这些写法要么直接编译失败,要么没有你想象中的效果:
// new T(); // 不能直接创建类型参数对象
// T.class; // 不能直接拿到 T 的 Class 对象
// if (obj instanceof List<String>) {} // 不能这样判断
// List<String>[] arr = new List<String>[10]; // 不能直接创建泛型数组如果业务上确实需要运行时类型信息,常见做法有:
- 显式传入
Class<T> - 传入更完整的类型引用对象
- 在字段、方法、父类声明处通过反射读取泛型签名
6. 桥接方法为什么存在
桥接方法是编译器为了维持多态和重写关系自动生成的方法。
它的出现,本质上是为了让“源码里看起来能正确重写的方法”,在擦除之后依然能对得上。
看一个例子:
class Parent<T> {
public T echo(T value) {
return value;
}
}
class StringParent extends Parent<String> {
@Override
public String echo(String value) {
return "string:" + value;
}
}源码里看上去没问题,因为子类确实重写了父类的 echo。
但擦除后:
- 父类里的
echo(T value)更接近echo(Object value) - 子类里的
echo(String value)仍然是echo(String value)
此时签名已经不一致了。为了让多态还能成立,编译器会在子类里补一个桥接方法,逻辑类似:
public Object echo(Object value) {
return echo((String) value);
}你不需要手写它,但需要知道它存在,因为:
- 用
javap -v看字节码时能看到它 - 框架、反射、调试字节码问题时经常会遇到
- 这正是“泛型源码看起来正常,但字节码层面要补救”的典型体现
7. 为什么不能直接创建泛型数组
泛型数组是高频问题,因为数组和泛型的类型机制不一样:
- 数组在运行时知道自己的元素类型,例如
String[] - 泛型在大多数情况下会被擦除,例如
List<String>运行时只剩List
如果允许直接创建泛型数组,会把“数组的运行时类型检查”和“泛型的编译期类型检查”混在一起,容易造成类型污染。
例如下面这行代码会直接编译失败:
// List<String>[] array = new List<String>[10];更推荐的做法:
- 优先使用
List<List<String>>代替泛型数组 - 如果必须创建和
T相关的数组,通常通过Array.newInstance加Class<T>解决
示意代码:
import java.lang.reflect.Array;
public class ArrayFactory {
public static <T> T[] createArray(Class<T> type, int size) {
@SuppressWarnings("unchecked")
T[] array = (T[]) Array.newInstance(type, size);
return array;
}
}8. 学习重点
这一章最应该真正掌握的,不是语法背诵,而是下面几个判断:
- 泛型的主要价值在编译期,而不是运行期
? extends T重点在“安全读取”,? super T重点在“安全写入”List<Object>不是List<String>的父类型- 类型擦除不代表所有泛型信息都完全消失,声明处签名仍可能被反射读取
- 很多框架要求你传
Class<T>,本质上是在弥补运行时类型参数不可直接获取的问题
9. 常见问题
9.1 以为运行时还能完整拿到泛型参数
错误理解:
new ArrayList<String>()创建出来的对象,运行时一定知道自己是String
更准确的理解:
- 对象实例通常只知道自己是
ArrayList - 泛型参数更多体现在源码和字节码签名层面
9.2 以为 List<Object> 能接收 List<String>
这是错误的:
// List<Object> list = new ArrayList<String>(); // 编译失败原因是:
- Java 泛型默认是不变的
List<String>和List<Object>是两个不同的参数化类型
9.3 只记语法,不理解边界设计意义
如果只死记:
extends是上界super是下界
但不知道什么时候用,写 API 时还是会混乱。
记住“读用 extends,写用 super”会更实用。
9.4 把原始类型和泛型混用
下面这种写法会让类型检查失效:
List<String> list = new ArrayList<>();
List raw = list;
raw.add(123);
String value = list.get(0); // 运行时可能抛出 ClassCastException工程里应尽量避免原始类型,除非是在兼容老代码或和底层 API 交互。
10. 动手验证
这一节可以直接操作,适合边看边练。
10.1 准备一个可运行示例
新建文件 GenericEraseDemo.java,内容如下:
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
class Box<T> {
private T value;
public void set(T value) {
this.value = value;
}
public T get() {
return value;
}
}
class Parent<T> {
public T echo(T value) {
return value;
}
}
class StringParent extends Parent<String> {
@Override
public String echo(String value) {
return "string:" + value;
}
}
class UserGroup {
private List<String> names = new ArrayList<>();
}
public class GenericEraseDemo {
public static <T> T first(List<T> list) {
return list.get(0);
}
public static <T> void copy(List<? extends T> source, List<? super T> target) {
for (T item : source) {
target.add(item);
}
}
public static void main(String[] args) throws Exception {
Box<String> box = new Box<>();
box.set("hello");
System.out.println("box=" + box.get());
List<String> strings = new ArrayList<>();
strings.add("A");
System.out.println("first=" + first(strings));
List<Integer> source = Arrays.asList(1, 2, 3);
List<Number> target = new ArrayList<>();
copy(source, target);
System.out.println("target=" + target);
List<? extends Number> producer = source;
System.out.println("producerRead=" + producer.get(0));
// producer.add(4); // 取消注释后编译失败
List<? super Integer> consumer = target;
consumer.add(99);
System.out.println("consumerReadAsObject=" + consumer.get(target.size() - 1));
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println("sameRuntimeClass=" + (a.getClass() == b.getClass()));
Parent<String> parent = new StringParent();
System.out.println("bridgeCall=" + parent.echo("bridge"));
Field field = UserGroup.class.getDeclaredField("names");
System.out.println("rawFieldType=" + field.getType().getName());
System.out.println("genericFieldType=" + field.getGenericType().getTypeName());
}
}10.2 编译并运行
javac GenericEraseDemo.java
java GenericEraseDemo如果你在 PowerShell 中操作,也直接执行同样两条命令即可。
10.3 你应该观察到什么
输出不一定逐字完全一致,但应包含这些关键信息:
box=hello
first=A
target=[1, 2, 3]
producerRead=1
consumerReadAsObject=99
sameRuntimeClass=true
bridgeCall=string:bridge
rawFieldType=java.util.List
genericFieldType=java.util.List<java.lang.String>验证结论:
sameRuntimeClass=true证明不同泛型参数在运行时类相同producerRead=1说明? extends Number可以安全读取consumerReadAsObject=99说明? super Integer读取时只能按Object理解rawFieldType和genericFieldType的对比,说明声明处泛型签名仍可能被反射读取
10.4 再做两个“故意失败”的验证
把这两处注释分别取消,再重新执行 javac:
// producer.add(4);
// List<String>[] array = new List<String>[10];你应该看到编译器直接报错。
这两个错误恰好对应:
extends不能安全写入具体元素- 泛型数组不能直接创建
10.5 查看桥接方法
先编译:
javac GenericEraseDemo.java再查看 StringParent 的字节码:
javap -v StringParent你可以在输出里搜索 bridge 或 ACC_BRIDGE。
如果是在 PowerShell 里,也可以这样筛选:
javap -v StringParent | Select-String -Pattern "bridge|ACC_BRIDGE"如果看到了桥接相关标记,说明编译器确实为子类补了桥接方法。
11. 练习建议
下面这些练习做完,基本就真正掌握了:
- 自己实现一个
Pair<L, R>泛型类,补上构造器、getter、toString - 写一个
max(List<? extends Comparable<? super T>> list)方法,体会复杂边界的设计目的 - 写一个
copy(List<? extends T> src, List<? super T> dst),再用Integer -> Number -> Object多组类型做验证 - 用反射读取一个字段、一个方法返回值、一个父类声明上的泛型信息
- 用
javap -v观察桥接方法和泛型签名
12. 自测问题
- Java 泛型的核心价值更偏向编译期还是运行期?为什么?
? extends T和? super T的适用场景分别是什么?- 为什么
List<Object>不能直接接收List<String>? - 类型擦除之后,为什么框架有时仍然能读到字段上的泛型信息?
- 为什么很多框架需要显式传入
Class<T>或类型引用对象? - 为什么不能直接创建
new List<String>[10]? - 桥接方法解决的本质问题是什么?
13. 自测核对要点
如果你的回答能覆盖下面这些点,基本就说明掌握到位了:
- 泛型主要负责编译期类型检查和 API 表达能力
- 类型擦除让 JVM 不需要为每种参数化类型生成独立运行时类
extends适合读,super适合写- Java 泛型是不变的,所以
List<String>不是List<Object>的子类型 - 运行时对象不一定保留完整泛型参数,但声明处签名可能保留在字节码元数据中
Class<T>、类型引用对象这类额外参数,本质上是在补足运行时类型信息- 桥接方法是编译器为维持擦除后的重写关系而自动生成的