Java 泛型全过程原理
泛型不是“尖括号里写个类型”这么简单。它真正解决的是:同一套代码如何服务多种类型,同时尽量把类型错误提前到编译期发现。集合、MyBatis Mapper、Spring Bean 获取、JSON 反序列化、RPC 响应包装、分页结果、通用工具类都大量使用泛型。
一句话先建立直觉:
泛型就是把类型变成代码的参数,让编译器帮你检查类型;Java 泛型主要在编译期生效,运行期通过类型擦除变成普通类和普通方法。
学习目标
学完这一页,你要能说清楚:
- 没有泛型为什么容易出现运行期
ClassCastException。 - 泛型类、泛型方法、泛型接口分别解决什么问题。
- Java 为什么采用类型擦除,而不是像数组那样运行时保留完整元素类型。
- 擦除后字节码大概会变成什么样。
T、?、? extends T、? super T的边界和使用场景。- PECS 原则为什么是“生产者 extends,消费者 super”。
- 为什么不能
new T(),为什么不能创建泛型数组。 - 泛型和重载、重写、桥接方法有什么关系。
- JDK 7、JDK 8 在泛型使用体验上的关键变化。
- 商业项目里怎么设计泛型 Result、Page、Repository、Converter。
没有泛型会怎样
JDK 5 以前,集合通常这样写:
List users = new ArrayList();
users.add("Tom");
users.add(100);
String name = (String) users.get(0);
String error = (String) users.get(1); // 运行时才报 ClassCastException这段代码的问题是:
- 编译器不知道
users里应该放什么类型。 - 放错元素时不报错。
- 取出元素时必须强转。
- 类型错误被推迟到运行期,线上才暴露就很危险。
使用泛型后:
List<String> users = new ArrayList<String>();
users.add("Tom");
// users.add(100); // 编译期直接报错
String name = users.get(0);泛型最大的价值不是少写强转,而是把类型错误提前到编译期。
总体流程图
flowchart TD
A["源码中写 List<String>"] --> B["编译器检查只能放 String"]
B --> C["编译器在必要位置插入强转"]
C --> D["类型擦除为 List"]
D --> E["生成普通字节码"]
E --> F["运行期对象仍然是 ArrayList"]所以要记住:源码里有 List<String>,运行期对象本身通常不知道自己只应该存 String。
泛型类
泛型类适合“这个类内部要保存某种类型,但类型由使用方决定”的场景。
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;
}
}使用:
Box<String> stringBox = new Box<String>("hello");
String value = stringBox.getValue();
Box<Integer> intBox = new Box<Integer>(100);
Integer number = intBox.getValue();JDK 7 引入菱形语法,右边可以省略泛型参数:
Box<String> stringBox = new Box<>("hello");泛型类常用于:
| 场景 | 示例 |
|---|---|
| 通用返回结果 | Result<T> |
| 分页对象 | PageResult<T> |
| 缓存容器 | CacheValue<T> |
| 仓储接口 | Repository<T, ID> |
| 树结构 | TreeNode<T> |
泛型接口
泛型接口适合“同一套行为,不同实现处理不同类型”。
public interface Converter<S, T> {
T convert(S source);
}实现:
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 的入参和返回值符合接口声明。
泛型方法
泛型方法适合“方法本身需要一个临时类型参数,但类不一定是泛型类”。
public static <T> T first(List<T> list) {
if (list == null || list.isEmpty()) {
return null;
}
return list.get(0);
}调用:
String name = first(Arrays.asList("Tom", "Jerry"));
Integer age = first(Arrays.asList(18, 20));<T> 写在返回值前面,表示声明一个方法级类型参数。后面的返回值 T、参数 List<T> 都可以使用它。
多个类型参数:
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 对目标类型推断做了增强,很多场景下编译器能根据左边变量类型、方法参数、返回值上下文推断泛型类型。
类型参数命名
常见约定:
| 名称 | 含义 |
|---|---|
T | Type,普通类型 |
E | Element,集合元素 |
K | Key,Map 的键 |
V | Value,Map 的值 |
R | Result,返回结果 |
S | Source,来源类型 |
这不是语法强制,但遵守约定能让代码更容易读。
核心原理:类型擦除
Java 泛型使用类型擦除实现。编译后,很多泛型信息会被擦掉,类型参数会被替换成它的上界;如果没有显式上界,默认上界是 Object。
源码:
public class Box<T> {
private T value;
public T getValue() {
return value;
}
}擦除后可以近似理解为:
public class Box {
private Object value;
public Object getValue() {
return value;
}
}调用处:
Box<String> box = new Box<>("hello");
String value = box.getValue();编译器会近似处理成:
Box box = new Box("hello");
String value = (String) box.getValue();这解释了两个现象:
- 泛型能在编译期检查类型。
- 运行期很多地方看不到完整泛型类型。
有上界的擦除
public class NumberBox<T extends Number> {
private T value;
public double doubleValue() {
return value.doubleValue();
}
}因为 T extends Number,擦除后 T 不是变成 Object,而是变成 Number:
public class NumberBox {
private Number value;
public double doubleValue() {
return value.doubleValue();
}
}这就是为什么设置上界后,可以直接调用上界类型的方法。
为什么 Java 要用类型擦除
泛型是 JDK 5 引入的。当时 Java 已经有大量旧代码和旧字节码,如果泛型完全改变运行时类型系统,会导致兼容性问题。
类型擦除的核心目标是兼容旧代码:
List<String> names = new ArrayList<String>();
List raw = names;
raw.add(100); // 编译器会警告,但为了兼容旧代码允许擦除让泛型代码和旧的原始类型代码可以共存。
代价是:
- 运行时无法直接区分
List<String>和List<Integer>。 - 不能直接
new T()。 - 不能创建真正类型安全的泛型数组。
- 某些反射和序列化场景需要额外传入
Class<T>或TypeReference<T>。
泛型和数组的区别
数组是运行时保留元素类型的:
String[] names = new String[2];
Object[] objects = names;
objects[0] = 100; // 运行时报 ArrayStoreException数组运行时知道自己是 String[],所以能阻止放入 Integer。
泛型集合运行时通常不知道完整元素类型:
List<String> names = new ArrayList<String>();
List raw = names;
raw.add(100); // 运行期可以放进去这就是为什么“泛型主要是编译期安全”,不是运行期绝对安全。
为什么不能 new T()
错误写法:
public class Factory<T> {
public T create() {
return new T(); // 编译失败
}
}原因是类型擦除后,运行时不知道 T 到底是什么类。它可能是 User,也可能是 Order,还可能没有无参构造。
常见解决方式是传入 Class<T>:
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);
}
}
}使用:
Factory<User> factory = new Factory<User>(User.class);
User user = factory.create();这也是很多框架 API 需要你传 Class<T> 的原因:
User user = objectMapper.readValue(json, User.class);为什么不能创建泛型数组
错误写法:
List<String>[] array = new List<String>[10]; // 编译失败原因是数组运行时要知道元素类型,但泛型运行时被擦除。如果允许创建泛型数组,就可能破坏类型安全。
可以创建原始数组再转换,但会有警告:
@SuppressWarnings("unchecked")
List<String>[] array = (List<String>[]) new List[10];业务开发中一般避免泛型数组,优先使用集合:
List<List<String>> list = new ArrayList<List<String>>();通配符 ?
? 表示未知类型。
public static void printAll(List<?> list) {
for (Object item : list) {
System.out.println(item);
}
}List<?> 可以接收 List<String>、List<Integer>、List<User>。
但是你不能往里面随便加元素:
public static void add(List<?> list) {
// list.add("hello"); // 编译失败
list.add(null); // 只有 null 可以
}因为编译器不知道这个列表真实元素类型是什么。如果它实际是 List<Integer>,你往里放 String 就错了。
上界通配符 ? extends T
public static double sum(List<? extends Number> numbers) {
double total = 0;
for (Number number : numbers) {
total += number.doubleValue();
}
return total;
}这个方法可以接收:
List<Integer> integers = Arrays.asList(1, 2, 3);
List<Double> doubles = Arrays.asList(1.1, 2.2);
sum(integers);
sum(doubles);? extends Number 的含义是:列表元素类型是 Number 或 Number 的某个子类。
它适合读取:
Number number = numbers.get(0);但不适合写入:
// numbers.add(1); // 编译失败
// numbers.add(1.0); // 编译失败为什么不能写?因为实际传入的可能是 List<Integer>,也可能是 List<Double>。编译器不知道具体是哪一种,所以不能允许你往里放任意 Number 子类。
下界通配符 ? super T
public static void addIntegers(List<? super Integer> list) {
list.add(1);
list.add(2);
}这个方法可以接收:
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 的含义是:列表元素类型是 Integer 或 Integer 的某个父类。
它适合写入 Integer:
list.add(100);但读取时只能安全地当成 Object:
Object value = list.get(0);因为实际列表可能是 List<Object>,也可能是 List<Number>,编译器不能保证读出来一定是 Integer。
PECS 原则
PECS 是 Joshua Bloch 在 Effective Java 里总结的泛型使用原则:
Producer Extends, Consumer Super.
如果参数是数据生产者,只从里面读,用 extends;如果参数是数据消费者,要往里面写,用 super。
flowchart TD
A["方法参数是数据来源"] --> B["只读取元素"]
B --> C["使用 ? extends T"]
D["方法参数是数据目标"] --> E["需要写入元素"]
E --> F["使用 ? super T"]例子:从一个集合复制到另一个集合。
public static <T> void copy(List<? extends T> src, List<? super T> dest) {
for (T item : src) {
dest.add(item);
}
}src 负责生产数据,所以是 ? extends T。dest 负责消费数据,所以是 ? super T。
泛型不变性
很多人会误以为:
Integer extends Number所以:
List<Integer> extends List<Number>这是错的。Java 泛型默认是不变的。
下面代码不能编译:
List<Integer> integers = new ArrayList<Integer>();
// List<Number> numbers = integers;如果允许这样做,就会破坏类型安全:
List<Integer> integers = new ArrayList<Integer>();
List<Number> numbers = integers; // 假设允许
numbers.add(1.5); // 往 Integer 列表放 Double
Integer value = integers.get(0); // 类型崩了所以需要 ? extends Number 来表达“可以接收 Number 子类列表”。
泛型和重载
由于类型擦除,下面两个方法不能同时存在:
public void handle(List<String> list) {
}
public void handle(List<Integer> list) {
}擦除后都变成:
public void handle(List list) {
}方法签名冲突,所以编译失败。
这类问题在 Controller、RPC 接口、工具类设计里都要注意,不要试图只靠泛型参数做重载区分。
泛型和桥接方法
泛型擦除后,还要保证 Java 的多态语义不变。编译器有时会生成桥接方法。
源码:
class Parent<T> {
public T get() {
return null;
}
}
class Child extends Parent<String> {
@Override
public String get() {
return "child";
}
}擦除后,父类方法近似是:
public Object get()子类方法是:
public String get()为了保证通过父类引用调用时仍然能走到子类方法,编译器会生成桥接方法,近似理解为:
public Object get() {
return this.get();
}实际字节码里桥接方法带有 ACC_BRIDGE、ACC_SYNTHETIC 标记。普通开发不用手写桥接方法,但理解它能解释为什么反射查看方法时可能看到额外的 synthetic/bridge 方法。
泛型和反射
运行时对象本身泛型信息常被擦除:
List<String> names = new ArrayList<String>();
System.out.println(names.getClass()); // class java.util.ArrayList但声明位置上的泛型签名可以通过反射读取:
public class UserService {
private List<String> names;
}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 10 | var 局部变量推断 | 不是泛型本身变化,但会影响泛型代码可读性 |
学习和面试最重要的是 JDK 7、JDK 8:
- JDK 7 的菱形语法减少重复类型声明。
- JDK 8 的 Lambda、Stream、函数式接口和泛型结合非常紧密。
- JDK 8 类型推断增强后,很多泛型方法调用不需要显式写类型参数。
Demo 1:通用接口返回 Result
商业项目里接口通常不会直接返回业务对象,而是统一包装状态码、消息和数据。
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;
}
}使用:
Result<UserDTO> result = Result.success(new UserDTO(1L, "Tom"));
UserDTO user = result.getData();如果没有泛型,getData() 只能返回 Object,调用方到处强转,容易出错。
Demo 2:分页结果 PageResult
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);
}
}配合转换器:
public interface Converter<S, T> {
T convert(S source);
}使用:
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 简化:
PageResult<UserDTO> dtoPage = entityPage.map(source -> new UserDTO(source.getId(), source.getName()));这就是泛型、接口、JDK 8 Lambda 结合后的商业项目写法。
Demo 3:通用 Repository
public interface Repository<T, ID> {
T findById(ID id);
void save(T entity);
}用户仓储:
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());
}
}订单仓储:
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 统一返回
Result<UserDTO>
Result<List<OrderDTO>>
Result<PageResult<ProductDTO>>泛型让接口返回结构统一,但 data 类型保持明确。
批量转换
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 都可以复用同一个工具方法。
领域基础类
public abstract class BaseService<T, ID> {
public abstract T getById(ID id);
}通用服务层可以用泛型抽象公共能力,但不要为了抽象而抽象。如果每个业务服务差异很大,强行抽通用泛型基类会让代码更难读。
常见坑
使用原始类型
List list = new ArrayList<String>();
list.add(100);这会绕过泛型检查。生产代码尽量不要使用原始类型,除非兼容非常旧的 API。
滥用 Object
public Object getData() {
return data;
}如果本来可以用 T 表达类型,就不要退化成 Object。否则调用方必须强转,类型错误会延后到运行期。
List<Object> 不能接收 List<String>
public void handle(List<Object> list) {
}
List<String> names = new ArrayList<String>();
// handle(names); // 编译失败如果只是读取,应该写:
public void handle(List<?> list) {
}或者:
public void handle(List<? extends Object> list) {
}JSON 反序列化丢泛型
错误思路:
Result<List<UserDTO>> result = objectMapper.readValue(json, Result.class);这样只能得到原始 Result,内部 List 元素类型可能变成 Map。通常要用框架提供的 TypeReference:
Result<List<UserDTO>> result = objectMapper.readValue(
json,
new TypeReference<Result<List<UserDTO>>>() {}
);原理是匿名子类把泛型信息保留在父类泛型签名里,框架再通过反射读取。
过度泛型化
泛型不是越多越好。下面这种代码可读性很差:
public class Processor<A, B, C, D, E> {
}如果类型参数没有明确业务含义,或者只是为了“看起来通用”,就容易让维护者看不懂。商业项目里要优先保证清晰。
线上排查流程
flowchart TD
A["泛型相关问题"] --> B["ClassCastException"]
A --> C["JSON反序列化类型不对"]
A --> D["编译期泛型不匹配"]
A --> E["反射读取不到泛型"]
B --> F["检查是否使用原始类型或强转"]
C --> G["检查是否传入 TypeReference 或完整 Type"]
D --> H["检查 extends/super 边界和泛型不变性"]
E --> I["检查泛型是否声明在字段、方法、父类签名上"]排查建议:
- 出现
ClassCastException,先查是否用了原始类型List、Map,或者接口返回了Object。 - JSON 反序列化内部元素变成
LinkedHashMap,重点查有没有传完整泛型类型。 List<String>不能传给List<Object>,不要硬转,改用List<?>或合理的上下界。- 反射读取泛型时,要区分对象运行时类型和声明位置泛型签名。
- 批量工具类如果泛型参数太复杂,优先补单元测试覆盖不同类型输入。
面试标准回答
泛型是什么
泛型是 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> 类型安全集合。设计时要让类型参数有明确含义,不要过度抽象。
