泛型与类型擦除
泛型与类型擦除:字节码里到底还剩什么
Java 的泛型是编译期特性,运行期不存在 List<String> 这种类型。这个结论很多人背过,但由此推导出的行为——为什么不能 new T()、为什么重载冲突、桥方法从哪来——才是真正容易出错的地方。
本文以 JDK 8/17 为基准,用 javap 和反射验证擦除规则,再给出通配符与变型的实用判断方法。
擦除规则
编译器把类型参数替换为上界,没有上界时替换为 Object:
public class Box<T> {
private T value;
public T get() { return value; }
public void set(T value) { this.value = value; }
}
javap 后的签名:
public java.lang.Object get();
descriptor: ()Ljava/lang/Object;
public void set(java.lang.Object);
descriptor: (Ljava/lang/Object;)V
注意 Signature 属性仍保留 Ljava/lang/Object; 与泛型信息,这就是反射能拿到 List<String> 的原因。擦除的是类型检查与强制转换的位置,不是元数据。
有上界时替换为第一个上界:
class NumberBox<T extends Number & Comparable<T>> {
T value;
}
字段与方法的描述符变成 Ljava/lang/Number;。多个上界时,第一个是类或接口中排在最前的那个,其余只用于编译期约束。
擦除带来的四个限制
- 不能创建泛型数组:
new T[10]和new List<String>[10]都不合法。数组在运行期需要知道元素类型以做存储检查,而擦除后这个信息不存在。 - 不能对类型参数用
instanceof:x instanceof T无法通过编译,只能对无界通配符做instanceof List<?>。 - 静态成员不能引用类型参数:静态上下文不依附于任何实例,无法确定 T。
- 基本类型不能作为类型参数:
List<int>不合法,只能用包装类。这也是Stream需要IntStream、LongStream、DoubleStream的原因——避免大量装箱。
桥方法
class Node implements Comparable<Node> {
public int compareTo(Node o) { return 0; }
}
擦除后接口方法是 compareTo(Object),实现类只有 compareTo(Node),二者描述符不同,无法构成重写。编译器会插入一个桥方法:
public int compareTo(java.lang.Object);
descriptor: (Ljava/lang/Object;)I
flags: ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC
Code:
0: aload_0
1: aload_1
2: checkcast #2 // class Node
5: invokevirtual #3 // Method compareTo:(LNode;)I
桥方法做 checkcast 后转发,ACC_BRIDGE 与 ACC_SYNTHETIC 标记它由编译器生成。反射调用 getDeclaredMethods 时会看到它,判断真实方法需要用 isBridge() 过滤。
另一个经典现象是"看似合法的重载冲突":
class A<T> {
void m(T t) {}
void m(String s) {} // 编译错误:两个方法的擦除后签名都是 m(Object)
}
反过来,子类"重写"父类泛型方法时可能出现两个不同签名的同名方法,原因同样在擦除。
泛型与重载、反射的冲突
擦除不仅影响数组和实例化,还会在反射层面制造意外。看下面的类:
class Handler<T> {
void handle(List<String> list) {}
void handle(Set<String> set) {}
}
这两个方法擦除后分别是 handle(List) 和 handle(Set),描述符不同,可以共存。但如果参数类型都是 List<T>:
class Handler<T> {
void handle(List<String> a) {}
void handle(List<Integer> b) {} // 编译错误
}
两个方法擦除后都是 handle(List),构成重复定义。
反射场景的问题更隐蔽:getDeclaredMethods() 返回的方法签名是擦除后的,参数类型是 List 而非 List<String>,用 getMethod("handle", List.class) 才能找到。若尝试用 ParameterizedType 去匹配,会失败,因为方法参数的运行时类型就是原始类型。
一个真实的后果是 Spring 的 @Autowired:当容器中存在 List<User> 和 List<Order> 两个 bean 时,按类型注入无法区分,必须配合 @Qualifier 或 @Primary。Spring 用 ResolvableType 从字段签名中恢复泛型信息才能做到精确匹配,这也是它比手写反射复杂的根本原因。
通配符与 PECS
List<? extends Number> 表示"某种 Number 子类型",只能读不能写(除了写 null);List<? super Integer> 表示"某种 Integer 父类型",可以写但读出来只能是 Object。
记忆方法是 PECS:Producer Extends, Consumer Super。
// 从 src 读取,向 dst 写入
public static <T> void copy(List<? extends T> src, List<? super T> dst) {
for (T t : src) {
dst.add(t);
}
}
JDK 的 Collections.copy 就是这种签名。判断依据是数据的流向:作为生产者被读取用 extends,作为消费者被写入用 super。
List<Object> 与 List<?> 不同:前者可以 add 任意对象,后者只能 add null 和 remove。List<?> 是"未知类型",List<Object> 是"元素为 Object"。
数组是协变的,泛型是不变的,这造成一个不对称:
Object[] arr = new String[1];
arr[0] = 1; // 运行时 ArrayStoreException
List<Object> list = new ArrayList<String>(); // 编译错误
数组的协变在运行期通过元素类型检查兜底,泛型选择在编译期直接禁止,代价是后者更安全但表达力受限。
自限定类型与递归泛型
JDK 中大量出现 class Enum<E extends Enum<E>> 这种写法,称为自限定类型(CRTP)。它的作用是让父类中的方法能返回具体的子类型:
abstract class Builder<T extends Builder<T>> {
protected String name;
@SuppressWarnings("unchecked")
public T name(String name) {
this.name = name;
return (T) this;
}
}
class UserBuilder extends Builder<UserBuilder> {
public UserBuilder age(int age) { return this; }
}
这样 new UserBuilder().name("tom").age(18) 可以链式调用,name 返回的是 UserBuilder 而不是 Builder。代价是父类中的强制转换无法避免,且类型参数会沿继承链传播,可读性下降。只有在构建器、比较器这类需要"返回自身类型"的场景才值得使用。
堆污染与 @SafeVarargs
可变参数本质是数组,泛型可变参数会创建泛型数组,触发警告:
@SafeVarargs
static <T> List<T> of(T... items) {
return new ArrayList<>(Arrays.asList(items));
}
@SafeVarargs 只能在 static、final 或构造器上使用(JDK 9 起还允许 private 方法),语义是"我保证不把 items 数组暴露出去,也不写入不兼容的类型"。如果方法内执行 items[0] = 1 这类操作,调用方可能在别处触发 ClassCastException,这就是堆污染。
static <T> T[] toArray(T... args) {
return args;
}
static <T> T[] pickTwo(T a, T b, T c) {
return toArray(a, b); // 返回 Object[],调用方按 String[] 接收会 ClassCastException
}
如何拿到泛型信息
字段、方法参数、方法返回值的泛型信息保存在 Signature 属性中,通过反射可读:
class Repo {
List<String> names;
}
Field f = Repo.class.getDeclaredField("names");
ParameterizedType pt = (ParameterizedType) f.getGenericType();
System.out.println(pt.getActualTypeArguments()[0]); // class java.lang.String
但局部变量、对象实例本身没有这份信息。new ArrayList<String>() 的运行时类型就是 ArrayList。这也是 Jackson 需要 TypeReference、Spring 需要 ResolvableType 的原因:它们把泛型信息放在匿名子类的父类签名里保存下来。
new TypeReference<List<User>>() {}
匿名子类的 getGenericSuperclass() 返回带参数的 TypeReference<List<User>>,从而绕过擦除。
常见坑 / 最佳实践
- 用
instanceof判断泛型类型。擦除后无效,只能判断原始类型,再对元素逐个检查。 - 在泛型类里用
==比较 T。T 可能是包装类型,应使用Objects.equals。 - 泛型数组强转。
(T[]) new Object[n]会埋下ClassCastException,需要@SuppressWarnings("unchecked")并确保数组不外泄。 - 过度使用通配符。方法返回值不要用通配符,会把复杂性推给调用方;通配符只出现在参数位置。
- 忽略原始类型警告。使用原始类型
List会丢失全部类型检查,开启-Xlint:unchecked并逐个处理。 - 泛型异常。
catch (T e)不合法,异常类型必须在运行期可确定;可以借助Class<T>参数配合clazz.isInstance(e)。
小结
- 泛型在编译期检查,运行期擦除到上界,
Signature属性保留了供反射使用的元数据。 - 桥方法由编译器生成,用于在擦除后的签名与真实实现之间转发,反射时需要过滤。
- 通配符遵循 PECS:读取用
extends,写入用super。 - 泛型不变、数组协变,两者在类型安全上的取舍不同:前者编译期拒绝,后者运行期用
ArrayStoreException兜底。 - 需要运行期泛型信息时,用
TypeReference这类匿名子类技巧保存签名,或改用Class<T>显式传参。
- 点赞
- 收藏
- 关注作者
评论(0)