什么是泛型?为什么要使用它?

面试考察点

  1. 基础概念掌握:面试官不仅仅是想知道泛型是什么,更是想知道你是否理解泛型的本质——参数化类型,以及它在编译期和运行期的不同表现。
  2. 类型安全意识:考察你是否清楚泛型如何解决类型安全问题,能否对比 “泛型前” 和 “泛型后” 的代码差异,理解为什么 ClassCastException 从运行期提前到了编译期。
  3. 底层原理深度:考察你是否了解 类型擦除 机制,能否解释为什么 List<String> 和 List<Integer> 在运行时是同一个类,以及泛型与 Java 向后兼容的设计权衡。

核心答案

Java 泛型(Generics) 是 JDK 5 引入的特性,允许在定义类、接口和方法时使用 类型参数,将类型从 “具体” 变成 “参数化”,从而在编译期进行类型检查。 **** **** **** ****

泛型核心价值 说明
类型安全 编译期检查类型,将运行时异常提前到编译期
消除强转 无需手动类型转换,代码更简洁
代码复用 一套代码适用于多种类型
设计优雅 泛型集合、泛型方法提升 API 设计质量

为什么要使用泛型?

使用泛型前 使用泛型后
List list = new ArrayList();list.add(“hello”);String s = (String) list.get(0); // 需要强转 List list = new ArrayList<>();list.add(“hello”);String s = list.get(0); // 无需强转
list.add(123); // 编译通过,运行时报错 list.add(123); // 编译直接报错

一句话总结:泛型通过编译期类型检查,保证了类型安全,消除了强制类型转换,让代码更健壮、更优雅。

深度解析

一、泛型的本质:参数化类型

// 传统方式:类型写死,每种类型都要定义一个类,大量重复代码
class StringList {
String[] data;
String get(int i) { return data[i]; }
}

class IntegerList {
Integer[] data;
Integer get(int i) { return data[i]; }
}

// 泛型方式:类型作为参数传入,一套代码适用于多种类型
class ArrayList<E> { // E 是类型参数
E[] data;
E get(int i) { return data[i]; }
}

ArrayList<String> list1; // E = String
ArrayList<Integer> list2; // E = Integer
ArrayList<User> list3; // E = User

上图展示了泛型的核心思想——类型参数化。传统方式需要为每种类型单独定义一个类,导致大量重复代码;而泛型将类型作为参数传入,实现了一套代码适用于多种类型。

  • 类型参数:如 <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);
}
}

三、泛型的核心原理:类型擦除

// 编译前(源代码)
List<String> list = new ArrayList<>();
list.add("hello");
String s = list.get(0);

// 编译后(类型擦除后)
List list = new ArrayList(); // List<String> 被擦除为 List
list.add("hello"); // 编译器插入类型检查
String s = (String) list.get(0); // 编译器插入强制类型转换

// 结论:运行时 List<String> 和 List<Integer> 是同一个类

上图展示了 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; }
}

四、通配符详解

通配符 含义 读 / 写 使用场景
<?> 无界通配符,可持有任何类型 只能读,不能写(null 除外) 不确定类型时的参数声明
<? extends T> 上界通配符(协变),可以是 T 或 T 的子类 只能读,不能写(Producer Extends) 从集合中读取数据
<? super T> 下界通配符(逆变),可以是 T 或 T 的父类 只能写,不能读(Consumer Super) 向集合中写入数据

记忆口诀:PECS(Producer Extends, Consumer Super)

上图对比了三种通配符的特点和使用场景,核心原则是 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); // 无需强转

面试高频追问

  1. 什么是类型擦除?为什么 Java 要这样设计? 类型擦除是编译器在编译时将泛型类型替换为原始类型或上界的过程。Java 这样设计是为了 向后兼容,确保 JDK 5 之前的代码在新版本中仍能运行,这是 Java 设计者的权衡之举。
  2. List<?>、List<Object> 和 List 有什么区别?
    • List<?>:未知类型的 List,只能读不能写(类型安全)
    • List<Object>:元素类型为 Object 的 List,可以读写任意对象
    • List:原始类型,绕过泛型检查,不推荐使用
  3. 泛型数组为什么不能创建? 因为类型擦除后,List<String>[] 和 List<Integer>[] 都变成 List[],如果允许创建,会导致类型安全问题。

常见面试变体

  • “Java 泛型是如何实现的?和 C++ 模板有什么区别?”
  • “什么是 PECS 原则?能举个例子吗?”
  • “泛型的类型擦除会带来什么问题?如何解决?”
  • “如何实现一个泛型数组?”

记忆口诀

泛型本质:类型参数化,编译期检查。 PECS 原则:生产者用 extends(只读),消费者用 super(只写)。 类型擦除:编译时擦除为 Object 或上界,运行时无泛型信息。

总结

Java 泛型通过类型参数化实现编译期类型检查,解决了类型安全和强制转换问题。核心原理是类型擦除——编译时将泛型类型替换为原始类型,这是 Java 为了向后兼容的设计权衡。使用时遵循 PECS 原则(Producer Extends, Consumer Super),并注意泛型的限制(不能用基本类型、不能创建泛型数组等)。


声明:本文转载自小哈的博客 《什么是泛型?为什么要使用它?》,版权归原文作者所有。转载请保留本声明与原文链接。