Skip to content

Java 泛型全过程原理

泛型不是“尖括号里写个类型”这么简单。它真正解决的是:同一套代码如何服务多种类型,同时尽量把类型错误提前到编译期发现。集合、MyBatis Mapper、Spring Bean 获取、JSON 反序列化、RPC 响应包装、分页结果、通用工具类都大量使用泛型。

一句话先建立直觉:

泛型就是把类型变成代码的参数,让编译器帮你检查类型;Java 泛型主要在编译期生效,运行期通过类型擦除变成普通类和普通方法。

学习目标

学完这一页,你要能说清楚:

  1. 没有泛型为什么容易出现运行期 ClassCastException
  2. 泛型类、泛型方法、泛型接口分别解决什么问题。
  3. Java 为什么采用类型擦除,而不是像数组那样运行时保留完整元素类型。
  4. 擦除后字节码大概会变成什么样。
  5. T?? extends T? super T 的边界和使用场景。
  6. PECS 原则为什么是“生产者 extends,消费者 super”。
  7. 为什么不能 new T(),为什么不能创建泛型数组。
  8. 泛型和重载、重写、桥接方法有什么关系。
  9. JDK 7、JDK 8 在泛型使用体验上的关键变化。
  10. 商业项目里怎么设计泛型 Result、Page、Repository、Converter。

没有泛型会怎样

JDK 5 以前,集合通常这样写:

java
List users = new ArrayList();
users.add("Tom");
users.add(100);

String name = (String) users.get(0);
String error = (String) users.get(1); // 运行时才报 ClassCastException

这段代码的问题是:

  1. 编译器不知道 users 里应该放什么类型。
  2. 放错元素时不报错。
  3. 取出元素时必须强转。
  4. 类型错误被推迟到运行期,线上才暴露就很危险。

使用泛型后:

java
List<String> users = new ArrayList<String>();
users.add("Tom");
// users.add(100); // 编译期直接报错

String name = users.get(0);

泛型最大的价值不是少写强转,而是把类型错误提前到编译期。

总体流程图

mermaid
flowchart TD
    A["源码中写 List<String>"] --> B["编译器检查只能放 String"]
    B --> C["编译器在必要位置插入强转"]
    C --> D["类型擦除为 List"]
    D --> E["生成普通字节码"]
    E --> F["运行期对象仍然是 ArrayList"]

所以要记住:源码里有 List<String>,运行期对象本身通常不知道自己只应该存 String

泛型类

泛型类适合“这个类内部要保存某种类型,但类型由使用方决定”的场景。

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

    public Box(T value) {
        this.value = value;
    }

    public T getValue() {
        return value;
    }

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

使用:

java
Box<String> stringBox = new Box<String>("hello");
String value = stringBox.getValue();

Box<Integer> intBox = new Box<Integer>(100);
Integer number = intBox.getValue();

JDK 7 引入菱形语法,右边可以省略泛型参数:

java
Box<String> stringBox = new Box<>("hello");

泛型类常用于:

场景示例
通用返回结果Result<T>
分页对象PageResult<T>
缓存容器CacheValue<T>
仓储接口Repository<T, ID>
树结构TreeNode<T>

泛型接口

泛型接口适合“同一套行为,不同实现处理不同类型”。

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

实现:

java
public class UserConverter implements Converter<UserEntity, UserDTO> {
    @Override
    public UserDTO convert(UserEntity source) {
        UserDTO dto = new UserDTO();
        dto.setId(source.getId());
        dto.setName(source.getName());
        return dto;
    }
}

这里 S 是来源类型,T 是目标类型。编译器会保证 UserConverter 的入参和返回值符合接口声明。

泛型方法

泛型方法适合“方法本身需要一个临时类型参数,但类不一定是泛型类”。

java
public static <T> T first(List<T> list) {
    if (list == null || list.isEmpty()) {
        return null;
    }
    return list.get(0);
}

调用:

java
String name = first(Arrays.asList("Tom", "Jerry"));
Integer age = first(Arrays.asList(18, 20));

<T> 写在返回值前面,表示声明一个方法级类型参数。后面的返回值 T、参数 List<T> 都可以使用它。

多个类型参数:

java
public static <K, V> Map<K, V> of(K key, V value) {
    Map<K, V> map = new HashMap<K, V>();
    map.put(key, value);
    return map;
}

JDK 8 对目标类型推断做了增强,很多场景下编译器能根据左边变量类型、方法参数、返回值上下文推断泛型类型。

类型参数命名

常见约定:

名称含义
TType,普通类型
EElement,集合元素
KKey,Map 的键
VValue,Map 的值
RResult,返回结果
SSource,来源类型

这不是语法强制,但遵守约定能让代码更容易读。

核心原理:类型擦除

Java 泛型使用类型擦除实现。编译后,很多泛型信息会被擦掉,类型参数会被替换成它的上界;如果没有显式上界,默认上界是 Object

源码:

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

    public T getValue() {
        return value;
    }
}

擦除后可以近似理解为:

java
public class Box {
    private Object value;

    public Object getValue() {
        return value;
    }
}

调用处:

java
Box<String> box = new Box<>("hello");
String value = box.getValue();

编译器会近似处理成:

java
Box box = new Box("hello");
String value = (String) box.getValue();

这解释了两个现象:

  1. 泛型能在编译期检查类型。
  2. 运行期很多地方看不到完整泛型类型。

有上界的擦除

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

    public double doubleValue() {
        return value.doubleValue();
    }
}

因为 T extends Number,擦除后 T 不是变成 Object,而是变成 Number

java
public class NumberBox {
    private Number value;

    public double doubleValue() {
        return value.doubleValue();
    }
}

这就是为什么设置上界后,可以直接调用上界类型的方法。

为什么 Java 要用类型擦除

泛型是 JDK 5 引入的。当时 Java 已经有大量旧代码和旧字节码,如果泛型完全改变运行时类型系统,会导致兼容性问题。

类型擦除的核心目标是兼容旧代码:

java
List<String> names = new ArrayList<String>();
List raw = names;
raw.add(100); // 编译器会警告,但为了兼容旧代码允许

擦除让泛型代码和旧的原始类型代码可以共存。

代价是:

  1. 运行时无法直接区分 List<String>List<Integer>
  2. 不能直接 new T()
  3. 不能创建真正类型安全的泛型数组。
  4. 某些反射和序列化场景需要额外传入 Class<T>TypeReference<T>

泛型和数组的区别

数组是运行时保留元素类型的:

java
String[] names = new String[2];
Object[] objects = names;
objects[0] = 100; // 运行时报 ArrayStoreException

数组运行时知道自己是 String[],所以能阻止放入 Integer

泛型集合运行时通常不知道完整元素类型:

java
List<String> names = new ArrayList<String>();
List raw = names;
raw.add(100); // 运行期可以放进去

这就是为什么“泛型主要是编译期安全”,不是运行期绝对安全。

为什么不能 new T()

错误写法:

java
public class Factory<T> {
    public T create() {
        return new T(); // 编译失败
    }
}

原因是类型擦除后,运行时不知道 T 到底是什么类。它可能是 User,也可能是 Order,还可能没有无参构造。

常见解决方式是传入 Class<T>

java
public class Factory<T> {
    private final Class<T> type;

    public Factory(Class<T> type) {
        this.type = type;
    }

    public T create() {
        try {
            return type.getDeclaredConstructor().newInstance();
        } catch (Exception e) {
            throw new IllegalStateException("create instance failed: " + type.getName(), e);
        }
    }
}

使用:

java
Factory<User> factory = new Factory<User>(User.class);
User user = factory.create();

这也是很多框架 API 需要你传 Class<T> 的原因:

java
User user = objectMapper.readValue(json, User.class);

为什么不能创建泛型数组

错误写法:

java
List<String>[] array = new List<String>[10]; // 编译失败

原因是数组运行时要知道元素类型,但泛型运行时被擦除。如果允许创建泛型数组,就可能破坏类型安全。

可以创建原始数组再转换,但会有警告:

java
@SuppressWarnings("unchecked")
List<String>[] array = (List<String>[]) new List[10];

业务开发中一般避免泛型数组,优先使用集合:

java
List<List<String>> list = new ArrayList<List<String>>();

通配符 ?

? 表示未知类型。

java
public static void printAll(List<?> list) {
    for (Object item : list) {
        System.out.println(item);
    }
}

List<?> 可以接收 List<String>List<Integer>List<User>

但是你不能往里面随便加元素:

java
public static void add(List<?> list) {
    // list.add("hello"); // 编译失败
    list.add(null); // 只有 null 可以
}

因为编译器不知道这个列表真实元素类型是什么。如果它实际是 List<Integer>,你往里放 String 就错了。

上界通配符 ? extends T

java
public static double sum(List<? extends Number> numbers) {
    double total = 0;
    for (Number number : numbers) {
        total += number.doubleValue();
    }
    return total;
}

这个方法可以接收:

java
List<Integer> integers = Arrays.asList(1, 2, 3);
List<Double> doubles = Arrays.asList(1.1, 2.2);

sum(integers);
sum(doubles);

? extends Number 的含义是:列表元素类型是 NumberNumber 的某个子类。

它适合读取:

java
Number number = numbers.get(0);

但不适合写入:

java
// numbers.add(1);   // 编译失败
// numbers.add(1.0); // 编译失败

为什么不能写?因为实际传入的可能是 List<Integer>,也可能是 List<Double>。编译器不知道具体是哪一种,所以不能允许你往里放任意 Number 子类。

下界通配符 ? super T

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

这个方法可以接收:

java
List<Integer> integers = new ArrayList<Integer>();
List<Number> numbers = new ArrayList<Number>();
List<Object> objects = new ArrayList<Object>();

addIntegers(integers);
addIntegers(numbers);
addIntegers(objects);

? super Integer 的含义是:列表元素类型是 IntegerInteger 的某个父类。

它适合写入 Integer

java
list.add(100);

但读取时只能安全地当成 Object

java
Object value = list.get(0);

因为实际列表可能是 List<Object>,也可能是 List<Number>,编译器不能保证读出来一定是 Integer

PECS 原则

PECS 是 Joshua Bloch 在 Effective Java 里总结的泛型使用原则:

Producer Extends, Consumer Super.

如果参数是数据生产者,只从里面读,用 extends;如果参数是数据消费者,要往里面写,用 super

mermaid
flowchart TD
    A["方法参数是数据来源"] --> B["只读取元素"]
    B --> C["使用 ? extends T"]
    D["方法参数是数据目标"] --> E["需要写入元素"]
    E --> F["使用 ? super T"]

例子:从一个集合复制到另一个集合。

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

src 负责生产数据,所以是 ? extends Tdest 负责消费数据,所以是 ? super T

泛型不变性

很多人会误以为:

java
Integer extends Number

所以:

java
List<Integer> extends List<Number>

这是错的。Java 泛型默认是不变的。

下面代码不能编译:

java
List<Integer> integers = new ArrayList<Integer>();
// List<Number> numbers = integers;

如果允许这样做,就会破坏类型安全:

java
List<Integer> integers = new ArrayList<Integer>();
List<Number> numbers = integers; // 假设允许
numbers.add(1.5);                // 往 Integer 列表放 Double
Integer value = integers.get(0); // 类型崩了

所以需要 ? extends Number 来表达“可以接收 Number 子类列表”。

泛型和重载

由于类型擦除,下面两个方法不能同时存在:

java
public void handle(List<String> list) {
}

public void handle(List<Integer> list) {
}

擦除后都变成:

java
public void handle(List list) {
}

方法签名冲突,所以编译失败。

这类问题在 Controller、RPC 接口、工具类设计里都要注意,不要试图只靠泛型参数做重载区分。

泛型和桥接方法

泛型擦除后,还要保证 Java 的多态语义不变。编译器有时会生成桥接方法。

源码:

java
class Parent<T> {
    public T get() {
        return null;
    }
}

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

擦除后,父类方法近似是:

java
public Object get()

子类方法是:

java
public String get()

为了保证通过父类引用调用时仍然能走到子类方法,编译器会生成桥接方法,近似理解为:

java
public Object get() {
    return this.get();
}

实际字节码里桥接方法带有 ACC_BRIDGEACC_SYNTHETIC 标记。普通开发不用手写桥接方法,但理解它能解释为什么反射查看方法时可能看到额外的 synthetic/bridge 方法。

泛型和反射

运行时对象本身泛型信息常被擦除:

java
List<String> names = new ArrayList<String>();
System.out.println(names.getClass()); // class java.util.ArrayList

但声明位置上的泛型签名可以通过反射读取:

java
public class UserService {
    private List<String> names;
}
java
Field field = UserService.class.getDeclaredField("names");
Type type = field.getGenericType();
if (type instanceof ParameterizedType) {
    ParameterizedType pt = (ParameterizedType) type;
    System.out.println(pt.getActualTypeArguments()[0]);
}

Spring、Jackson、MyBatis 在复杂泛型场景下,经常需要通过字段、方法参数、返回值、父类泛型签名读取类型信息。

JDK 版本差异

版本泛型相关变化说明
JDK 5引入泛型集合从原始类型升级为类型安全集合
JDK 7菱形语法new ArrayList<String>() 可写成 new ArrayList<>()
JDK 8目标类型推断增强Lambda、方法引用、泛型方法推断更好用
JDK 10var 局部变量推断不是泛型本身变化,但会影响泛型代码可读性

学习和面试最重要的是 JDK 7、JDK 8:

  1. JDK 7 的菱形语法减少重复类型声明。
  2. JDK 8 的 Lambda、Stream、函数式接口和泛型结合非常紧密。
  3. JDK 8 类型推断增强后,很多泛型方法调用不需要显式写类型参数。

Demo 1:通用接口返回 Result

商业项目里接口通常不会直接返回业务对象,而是统一包装状态码、消息和数据。

java
public class Result<T> {
    private final int code;
    private final String message;
    private final T data;

    private Result(int code, String message, T data) {
        this.code = code;
        this.message = message;
        this.data = data;
    }

    public static <T> Result<T> success(T data) {
        return new Result<T>(200, "success", data);
    }

    public static <T> Result<T> fail(String message) {
        return new Result<T>(500, message, null);
    }

    public T getData() {
        return data;
    }
}

使用:

java
Result<UserDTO> result = Result.success(new UserDTO(1L, "Tom"));
UserDTO user = result.getData();

如果没有泛型,getData() 只能返回 Object,调用方到处强转,容易出错。

Demo 2:分页结果 PageResult

java
public class PageResult<T> {
    private final long total;
    private final List<T> records;

    public PageResult(long total, List<T> records) {
        this.total = total;
        this.records = records;
    }

    public long getTotal() {
        return total;
    }

    public List<T> getRecords() {
        return records;
    }

    public <R> PageResult<R> map(Converter<T, R> converter) {
        List<R> target = new ArrayList<R>();
        for (T item : records) {
            target.add(converter.convert(item));
        }
        return new PageResult<R>(total, target);
    }
}

配合转换器:

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

使用:

java
PageResult<UserEntity> entityPage = userRepository.queryPage();
PageResult<UserDTO> dtoPage = entityPage.map(new Converter<UserEntity, UserDTO>() {
    @Override
    public UserDTO convert(UserEntity source) {
        return new UserDTO(source.getId(), source.getName());
    }
});

JDK 8 可以用 Lambda 简化:

java
PageResult<UserDTO> dtoPage = entityPage.map(source -> new UserDTO(source.getId(), source.getName()));

这就是泛型、接口、JDK 8 Lambda 结合后的商业项目写法。

Demo 3:通用 Repository

java
public interface Repository<T, ID> {
    T findById(ID id);

    void save(T entity);
}

用户仓储:

java
public class UserRepository implements Repository<User, Long> {
    @Override
    public User findById(Long id) {
        return new User(id, "Tom");
    }

    @Override
    public void save(User entity) {
        System.out.println("save user: " + entity.getName());
    }
}

订单仓储:

java
public class OrderRepository implements Repository<Order, String> {
    @Override
    public Order findById(String orderNo) {
        return new Order(orderNo);
    }

    @Override
    public void save(Order entity) {
        System.out.println("save order: " + entity.getOrderNo());
    }
}

泛型让 Repository 的规则统一,同时每个实现又保持自己的实体类型和主键类型。

商业场景

API 统一返回

java
Result<UserDTO>
Result<List<OrderDTO>>
Result<PageResult<ProductDTO>>

泛型让接口返回结构统一,但 data 类型保持明确。

批量转换

java
public static <S, T> List<T> map(List<S> source, Converter<S, T> converter) {
    List<T> result = new ArrayList<T>();
    for (S item : source) {
        result.add(converter.convert(item));
    }
    return result;
}

订单实体转 DTO、患者实体转 DTO、商品实体转 DTO 都可以复用同一个工具方法。

领域基础类

java
public abstract class BaseService<T, ID> {
    public abstract T getById(ID id);
}

通用服务层可以用泛型抽象公共能力,但不要为了抽象而抽象。如果每个业务服务差异很大,强行抽通用泛型基类会让代码更难读。

常见坑

使用原始类型

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

这会绕过泛型检查。生产代码尽量不要使用原始类型,除非兼容非常旧的 API。

滥用 Object

java
public Object getData() {
    return data;
}

如果本来可以用 T 表达类型,就不要退化成 Object。否则调用方必须强转,类型错误会延后到运行期。

List<Object> 不能接收 List<String>

java
public void handle(List<Object> list) {
}

List<String> names = new ArrayList<String>();
// handle(names); // 编译失败

如果只是读取,应该写:

java
public void handle(List<?> list) {
}

或者:

java
public void handle(List<? extends Object> list) {
}

JSON 反序列化丢泛型

错误思路:

java
Result<List<UserDTO>> result = objectMapper.readValue(json, Result.class);

这样只能得到原始 Result,内部 List 元素类型可能变成 Map。通常要用框架提供的 TypeReference

java
Result<List<UserDTO>> result = objectMapper.readValue(
        json,
        new TypeReference<Result<List<UserDTO>>>() {}
);

原理是匿名子类把泛型信息保留在父类泛型签名里,框架再通过反射读取。

过度泛型化

泛型不是越多越好。下面这种代码可读性很差:

java
public class Processor<A, B, C, D, E> {
}

如果类型参数没有明确业务含义,或者只是为了“看起来通用”,就容易让维护者看不懂。商业项目里要优先保证清晰。

线上排查流程

mermaid
flowchart TD
    A["泛型相关问题"] --> B["ClassCastException"]
    A --> C["JSON反序列化类型不对"]
    A --> D["编译期泛型不匹配"]
    A --> E["反射读取不到泛型"]
    B --> F["检查是否使用原始类型或强转"]
    C --> G["检查是否传入 TypeReference 或完整 Type"]
    D --> H["检查 extends/super 边界和泛型不变性"]
    E --> I["检查泛型是否声明在字段、方法、父类签名上"]

排查建议:

  1. 出现 ClassCastException,先查是否用了原始类型 ListMap,或者接口返回了 Object
  2. JSON 反序列化内部元素变成 LinkedHashMap,重点查有没有传完整泛型类型。
  3. List<String> 不能传给 List<Object>,不要硬转,改用 List<?> 或合理的上下界。
  4. 反射读取泛型时,要区分对象运行时类型和声明位置泛型签名。
  5. 批量工具类如果泛型参数太复杂,优先补单元测试覆盖不同类型输入。

面试标准回答

泛型是什么

泛型是 Java 提供的类型参数机制,可以让类、接口、方法在定义时不写死具体类型,而由使用方传入类型。它的核心价值是编译期类型检查,减少强制类型转换,把类型错误尽早暴露。

Java 泛型的实现原理

Java 泛型主要通过类型擦除实现。编译器会在编译期检查泛型类型,之后把类型参数擦除为上界,没有显式上界时擦除为 Object,并在必要位置插入强制类型转换。所以运行期很多对象并不知道完整泛型类型。

? extends T? super T 区别

? extends T 表示某个未知类型是 T 的子类,适合读取,不适合写入;? super T 表示某个未知类型是 T 的父类,适合写入 T 或其子类,读取时通常只能当成 Object。总结就是 PECS:生产者用 extends,消费者用 super。

为什么不能 new T()

因为 Java 泛型会类型擦除,运行时不知道 T 到底代表哪个具体类,也不知道它有没有无参构造。需要创建对象时,通常显式传入 Class<T>,再通过反射创建。

为什么 List<String> 不能赋值给 List<Object>

因为泛型默认不变。如果允许赋值,就可以通过 List<Object> 往真实的 List<String> 里放入 Integer、Double 等对象,破坏类型安全。需要接收多种泛型列表时,应使用 List<?>List<? extends Object>

泛型为什么不能用于重载区分

因为类型擦除后,List<String>List<Integer> 在方法签名层面都会变成 List,两个方法擦除后签名相同,会发生冲突,所以不能只靠泛型参数不同来重载。

泛型在项目中怎么用

常见用法包括 Result<T> 统一接口返回、PageResult<T> 分页结果、Repository<T, ID> 通用仓储、Converter<S, T> 对象转换器、List<T> 类型安全集合。设计时要让类型参数有明确含义,不要过度抽象。

关联知识点