Skip to content

泛型与类型擦除

1. 这是什么

泛型是 Java 在编译期提供的类型约束机制,用来提升代码复用性和类型安全。
类型擦除则表示:大多数泛型信息主要服务于编译器,编译完成后不会以“完整泛型参数”的形式直接存在于运行时对象里。

一句话理解:

  • 泛型解决的是“写代码时怎么更安全、更通用”。
  • 类型擦除解决的是“JVM 运行时如何兼容老代码和已有字节码体系”。

2. 为什么重要

泛型几乎贯穿整个 Java 生态:

  • 写集合时会频繁使用 List<String>Map<String, Object>
  • 写工具类、公共组件、框架扩展点时,经常要设计泛型 API。
  • Spring、MyBatis、Jackson、JPA 等框架大量依赖泛型声明和反射信息。
  • 如果不理解类型擦除,就很难解释“为什么运行时拿不到 T”“为什么这里必须传 Class<T>”“为什么不能直接 new T()”。

换句话说,泛型不是一个只在语法题里出现的知识点,而是 Java 工程开发中的基础能力。

3. 先建立直觉:泛型到底解决了什么问题

在没有泛型约束时,集合能装任何对象,问题往往拖到运行时才暴露:

java
import java.util.ArrayList;
import java.util.List;

public class RawTypeDemo {
    public static void main(String[] args) {
        List list = new ArrayList();
        list.add("hello");
        list.add(123);

        String first = (String) list.get(0);
        String second = (String) list.get(1); // 运行时抛出 ClassCastException

        System.out.println(first);
        System.out.println(second);
    }
}

用了泛型后,错误会提前到编译期:

java
import java.util.ArrayList;
import java.util.List;

public class GenericDemo {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("hello");
        // list.add(123); // 编译期直接报错

        String value = list.get(0);
        System.out.println(value);
    }
}

这里的关键结论是:

  • 原始类型 List 让编译器失去类型信息。
  • 泛型 List<String> 让编译器在写代码时就能帮你拦住错误。
  • 泛型把一部分“晚出错”的问题,提前成了“早发现”的问题。

4. 核心内容

4.1 泛型类

java
public class Box<T> {
    private T value;

    public void set(T value) {
        this.value = value;
    }

    public T get() {
        return value;
    }
}

使用时:

java
Box<String> box = new Box<>();
box.set("Java");
String value = box.get();

说明:

  • T 是类型参数,占位符而不是具体类型。
  • 创建对象时把 T 替换成 String,编译器就会按 String 检查。
  • 常见命名有 TEKVR,本质上都只是名称约定。

4.2 泛型方法

java
import java.util.List;

public class ListUtils {
    public static <T> T first(List<T> list) {
        return list.get(0);
    }
}

注意:

  • <T> 必须写在返回值类型前面,表示这是一个泛型方法。
  • 泛型方法不要求所在类本身也是泛型类。

4.3 泛型接口

java
public interface Converter<S, T> {
    T convert(S source);
}

实现类可以在实现时指定具体类型:

java
public class StringToIntegerConverter implements Converter<String, Integer> {
    @Override
    public Integer convert(String source) {
        return Integer.valueOf(source);
    }
}

4.4 上界通配符 ? extends T

? extends T 表示“某个未知类型,但它一定是 TT 的子类型”。

java
import java.util.List;

public class ExtendsDemo {
    public static void printNumbers(List<? extends Number> numbers) {
        Number n = numbers.get(0); // 可以安全读取为 Number
        System.out.println(n);

        // numbers.add(1); // 编译错误
        // numbers.add(1.5); // 编译错误
        numbers.add(null); // 只有 null 能放进去
    }
}

理解重点:

  • 你知道里面放的是 Number 家族。
  • 但你不知道它到底是 List<Integer>List<Double> 还是别的子类型。
  • 因为“不确定具体子类型”,所以编译器不允许你写入具体值。

所以,extends 更适合“读多写少”的场景。

4.5 下界通配符 ? super T

? super T 表示“某个未知类型,但它一定是 TT 的父类型”。

java
import java.util.List;

public class SuperDemo {
    public static void addIntegers(List<? super Integer> list) {
        list.add(1);
        list.add(2);

        Object value = list.get(0); // 读取时只能当成 Object
        System.out.println(value);
    }
}

理解重点:

  • 你可以确定往里放 Integer 是安全的。
  • 但读出来时不知道实际容器声明成了 List<Integer>List<Number> 还是 List<Object>
  • 所以读取结果只能按 Object 处理。

所以,super 更适合“写多读少”的场景。

4.6 PECS 原则

PECS 是最常见的泛型边界设计口诀:

  • Producer Extends:如果参数负责“生产数据给你读”,用 extends
  • Consumer Super:如果参数负责“消费你写进去的数据”,用 super

一个经典例子:

java
import java.util.List;

public class CopyUtils {
    public static <T> void copy(List<? extends T> source, List<? super T> target) {
        for (T item : source) {
            target.add(item);
        }
    }
}

这段代码的含义是:

  • source 只负责提供元素,所以用 extends
  • target 只负责接收元素,所以用 super

4.7 三种写法怎么选

写法含义读取时能当成什么写入时能放什么典型场景
List<T>明确知道元素类型就是 TTT类内部、入参与返回值都明确
List<? extends T>元素类型是 T 的某个子类型T通常不能写入只读、遍历、统计
List<? super T>元素类型是 T 的某个父类型ObjectT 及其子类收集、写入、消费

5. 类型擦除到底发生了什么

5.1 一个最直观的现象

java
import java.util.ArrayList;
import java.util.List;

public class ErasureRuntimeDemo {
    public static void main(String[] args) {
        List<String> a = new ArrayList<>();
        List<Integer> b = new ArrayList<>();

        System.out.println(a.getClass() == b.getClass()); // true
    }
}

这说明:

  • List<String>List<Integer> 在运行时是同一个 ArrayList 类。
  • JVM 不会为每一种泛型参数都生成一份新类。
  • 泛型的主要检查发生在编译期,而不是靠运行时保留完整参数化类型来完成。

5.2 类型擦除的基本规则

可以把类型擦除理解成“编译器在生成字节码前,先把泛型改写成普通类型表示”。

常见规则:

  • 无边界的 T,通常擦除为 Object
  • 有上界的 T extends Number,通常擦除为 Number
  • 编译器会在需要的地方自动补充强制类型转换
  • 编译器在某些继承场景下会补充桥接方法

例如:

java
public class NumberBox<T extends Number> {
    private T value;

    public T get() {
        return value;
    }
}

从擦除的角度看,它更接近:

java
public class NumberBox {
    private Number value;

    public Number get() {
        return value;
    }
}

这不是完整源码级等价转换,但足够帮助你理解擦除后的大方向。

5.3 编译器会偷偷帮你补类型转换

java
List<String> list = new ArrayList<>();
list.add("A");
String value = list.get(0);

在概念上,编译器会把“取出并当作 String 使用”的逻辑补成类似下面的效果:

java
String value = (String) list.get(0);

这也是为什么:

  • 泛型能减少你手写强转
  • 但底层并不是 JVM 真正理解了所有泛型参数
  • 如果你绕过泛型检查,运行时仍然可能出现 ClassCastException

5.4 类型擦除不等于“运行时完全没有任何泛型痕迹”

这是一个很容易误解的点。

对象实例本身通常不会带着“我是 List<String>”这样的完整运行时类型参数,
但类、字段、方法声明上的泛型签名,仍然可能保存在字节码元数据里,供反射读取。

例如:

java
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;

class UserGroup {
    private List<String> names = new ArrayList<>();
}

public class ReflectionGenericDemo {
    public static void main(String[] args) throws Exception {
        Field field = UserGroup.class.getDeclaredField("names");
        System.out.println(field.getType().getName());
        System.out.println(field.getGenericType().getTypeName());
    }
}

你会看到类似结果:

text
java.util.List
java.util.List<java.lang.String>

这说明:

  • field.getType() 看到的是原始类型 List
  • field.getGenericType() 还能读到声明时写下的泛型签名

这也是很多框架能分析字段、方法泛型信息的原因。

5.5 类型擦除带来的直接限制

下面这些写法要么直接编译失败,要么没有你想象中的效果:

java
// new T();                  // 不能直接创建类型参数对象
// T.class;                  // 不能直接拿到 T 的 Class 对象
// if (obj instanceof List<String>) {} // 不能这样判断
// List<String>[] arr = new List<String>[10]; // 不能直接创建泛型数组

如果业务上确实需要运行时类型信息,常见做法有:

  • 显式传入 Class<T>
  • 传入更完整的类型引用对象
  • 在字段、方法、父类声明处通过反射读取泛型签名

6. 桥接方法为什么存在

桥接方法是编译器为了维持多态和重写关系自动生成的方法。
它的出现,本质上是为了让“源码里看起来能正确重写的方法”,在擦除之后依然能对得上。

看一个例子:

java
class Parent<T> {
    public T echo(T value) {
        return value;
    }
}

class StringParent extends Parent<String> {
    @Override
    public String echo(String value) {
        return "string:" + value;
    }
}

源码里看上去没问题,因为子类确实重写了父类的 echo
但擦除后:

  • 父类里的 echo(T value) 更接近 echo(Object value)
  • 子类里的 echo(String value) 仍然是 echo(String value)

此时签名已经不一致了。为了让多态还能成立,编译器会在子类里补一个桥接方法,逻辑类似:

java
public Object echo(Object value) {
    return echo((String) value);
}

你不需要手写它,但需要知道它存在,因为:

  • javap -v 看字节码时能看到它
  • 框架、反射、调试字节码问题时经常会遇到
  • 这正是“泛型源码看起来正常,但字节码层面要补救”的典型体现

7. 为什么不能直接创建泛型数组

泛型数组是高频问题,因为数组和泛型的类型机制不一样:

  • 数组在运行时知道自己的元素类型,例如 String[]
  • 泛型在大多数情况下会被擦除,例如 List<String> 运行时只剩 List

如果允许直接创建泛型数组,会把“数组的运行时类型检查”和“泛型的编译期类型检查”混在一起,容易造成类型污染。

例如下面这行代码会直接编译失败:

java
// List<String>[] array = new List<String>[10];

更推荐的做法:

  • 优先使用 List<List<String>> 代替泛型数组
  • 如果必须创建和 T 相关的数组,通常通过 Array.newInstanceClass<T> 解决

示意代码:

java
import java.lang.reflect.Array;

public class ArrayFactory {
    public static <T> T[] createArray(Class<T> type, int size) {
        @SuppressWarnings("unchecked")
        T[] array = (T[]) Array.newInstance(type, size);
        return array;
    }
}

8. 学习重点

这一章最应该真正掌握的,不是语法背诵,而是下面几个判断:

  • 泛型的主要价值在编译期,而不是运行期
  • ? extends T 重点在“安全读取”,? super T 重点在“安全写入”
  • List<Object> 不是 List<String> 的父类型
  • 类型擦除不代表所有泛型信息都完全消失,声明处签名仍可能被反射读取
  • 很多框架要求你传 Class<T>,本质上是在弥补运行时类型参数不可直接获取的问题

9. 常见问题

9.1 以为运行时还能完整拿到泛型参数

错误理解:

  • new ArrayList<String>() 创建出来的对象,运行时一定知道自己是 String

更准确的理解:

  • 对象实例通常只知道自己是 ArrayList
  • 泛型参数更多体现在源码和字节码签名层面

9.2 以为 List<Object> 能接收 List<String>

这是错误的:

java
// List<Object> list = new ArrayList<String>(); // 编译失败

原因是:

  • Java 泛型默认是不变的
  • List<String>List<Object> 是两个不同的参数化类型

9.3 只记语法,不理解边界设计意义

如果只死记:

  • extends 是上界
  • super 是下界

但不知道什么时候用,写 API 时还是会混乱。
记住“读用 extends,写用 super”会更实用。

9.4 把原始类型和泛型混用

下面这种写法会让类型检查失效:

java
List<String> list = new ArrayList<>();
List raw = list;
raw.add(123);

String value = list.get(0); // 运行时可能抛出 ClassCastException

工程里应尽量避免原始类型,除非是在兼容老代码或和底层 API 交互。

10. 动手验证

这一节可以直接操作,适合边看边练。

10.1 准备一个可运行示例

新建文件 GenericEraseDemo.java,内容如下:

java
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

class Box<T> {
    private T value;

    public void set(T value) {
        this.value = value;
    }

    public T get() {
        return value;
    }
}

class Parent<T> {
    public T echo(T value) {
        return value;
    }
}

class StringParent extends Parent<String> {
    @Override
    public String echo(String value) {
        return "string:" + value;
    }
}

class UserGroup {
    private List<String> names = new ArrayList<>();
}

public class GenericEraseDemo {
    public static <T> T first(List<T> list) {
        return list.get(0);
    }

    public static <T> void copy(List<? extends T> source, List<? super T> target) {
        for (T item : source) {
            target.add(item);
        }
    }

    public static void main(String[] args) throws Exception {
        Box<String> box = new Box<>();
        box.set("hello");
        System.out.println("box=" + box.get());

        List<String> strings = new ArrayList<>();
        strings.add("A");
        System.out.println("first=" + first(strings));

        List<Integer> source = Arrays.asList(1, 2, 3);
        List<Number> target = new ArrayList<>();
        copy(source, target);
        System.out.println("target=" + target);

        List<? extends Number> producer = source;
        System.out.println("producerRead=" + producer.get(0));
        // producer.add(4); // 取消注释后编译失败

        List<? super Integer> consumer = target;
        consumer.add(99);
        System.out.println("consumerReadAsObject=" + consumer.get(target.size() - 1));

        List<String> a = new ArrayList<>();
        List<Integer> b = new ArrayList<>();
        System.out.println("sameRuntimeClass=" + (a.getClass() == b.getClass()));

        Parent<String> parent = new StringParent();
        System.out.println("bridgeCall=" + parent.echo("bridge"));

        Field field = UserGroup.class.getDeclaredField("names");
        System.out.println("rawFieldType=" + field.getType().getName());
        System.out.println("genericFieldType=" + field.getGenericType().getTypeName());
    }
}

10.2 编译并运行

bash
javac GenericEraseDemo.java
java GenericEraseDemo

如果你在 PowerShell 中操作,也直接执行同样两条命令即可。

10.3 你应该观察到什么

输出不一定逐字完全一致,但应包含这些关键信息:

text
box=hello
first=A
target=[1, 2, 3]
producerRead=1
consumerReadAsObject=99
sameRuntimeClass=true
bridgeCall=string:bridge
rawFieldType=java.util.List
genericFieldType=java.util.List<java.lang.String>

验证结论:

  • sameRuntimeClass=true 证明不同泛型参数在运行时类相同
  • producerRead=1 说明 ? extends Number 可以安全读取
  • consumerReadAsObject=99 说明 ? super Integer 读取时只能按 Object 理解
  • rawFieldTypegenericFieldType 的对比,说明声明处泛型签名仍可能被反射读取

10.4 再做两个“故意失败”的验证

把这两处注释分别取消,再重新执行 javac

java
// producer.add(4);
// List<String>[] array = new List<String>[10];

你应该看到编译器直接报错。
这两个错误恰好对应:

  • extends 不能安全写入具体元素
  • 泛型数组不能直接创建

10.5 查看桥接方法

先编译:

bash
javac GenericEraseDemo.java

再查看 StringParent 的字节码:

bash
javap -v StringParent

你可以在输出里搜索 bridgeACC_BRIDGE
如果是在 PowerShell 里,也可以这样筛选:

powershell
javap -v StringParent | Select-String -Pattern "bridge|ACC_BRIDGE"

如果看到了桥接相关标记,说明编译器确实为子类补了桥接方法。

11. 练习建议

下面这些练习做完,基本就真正掌握了:

  • 自己实现一个 Pair<L, R> 泛型类,补上构造器、getter、toString
  • 写一个 max(List<? extends Comparable<? super T>> list) 方法,体会复杂边界的设计目的
  • 写一个 copy(List<? extends T> src, List<? super T> dst),再用 Integer -> Number -> Object 多组类型做验证
  • 用反射读取一个字段、一个方法返回值、一个父类声明上的泛型信息
  • javap -v 观察桥接方法和泛型签名

12. 自测问题

  • Java 泛型的核心价值更偏向编译期还是运行期?为什么?
  • ? extends T? super T 的适用场景分别是什么?
  • 为什么 List<Object> 不能直接接收 List<String>
  • 类型擦除之后,为什么框架有时仍然能读到字段上的泛型信息?
  • 为什么很多框架需要显式传入 Class<T> 或类型引用对象?
  • 为什么不能直接创建 new List<String>[10]
  • 桥接方法解决的本质问题是什么?

13. 自测核对要点

如果你的回答能覆盖下面这些点,基本就说明掌握到位了:

  • 泛型主要负责编译期类型检查和 API 表达能力
  • 类型擦除让 JVM 不需要为每种参数化类型生成独立运行时类
  • extends 适合读,super 适合写
  • Java 泛型是不变的,所以 List<String> 不是 List<Object> 的子类型
  • 运行时对象不一定保留完整泛型参数,但声明处签名可能保留在字节码元数据中
  • Class<T>、类型引用对象这类额外参数,本质上是在补足运行时类型信息
  • 桥接方法是编译器为维持擦除后的重写关系而自动生成的