跳到内容

第 1 卷 / 第 12 章 / 第 3 课

12.3 类型擦除与运行时边界 ​

完成不变性、通配符与 PECS后,再用约 40 分钟检查泛型跨过编译边界时发生了什么。你会实际观察擦除、桥接方法、可具体化类型、数组冲突与 Class<T> 类型令牌。

运行时还区分 Box<String> 与 Box<Integer> 吗 ​

编译器能阻止错误类型写入 Box<String>。程序运行后,Box<String> 与 Box<Integer> 是否仍是两个不同的类?

它们在源码中携带不同类型关系,在同一个 Box 类定义下共享同一个运行时 Class 对象。这不表示泛型“只是注释”:编译器已经完成类型检查,必要的转换和桥接方法也写进了 class 文件。需要分清的是,哪些信息参与编译,哪些信息仍可在运行时查询。

1. 擦除发生在检查之后 ​

没有显式上界的 T 擦除到 Object。先用一个完整的最小容器观察:

java
final class Box<T> {
    private final T value;
    Box(T value) { this.value = java.util.Objects.requireNonNull(value); }
    T get() { return value; }
}

get 的字节码描述符返回 Object。当调用 Box<String>.get() 的结果需要作为 String 使用时,编译器会在调用处加入检查转换;结果被直接丢弃等场景可能不需要该转换。若声明为 <T extends Number>,T 擦除到 Number;有多个上界时使用最左侧上界。

Box<String> 与 Box<Integer> 因而共享运行时类:

java
boolean sameClass =
        new Box<>("Hammer").getClass().equals(new Box<>(42).getClass());

class 文件仍可用 Signature 等属性保存声明上的部分泛型信息,反射能读取字段、方法或父类声明里的类型参数。普通 Box 对象本身没有一份自动记录调用方具体类型实参的标签。

2. 桥接方法维持擦除后的多态 ​

进入 class 文件后,接口上的 T 已擦除成较宽的类型,子类却仍声明返回 String。编译器会生成 bridge method,让擦除后的接口调用仍能分派到更具体的实现。

java
interface Source<T> {
    T get();
}

final class StringSource implements Source<String> {
    @Override
    public String get() {
        return "Hammer";
    }
}

Source 的 T 擦除到 Object,实现方法却返回 String。编译器会为 StringSource 生成一个 synthetic bridge method,使擦除后的 Object get() 仍能分派到源码里的 String get()。

把这段接口和实现保存为 StringSource.java,即可从命令行观察:

bash
javac --release 17 -encoding UTF-8 StringSource.java
javap -v StringSource

也可以在反射中检查 Method.isBridge()。覆盖率或调用栈偶尔出现源码中没有的方法,不必立刻怀疑字节码被篡改,先看它是不是编译器生成的桥接方法。

3. 擦除会让某些重载撞在一起 ​

若两个重载只靠 List<String> 和 List<Integer> 区分,擦除后它们具有相同 JVM descriptor,运行时无法区分。

以下两个方法不能同时声明:

java
void process(List<String> values) {}
void process(List<Integer> values) {}

它们擦除后都是 process(List),JVM 方法描述符无法区分。可以改用不同方法名、增加类型确实不同的非泛型参数,或把逻辑合并成一个泛型方法。仅靠返回类型不同也不能构成 Java 重载。

4. 可具体化类型与 instanceof 的边界 ​

运行时保留足够信息的类型称为 reifiable type。基本类型、非泛型类、raw type、无界通配符参数化类型,以及由可具体化组件构成的数组,是常见例子。

java
Object value = java.util.List.of("Hammer");
if (value instanceof List<?>) {
    List<?> list = (List<?>) value;
}

// value instanceof List<String> // 编译失败

这里 value 的编译期类型是 Object,JVM 能确认对象是 List,却不能据此保证所有元素都是 String。Java 17 也允许某些无需 unchecked 转换的具体泛型检查,例如 List<String> words = new ArrayList<>(); boolean concrete = words instanceof ArrayList<String>;。这个检查利用已有的 List<String> 静态保证,运行时只检查 ArrayList 类,不会逐项检查 String。若接口要求这项保证,必须遍历元素,并明确遇到不兼容元素时是拒绝整个输入还是报告具体位置。

同一边界也解释了这些限制:

java
// new T()
// T.class
// new T[10]
// new List<String>[10]

编译器不知道 T 的构造器,也没有一个代表任意 T 的运行时 Class 对象。泛型类还不能直接或间接继承 Throwable,否则 catch 无法根据已擦除的类型实参区分异常。

5. 数组协变,泛型不变 ​

数组在运行时保留组件类型:

java
Object[] values = new String[1];
values[0] = 42; // ArrayStoreException

赋值能编译,因为 String[] 是 Object[] 的子类型;实际写入时,数组对象知道自己的组件是 String,因此拒绝整数。泛型则在编译期拒绝 List<Integer> 到 List<Number> 的赋值。

需要从泛型集合得到数组时,把运行时组件类型交给调用者:

java
static <T> T[] toArray(
        List<T> values,
        java.util.function.IntFunction<T[]> factory) {
    return values.toArray(factory);
}

String[] tools = toArray(List.of("Hammer"), String[]::new);

一般的可变序列优先使用 List<T>。只有 API 边界确实要求数组时,才承担组件类型和数组协变带来的额外规则。

6. raw type、unchecked cast 与泛型 varargs ​

类型实参必须是引用类型,所以使用 List<Integer>,不能写 List<int>。装箱让整数进入泛型集合,也会带来转换和内存成本;大量数值计算需要基准测试,而不是默认标准集合总够快。

泛型 varargs 在需要打包实参时会创建数组。这里元素是 List<T>,运行时数组组件只能是擦除后的 List:

java
@SafeVarargs
static <T> List<T> combine(List<T>... groups) {
    List<T> result = new java.util.ArrayList<>();
    for (List<T> group : groups) result.addAll(group);
    return result;
}

去掉示例中的 SafeVarargs 后,用 -Xlint:all 编译会报告 possible heap pollution。此实现只读取各个列表并复制元素,没有写入或泄漏参数数组。raw type、未经验证的 unchecked cast,以及泄漏或写入泛型 varargs 数组,都会让堆中对象违背静态类型承诺。

@SafeVarargs 是作者对方法体安全性的承诺,不是关闭警告的按钮。只有确认方法既不写入不兼容值,也不把 varargs 数组交给不可信代码时才能使用。Java 17 只允许在可变参数构造方法,以及 static、final 或 private 的可变参数方法上使用 SafeVarargs;可被重写的普通实例方法不能用它。能改成 List<List<T>> 时,集合参数通常更容易审查。

7. Class<T> 把一层运行时类型交回 API ​

有些仓库检查必须发生在运行时。与其假装箱子自己记得完整泛型实参,不如让调用者递上一枚 Class<T> 类型令牌。它能核对一层具体 runtime class,却恢复不了 List<String> 里嵌套的 String。

调用者可以显式提供 Class token:

java
final class TypeRegistry {
    private final Map<Class<?>, Object> values = new HashMap<>();

    <T> void put(Class<T> type, T value) {
        requireReferenceType(type);
        values.put(type, type.cast(java.util.Objects.requireNonNull(value, "value")));
    }

    <T> Optional<T> find(Class<T> type) {
        requireReferenceType(type);
        return Optional.ofNullable(values.get(type)).map(type::cast);
    }

    private static void requireReferenceType(Class<?> type) {
        java.util.Objects.requireNonNull(type, "type");
        if (type.isPrimitive()) {
            throw new IllegalArgumentException("reference type token required");
        }
    }
}

Class.cast 在注册边界做运行时检查,find(String.class) 的返回类型也随 token 变为 Optional<String>。这里拒绝 null token、null 值和基本类型 token,让空 Optional 只表示未注册。尤其要区分 Integer.class 和 int.class:后者虽然有 Class<Integer> 静态类型,却不能用 cast 接受 Integer 对象。这个单线程内存注册表按精确 token 查找,注册 Integer.class 不会自动让 Number.class 命中;快照也只是浅复制。

它只能表达运行时 Class。List<String> 和 List<Integer> 的原始类都是 List.class,没有 List<String>.class 这样的类字面量。框架要保存嵌套类型时,通常使用 Type、type token 子类或显式类型描述对象;这些描述保存的是类型元数据,仍不会自动检验集合中的每个元素。下一章讲反射时会再遇到这个边界。

8. 拆开 class 文件验证三道边界 ​

这一回不能只运行 main。先让 javac 检查源码,再用 javap 打开 class 文件寻找 bridge method,最后用运行时断言检查 type token。源码、class 文件和运行时分别保留不同层次的类型信息。

先准备临时目录:

bash
chapter12_erasure_root=$(mktemp -d)
readonly chapter12_erasure_root
mkdir -p "$chapter12_erasure_root/out"
cd "$chapter12_erasure_root"

保存为 ErasureRuntimeDemo.java:

java
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;

public class ErasureRuntimeDemo {
    public static void main(String[] args) {
        Box<String> words = new Box<>("Hammer");
        Box<Integer> counts = new Box<>(3);
        check(words.getClass().equals(counts.getClass()),
                "不同类型实参应共享同一个运行时类");

        boolean bridgeFound = false;
        for (Method method : StringSource.class.getDeclaredMethods()) {
            if (method.isBridge() && method.isSynthetic()) {
                bridgeFound = true;
            }
        }
        check(bridgeFound, "编译器应生成 synthetic bridge method");
        Source<String> source = new StringSource();
        checkEquals("Hammer", source.get());

        TypeRegistry registry = new TypeRegistry();
        registry.put(String.class, "west");
        registry.put(Integer.class, 3);
        checkEquals(Optional.of("west"), registry.find(String.class));
        checkEquals(Optional.of(3), registry.find(Integer.class));
        checkEquals(Optional.empty(), registry.find(Long.class));

        Map<Class<?>, Object> snapshot = registry.snapshot();
        expect(UnsupportedOperationException.class,
                () -> snapshot.put(Long.class, 9L));

        Object value = java.util.List.of("Hammer");
        check(value instanceof java.util.List<?>,
                "无界通配符类型可以做 instanceof 检查");

        System.out.println("共享运行时类:" + words.getClass().getSimpleName());
        System.out.println("检测到桥接方法:" + bridgeFound);
        System.out.println("类型注册:" + registry.find(String.class)
                + " / " + registry.find(Integer.class));
        System.out.println("擦除与运行时边界检查通过");
    }

    interface Source<T> {
        T get();
    }

    static final class StringSource implements Source<String> {
        @Override
        public String get() {
            return "Hammer";
        }
    }

    static final class Box<T> {
        private final T value;
        Box(T value) {
            this.value = Objects.requireNonNull(value, "value");
        }
        T get() {
            return value;
        }
    }

    static final class TypeRegistry {
        private final Map<Class<?>, Object> values = new HashMap<>();

        <T> void put(Class<T> type, T value) {
            requireReferenceType(type);
            values.put(type, type.cast(java.util.Objects.requireNonNull(value, "value")));
        }

        <T> Optional<T> find(Class<T> type) {
            requireReferenceType(type);
            return Optional.ofNullable(values.get(type)).map(type::cast);
        }

        private static void requireReferenceType(Class<?> type) {
            java.util.Objects.requireNonNull(type, "type");
            if (type.isPrimitive()) {
                throw new IllegalArgumentException("reference type token required");
            }
        }

        Map<Class<?>, Object> snapshot() {
            return Collections.unmodifiableMap(new HashMap<>(values));
        }
    }

    static <T extends Throwable> T expect(
            Class<T> type, Runnable action) {
        try {
            action.run();
        } catch (Throwable failure) {
            if (type.isInstance(failure)) return type.cast(failure);
            throw new AssertionError(
                    "expected=" + type + ", actual=" + failure, failure);
        }
        throw new AssertionError("expected exception=" + type.getName());
    }

    static void check(boolean condition, String message) {
        if (!condition) throw new AssertionError(message);
    }

    static void checkEquals(Object expected, Object actual) {
        if (!Objects.equals(expected, actual)) {
            throw new AssertionError(
                    "expected=" + expected + ", actual=" + actual);
        }
    }
}
bash
javac --release 17 -encoding UTF-8 -Xlint:all -d out ErasureRuntimeDemo.java
java -cp out ErasureRuntimeDemo
javap -v -classpath out 'ErasureRuntimeDemo$StringSource'

main 的预期输出如下;随后 javap 会列出反汇编结果,其中应出现 ACC_BRIDGE、ACC_SYNTHETIC,以及返回 Object 的桥接 get 方法:

text
共享运行时类:Box
检测到桥接方法:true
类型注册:Optional[west] / Optional[3]
擦除与运行时边界检查通过

保存记录后清理:

bash
cd
ls -ld -- "$chapter12_erasure_root"
rm -r -- "$chapter12_erasure_root"

分清编译期与运行时两道边界 ​

编译期先验证参数化类型关系,运行时再按擦除后的类和必要的检查转换执行。桥接方法维持多态,List<?> 允许检查“它是一个 List”,Class<T> 则让调用者显式交回一层类型信息;它们都不能凭空恢复 List<String> 的嵌套实参。

最后让编译器拒绝四种写法:按 List<String> 与 List<Integer> 重载、instanceof List<String>、new T[10]、泛型异常类。再用 javap -v 对照反射检测到的 bridge method,源码和字节码就接上了。

查阅 Java 17 的正式规则 ​

下一步:注解与反射 ​

泛型说明了编译器如何保留类型关系,又在运行时擦除大部分类型实参。下一章会看到另一条信息通道:注解把元数据写进声明,反射在运行时读取类、方法和字段。

继续学习第 13 章:注解

Built with VitePress | Software Systems Atlas