什么是 Java 泛型?为什么要使用它?
一则或许对你有用的小广告
欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论
- 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于
Spring AI + Spring Boot3.x + JDK 21..., 点击查看; - 《从零手撸:仿小红书(微服务架构)》 已完结,基于
Spring Cloud Alibaba + Spring Boot3.x + JDK 17..., 点击查看项目介绍; 演示链接: http://116.62.199.48:7070/; - 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/
面试考察点
-
基础概念掌握:面试官不仅仅是想知道泛型是什么,更是想知道你是否理解泛型的本质——参数化类型,以及它在编译期和运行期的不同表现。
-
类型安全意识:考察你是否清楚泛型如何解决类型安全问题,能否对比 "泛型前" 和 "泛型后" 的代码差异,理解为什么
ClassCastException从运行期提前到了编译期。 -
底层原理深度:考察你是否了解 类型擦除 机制,能否解释为什么
List<String>和List<Integer>在运行时是同一个类,以及泛型与 Java 向后兼容的设计权衡。
核心答案
Java 泛型(Generics) 是 JDK 5 引入的特性,允许在定义类、接口和方法时使用 类型参数,将类型从 "具体" 变成 "参数化",从而在编译期进行类型检查。
| 泛型核心价值 | 说明 |
|---|---|
| 类型安全 | 编译期检查类型,将运行时异常提前到编译期 |
| 消除强转 | 无需手动类型转换,代码更简洁 |
| 代码复用 | 一套代码适用于多种类型 |
| 设计优雅 | 泛型集合、泛型方法提升 API 设计质量 |
为什么要使用泛型?
| 使用泛型前 | 使用泛型后 |
|---|---|
List list = new ArrayList();list.add("hello");String s = (String) list.get(0); // 需要强转 | List<String> list = new ArrayList<>();list.add("hello");String s = list.get(0); // 无需强转 |
list.add(123); // 编译通过,运行时报错 | list.add(123); // 编译直接报错 |
一句话总结:泛型通过编译期类型检查,保证了类型安全,消除了强制类型转换,让代码更健壮、更优雅。
深度解析
一、泛型的本质:参数化类型
上图展示了泛型的核心思想——类型参数化。传统方式需要为每种类型单独定义一个类,导致大量重复代码;而泛型将类型作为参数传入,实现了一套代码适用于多种类型。
- 类型参数:如
<E>、<T>、<K, V>等,在定义时是占位符,在使用时被具体类型替换。 - 泛型类:类名后跟类型参数,如
ArrayList<E>。 - 泛型接口:接口名后跟类型参数,如
Comparable<T>。 - 泛型方法:方法返回值前声明类型参数,如
<T> T getFirst(List<T> list)。
二、泛型的三种使用方式
1. 泛型类
// 自定义泛型类
public class Box<T> {
private T content;
public void set(T content) {
this.content = content;
}
public T get() {
return content;
}
}
// 使用
Box<String> stringBox = new Box<>();
stringBox.set("Hello");
String value = stringBox.get(); // 无需强转
Box<Integer> intBox = new Box<>();
intBox.set(123);
Integer num = intBox.get();
2. 泛型接口
// 泛型接口
public interface Generator<T> {
T generate();
}
// 实现方式一:指定具体类型
public class StringGenerator implements Generator<String> {
@Override
public String generate() {
return "Hello";
}
}
// 实现方式二:保留泛型
public class GenericGenerator<T> implements Generator<T> {
@Override
public T generate() {
return null;
}
}
3. 泛型方法
public class GenericMethodDemo {
// 泛型方法:<T> 声明在返回值前
public static <T> T getFirst(List<T> list) {
if (list == null || list.isEmpty()) {
return null;
}
return list.get(0);
}
// 多个类型参数
public static <K, V> void printPair(K key, V value) {
System.out.println(key + " = " + value);
}
// 使用
public static void main(String[] args) {
List<String> names = Arrays.asList("张三", "李四", "王五");
String first = getFirst(names); // 编译器自动推断类型
printPair("name", "张三");
printPair(1, 100);
}
}
三、泛型的核心原理:类型擦除
上图展示了 Java 泛型的核心机制——类型擦除。这是理解泛型的关键。
-
擦除时机:编译期间,编译器将泛型类型替换为原始类型(Raw Type)或上界。
-
擦除规则:
- 无限界类型参数
<T>擦除为Object - 有上界
<T extends Number>擦除为Number - 多个上界
<T extends Serializable & Cloneable>擦除为第一个上界
- 无限界类型参数
-
运行时表现:
List<String> strings = new ArrayList<>(); List<Integer> integers = new ArrayList<>(); // 运行时返回 true!(类型信息被擦除) System.out.println(strings.getClass() == integers.getClass());
类型擦除的代码对比:
// 源代码
public class Box<T> {
private T value;
public T get() { return value; }
public void set(T value) { this.value = value; }
}
// 编译后(反编译结果)
public class Box {
private Object value; // T → Object
public Object get() { return value; }
public void set(Object value) { this.value = value; }
}
四、通配符详解
上图对比了三种通配符的特点和使用场景,核心原则是 PECS(Producer Extends, Consumer Super)。
PECS 原则详解:
public class PECSDemo {
// Producer Extends:从集合读取数据(生产者)
public static double sum(List<? extends Number> list) {
double total = 0;
for (Number num : list) { // 可以安全读取为 Number
total += num.doubleValue();
}
// list.add(123); // 编译错误!不能写
return total;
}
// Consumer Super:向集合写入数据(消费者)
public static void addNumbers(List<? super Integer> list) {
list.add(1); // 可以安全写入 Integer
list.add(2);
list.add(3);
// Integer i = list.get(0); // 编译错误!不能读为 Integer
}
public static void main(String[] args) {
List<Integer> integers = Arrays.asList(1, 2, 3);
List<Double> doubles = Arrays.asList(1.1, 2.2, 3.3);
System.out.println(sum(integers)); // 6.0
System.out.println(sum(doubles)); // 6.6
List<Number> numbers = new ArrayList<>();
addNumbers(numbers); // 可以写入 Integer
}
}
五、泛型的限制
由于类型擦除,泛型有以下限制:
public class GenericLimitations<T> {
// ❌ 1. 不能用基本类型作为类型参数
// List<int> list; // 编译错误
List<Integer> list; // 必须使用包装类
// ❌ 2. 运行时类型检查只适用于原始类型
// if (list instanceof List<String>) {} // 编译错误
if (list instanceof List) {} // 只能检查原始类型
// ❌ 3. 不能创建泛型数组
// T[] array = new T[10]; // 编译错误
T[] array = (T[]) new Object[10]; // 只能通过强转
// ❌ 4. 不能实例化类型参数
// T obj = new T(); // 编译错误
T obj = (T) new Object(); // 只能通过强转
// ❌ 5. 不能创建泛型静态字段
// private static T staticField; // 编译错误
// ❌ 6. 泛型类不能继承 Throwable
// class MyException<T> extends Exception {} // 编译错误
}
六、泛型 vs Object 对比
| 对比维度 | 使用 Object | 使用泛型 |
|---|---|---|
| 类型安全 | 运行时才能发现类型错误 | 编译期就能发现类型错误 |
| 强制转换 | 必须手动强转 | 编译器自动插入强转 |
| 代码可读性 | 需要查看文档才知道类型 | 类型信息直观可见 |
| 性能 | 有装箱拆箱开销 | 基本类型需包装,但无额外开销 |
| 灵活性 | 灵活但不安全 | 安全且灵活 |
// Object 方式
List list1 = new ArrayList();
list1.add("hello");
list1.add(123); // 编译通过
String s = (String) list1.get(0); // 需要强转
// 泛型方式
List<String> list2 = new ArrayList<>();
list2.add("hello");
// list2.add(123); // 编译错误!
String s2 = list2.get(0); // 无需强转
面试高频追问
-
什么是类型擦除?为什么 Java 要这样设计?
类型擦除是编译器在编译时将泛型类型替换为原始类型或上界的过程。Java 这样设计是为了 向后兼容,确保 JDK 5 之前的代码在新版本中仍能运行,这是 Java 设计者的权衡之举。
-
List<?>、List<Object>和List有什么区别?List<?>:未知类型的 List,只能读不能写(类型安全)List<Object>:元素类型为 Object 的 List,可以读写任意对象List:原始类型,绕过泛型检查,不推荐使用
-
泛型数组为什么不能创建?
因为类型擦除后,
List<String>[]和List<Integer>[]都变成List[],如果允许创建,会导致类型安全问题。
常见面试变体
- "Java 泛型是如何实现的?和 C++ 模板有什么区别?"
- "什么是 PECS 原则?能举个例子吗?"
- "泛型的类型擦除会带来什么问题?如何解决?"
- "如何实现一个泛型数组?"
记忆口诀
泛型本质:类型参数化,编译期检查。
PECS 原则:生产者用 extends(只读),消费者用 super(只写)。
类型擦除:编译时擦除为 Object 或上界,运行时无泛型信息。
总结
Java 泛型通过类型参数化实现编译期类型检查,解决了类型安全和强制转换问题。核心原理是类型擦除——编译时将泛型类型替换为原始类型,这是 Java 为了向后兼容的设计权衡。使用时遵循 PECS 原则(Producer Extends, Consumer Super),并注意泛型的限制(不能用基本类型、不能创建泛型数组等)。