《Java 编程入门》11.3 类型擦除与运行时真相

用 javap 和真实运行结果拆开类型擦除:List<String> 与 List<Integer> 为什么是同一个类、桥方法从哪来、new T() 和 instanceof List<String> 为什么被禁止;再讲 Class<T>、Supplier<T> 与匿名 TypeRef 三种在运行时恢复类型信息的办法,并落回 library 的泛型仓储。

本节目标:理解泛型信息在编译后发生了什么,能解释 new T()、instanceof List<String>、泛型重载为什么都不合法,并知道用 Class<T>、Supplier<T> 或类型令牌在运行时恢复类型。
适用版本:JDK 25 LTS(Temurin 25.0.5);擦除行为与 JDK 21 一致。

上一节留下的问题

Repository<T, ID> 写起来很顺,但一旦离开编译器的保护,问题就冒出来了:

  • 为什么 InMemoryRepository 必须接收一个 Function<T, ID>,不能直接调用 entity.getId()?
  • 为什么不能写 new T() 或 new T[10]?
  • 为什么 value instanceof List<String> 编译不过,但 value instanceof List<?> 可以?
  • 为什么 Result<String> 和 Result<Integer> 不能作为两个重载方法的参数?

这些问题的答案都指向同一件事:泛型主要活在编译期,编译成 class 文件后,大部分类型实参被擦除了。

擦除到底擦掉了什么

先看一段最普通的代码:

List<String> names = new ArrayList<>();
List<Integer> numbers = new ArrayList<>();
System.out.println(names.getClass() == numbers.getClass());
System.out.println(names.getClass().getName());

本机 JDK 25 输出:

true
java.util.ArrayList

List<String> 和 List<Integer> 在运行时是同一个类 java.util.ArrayList,类型实参 String 和 Integer 不见了。这不是实现偷懒,而是 Java 5 引入泛型时为兼容旧字节码做的设计:泛型必须能编译到和旧代码相同的 class 文件格式上,不能改变 JVM 的类型系统。

擦除的规则可以概括成:

  • 无界类型参数 T 擦成 Object。
  • 有界类型参数 T extends Number 擦成 Number。
  • 参数化类型 List<String> 擦成原始类型 List。
  • 编译器在需要的地方插入强制转换,保证取出来的值能赋给声明的类型。

用 javap 看一个带泛型参数的方法:

static String first(List<String> values) {
    return values.get(0);
}
javac ErasureDemo.java
javap -c -p ErasureDemo

关键几行是:

static java.lang.String first(java.util.List<java.lang.String>);
  Code:
     0: aload_0
     1: iconst_0
     2: invokeinterface #7,  2  // InterfaceMethod java/util/List.get:(I)Ljava/lang/Object;
     7: checkcast     #13       // class java/lang/String
    10: areturn

方法签名里还能看到 List<java.lang.String>,那是给反射和调试看的 Signature 属性;真正执行时,List.get 返回的是 Object,编译器在 checkcast 指令里补了一次到 String 的转换。换句话说,泛型检查在编译期完成,运行期靠编译器插入的 cast 兜底。

擦除带来的限制

限制一:不能 new 类型参数

T create() {
    return new T();
}
错误: 意外的类型
        return new T();
                   ^
  需要: 类
  找到:    类型参数T

编译器不知道 T 有没有无参构造器,也不知道它是不是抽象类。擦除后 T 只是 Object,new Object() 显然不能保证是调用方想要的类型。需要创建实例时,传入 Supplier<T> 或 Class<T>。

限制二:不能对参数化类型做 instanceof

static boolean isStringList(Object value) {
    return value instanceof List<String>;
}
错误: Object 无法安全地转换为 List<String>
        return value instanceof List<String>;
               ^

运行时没有 List<String> 这个类型,只有 List。instanceof List<?> 合法,因为它只检查「是不是 List」,不检查元素类型。真需要检查元素,只能遍历并逐个判断,或者依赖调用方传入 Class<T>。

限制三:不能用类型实参做重载

static void print(List<String> values) { }
static void print(List<Integer> values) { }
错误: 名称冲突: print(List<Integer>)和print(List<String>)具有相同疑符
    static void print(List<Integer> values) { }
                ^

擦除后两个方法都是 print(List),签名相同。Result<String> 和 Result<Integer> 也一样,不能靠它们区分重载。需要区分时,换方法名,或者包一层不同的类型。

桥方法:擦除留下的补丁

擦除还会影响继承。看一个协变返回值的例子:

public class Parent {
    Object value() {
        return "parent";
    }
}

public class Child extends Parent {
    @Override
    String value() {
        return "child";
    }
}

String 是 Object 的子类型,这个覆盖是合法的。但 JVM 的方法签名包含返回类型,父类的 value()Ljava/lang/Object; 和子类的 value()Ljava/lang/String; 并不是同一个方法。编译器会在子类里生成一个桥方法(bridge method):

javap -c -p Child
public class Child extends Parent {
  public Child();
    Code:
         0: aload_0
         1: invokespecial #1  // Method Parent."<init>":()V
         4: return

  java.lang.String value();
    Code:
         0: ldc           #7  // String child
         2: areturn

  java.lang.Object value();
    Code:
         0: aload_0
         1: invokevirtual #9  // Method value:()Ljava/lang/String;
         4: areturn
}

最后一个 Object value() 就是桥方法:它把调用转发给真正的 String value()。调用方通过父类引用调用时,JVM 找到的是桥方法,桥方法再转到子类实现。

泛型接口的实现也会生成桥方法。比如一个类实现 Comparable<Book>,编译器会生成 compareTo(Object) 的桥方法,内部转换成 compareTo(Book)。这些方法在源码里看不见,但 javap -p 能看到。理解了桥方法,就不会被栈追踪里出现的「多出来一层」吓到。

运行时怎么恢复类型信息

擦除不等于所有类型信息都消失。class 文件的 Signature 属性保留了泛型签名,反射可以读出来。常见做法有三种。

办法一:传 Class 令牌

static <T> T create(Class<T> type) throws ReflectiveOperationException {
    return type.getDeclaredConstructor().newInstance();
}
System.out.println("[" + create(String.class) + "]");

输出:

[]

Class<T> 是运行时可用的类型对象,T 由它携带。缺点是目标类必须有无参构造器,且反射创建比直接 new 慢;它适合框架和序列化,不适合高频业务路径。

办法二:传 Supplier 工厂

Supplier<Book> factory = () -> new Book(
        "978-7-111-54742-6", "Effective Java", "Joshua Bloch", 2018);
Book book = factory.get();

Supplier<T> 把「怎么创建 T」变成一段代码,不依赖反射,也没有无参构造器的限制。InMemoryRepository 选择 Function<T, ID> 而不是反射,是同一个思路:把擦除后拿不到的信息,由调用方显式传进来。

办法三:匿名子类做类型令牌

abstract class TypeRef<T> {
    private final Type type;

    TypeRef() {
        this.type = ((ParameterizedType) getClass().getGenericSuperclass())
                .getActualTypeArguments()[0];
    }

    Type type() {
        return type;
    }
}
TypeRef<List<Book>> ref = new TypeRef<List<Book>>() { };
System.out.println(ref.type());

输出:

java.util.List<com.plume.library.domain.Book>

匿名子类 new TypeRef<List<Book>>() { } 的父类是参数化类型,编译器会把 List<Book> 写进它的 Signature 属性;构造器里通过 getGenericSuperclass() 读回来。Jackson 的 TypeReference、Gson 的 TypeToken 都是这个套路——不引入第三方库,我们也能用标准反射实现一个最小版本。

在 library 里落地

回到 InMemoryRepository。它不能调用 entity.getId(),也不能 new T(),所以把「取主键」变成构造参数:

public class InMemoryRepository<T, ID> implements Repository<T, ID> {
    private final Map<ID, T> store = new LinkedHashMap<>();
    private final Function<T, ID> idOf;

    public InMemoryRepository(Function<T, ID> idOf) {
        this.idOf = idOf;
    }

    @Override
    public T save(T entity) {
        store.put(idOf.apply(entity), entity);
        return entity;
    }

    @Override
    public Optional<T> findById(ID id) {
        return Optional.ofNullable(store.get(id));
    }

    @Override
    public List<T> findAll() {
        return List.copyOf(store.values());
    }

    @Override
    public boolean deleteById(ID id) {
        return store.remove(id) != null;
    }

    @Override
    public long count() {
        return store.size();
    }
}

T 和 ID 在运行时都被擦成 Object,Map<ID, T> 实际是 Map<Object, Object>;idOf.apply(entity) 是唯一知道怎么取主键的地方。具体仓储把方法引用传进来:

public final class InMemoryBookRepository
        extends InMemoryRepository<Book, String> {

    public InMemoryBookRepository() {
        super(Book::isbn);
    }
}

super(Book::isbn) 在编译期被检查成 Function<Book, String>,在运行时就是一个普通的函数对象。findById("978-...") 返回 Optional<Book>,编译器在调用点插入 checkcast,所以调用方不需要强转。

反射还能告诉我们 InMemoryBookRepository 的父类带了什么类型实参:

Type superType = InMemoryBookRepository.class.getGenericSuperclass();
System.out.println(superType);

本机 JDK 25 输出:

com.plume.library.repository.InMemoryRepository<com.plume.library.domain.Book, java.lang.String>

Result<T> 也逃不过擦除。Result<String> 和 Result<Integer> 运行时都是 Result,所以下面两个方法不能共存:

void handle(Result<String> result) { }
void handle(Result<Integer> result) { }

编译器的报错和 List 的例子一样,都是「名称冲突……具有相同疑符」。如果确实需要按成功值类型分流,只能在方法体里用 instanceof 检查 Ok 的值,或者给 Result 加一个显式的类型标记。

常见错误与边界

错误一:以为强转能恢复泛型检查

@SuppressWarnings("unchecked")
static List<String> asStringList(Object value) {
    return (List<String>) value;
}

去掉 @SuppressWarnings 后编译会提示:

警告: [unchecked] 未经检查的转换
        return (List<String>) value;
                              ^
  需要: List<String>
  找到:    Object

这个转换在运行时什么都不检查,因为 List<String> 和 List<Integer> 是同一个类。把 List<Integer> 传进去,直到取元素时才炸:

Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String

@SuppressWarnings("unchecked") 只应该用在「我能证明这里安全」的地方,不能当成消除错误的万能药。同理,@SafeVarargs 只是对编译器的承诺,不会在运行时增加检查;它只能标在 static、final、private 方法或构造器上。

小结

  • 泛型类型实参在编译后被擦除:List<String> 和 List<Integer> 运行时都是 List,无界 T 擦成 Object,有界 T 擦成它的上界。
  • 编译器在取值处插入 checkcast,所以泛型错误通常表现为「编译期通过、运行时 ClassCastException」。
  • new T()、new T[10]、instanceof List<String>、泛型重载都不合法,因为运行时没有这些类型信息。
  • 桥方法是擦除的补丁:协变返回值和泛型接口实现会生成转发方法,javap -p 可以看到。
  • 需要运行时类型时,传 Class<T>、Supplier<T> 或使用匿名子类的 TypeRef;InMemoryRepository 用 Function<T, ID> 解决取主键问题。
  • @SuppressWarnings("unchecked") 和 @SafeVarargs 只是对编译器的承诺,不会增加运行时检查。

泛型让「容器装什么」在编译期可说、可查;下一章换一个方向:把「一段行为」当成参数传进方法。Lambda 和函数式接口会让 library 的搜索、排序、回调代码短很多。

阅读导航:上一节:11.2 通配符与 PECS · 下一节:12.1 Lambda 与函数式接口 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发