泛型与类型擦除

举报
yd_287908631 发表于 2026/10/20 11:21:25 2026/10/20
【摘要】 泛型与类型擦除:字节码里到底还剩什么Java 的泛型是编译期特性,运行期不存在 List<String> 这种类型。这个结论很多人背过,但由此推导出的行为——为什么不能 new T()、为什么重载冲突、桥方法从哪来——才是真正容易出错的地方。本文以 JDK 8/17 为基准,用 javap 和反射验证擦除规则,再给出通配符与变型的实用判断方法。 擦除规则编译器把类型参数替换为上界,没有上界...

泛型与类型擦除:字节码里到底还剩什么

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;。多个上界时,第一个是类或接口中排在最前的那个,其余只用于编译期约束。

擦除带来的四个限制

  1. 不能创建泛型数组:new T[10] 和 new List<String>[10] 都不合法。数组在运行期需要知道元素类型以做存储检查,而擦除后这个信息不存在。
  2. 不能对类型参数用 instanceof:x instanceof T 无法通过编译,只能对无界通配符做 instanceof List<?>。
  3. 静态成员不能引用类型参数:静态上下文不依附于任何实例,无法确定 T。
  4. 基本类型不能作为类型参数: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>>,从而绕过擦除。

常见坑 / 最佳实践

  1. 用 instanceof 判断泛型类型。擦除后无效,只能判断原始类型,再对元素逐个检查。
  2. 在泛型类里用 == 比较 T。T 可能是包装类型,应使用 Objects.equals。
  3. 泛型数组强转。(T[]) new Object[n] 会埋下 ClassCastException,需要 @SuppressWarnings("unchecked") 并确保数组不外泄。
  4. 过度使用通配符。方法返回值不要用通配符,会把复杂性推给调用方;通配符只出现在参数位置。
  5. 忽略原始类型警告。使用原始类型 List 会丢失全部类型检查,开启 -Xlint:unchecked 并逐个处理。
  6. 泛型异常。catch (T e) 不合法,异常类型必须在运行期可确定;可以借助 Class<T> 参数配合 clazz.isInstance(e)。

小结

  • 泛型在编译期检查,运行期擦除到上界,Signature 属性保留了供反射使用的元数据。
  • 桥方法由编译器生成,用于在擦除后的签名与真实实现之间转发,反射时需要过滤。
  • 通配符遵循 PECS:读取用 extends,写入用 super。
  • 泛型不变、数组协变,两者在类型安全上的取舍不同:前者编译期拒绝,后者运行期用 ArrayStoreException 兜底。
  • 需要运行期泛型信息时,用 TypeReference 这类匿名子类技巧保存签名,或改用 Class<T> 显式传参。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。