本节目标:理解泛型信息在编译后发生了什么,能解释
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 与函数式接口 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。