目录
  1. 1. 一、概述
  2. 2. 二、优点与缺点
    1. 2.1. 2.1 优点
    2. 2.2. 2.2 缺点(因类型擦除导致)
  3. 3. 三、泛型的分类
    1. 3.1. 3.1 通用类型(Generic Type)
    2. 3.2. 3.2 原始类型(Raw Type)
    3. 3.3. 3.3 通用方法(Generic Method)
    4. 3.4. 3.4 限定类型参数(Bounded Type Parameter)
    5. 3.5. 3.5 泛型、继承和子类型
  4. 4. 四、类型推断
    1. 4.1. 4.1 Java 8/11/17 中的类型推断增强
  5. 5. 五、通配符
    1. 5.1. 5.1 上限通配符
    2. 5.2. 5.2 下限通配符
    3. 5.3. 5.3 无限通配符
    4. 5.4. 5.4 PECS 原则(Producer Extends, Consumer Super)
  6. 6. 六、类型擦除(Type Erasure)
    1. 6.1. 6.1 类型擦除原理
    2. 6.2. 6.2 桥方法(Bridge Method)
    3. 6.3. 6.3 类型擦除的后果
  7. 7. 七、Gson 泛型反序列化与 TypeToken
  8. 8. 八、Kotlin 的泛型 vs Java 的泛型
  9. 9. 九、小结
  10. 10. 十、面试常问题目
Java进阶之泛型

一、概述

Java 中的泛型是一种比较常用且强大的功能,它是一种代码类型检验机制,使用泛型可以让你在代码编译期间检测到更多察觉不到的错误。

二、优点与缺点

2.1 优点

  • 在编译时进行更强的类型检测,Java 编译器将强类型检查应用于通用代码里,如果有违反类型安全,则直接通过编译器暴露出来。
  • 消除类型转换:使用泛型可以免去强制转型。
  • 使用泛型,可以实现不同类型的集合进行工作,可以实现自定义并且类型安全且易于阅读的泛型算法。

2.2 缺点(因类型擦除导致)

  • 泛型类型变量不能使用基本数据类型(必须用包装类型)
  • 不能使用 instanceof 运算符检测泛型类型
  • 泛型类不能被定义成静态的(静态成员无法引用类类型参数)
  • 泛型类里同名函数会造成方法冲突
  • 无法创建泛型实例(new T() 无效,可以通过反射实现)
  • 不存在泛型数组(new T[n] 无效)

三、泛型的分类

3.1 通用类型(Generic Type)

通用类型是通过类型进行参数化的通用类或接口。

通用类的定义格式如下:

class name<T1, T2, ..., Tn> {}

类型参数命名约定

  • E - Element(Java Collections Framework 广泛使用)
  • K - Key
  • V - Value
  • N - Number
  • T - Type
  • S, U, V etc. - 2nd, 3rd, 4th types

调用和实例化泛型类型:泛型类型的调用通常称为参数化类型,要实例化此类,通常是使用 new 关键字:

List<String> strList = new ArrayList<>();

多类型参数

public interface Pair<K, V> {
public K getKey();
public V getValue();
}

public class OrderedPair<K, V> implements Pair<K, V> {
private K key;
private V value;

public OrderedPair(K key, V value) {
this.key = key;
this.value = value;
}

public K getKey() { return key; }
public V getValue() { return value; }
}

通过以下语句创建 OrderedPair 的两个实例:

Pair<String, Integer> p1 = new OrderedPair<String, Integer>("Even", 8);
Pair<String, String> p2 = new OrderedPair<String, String>("Hello", "World");

由于菱形原因,Java 编译器可以从上下文推断出类型参数,因此可以使用菱形表示法缩短实例化语句:

Pair<String, Integer> p1 = new OrderedPair<>("Even", 8);
Pair<String, String> p2 = new OrderedPair<>("Hello", "World");

参数化类型

OrderedPair<String, Box<Integer>> p = new OrderedPair<>("primes", new Box<Integer>(...));

3.2 原始类型(Raw Type)

原始类型是没有任何类型参数的泛型类或接口的名称。例如:

public class Box<T> {
public void set(T t) { /* ... */ }
}

但非泛型类或接口类型不是原始类型。原始类型显示在旧代码中,因为在 JDK 5.0 之前,许多 API 类(例如 Collections 类)不是通用类型。使用原始类型时,实际上也会获得泛型行为(Box 内部存储 Object)。为了向后兼容,允许将参数化类型分配给其他原始类型:

Box<String> stringBox = new Box<>();
Box rawBox = stringBox; // 这是 OK 的

但反过来,将原始类型分配给参数化类型,就会提示警告:

Box rawBox = new Box();              // 原始类型
Box<Integer> intBox = rawBox; // WARNING: unchecked conversion

同样,如果使用原始类型来调用相应泛型类型中定义的泛型方法,也会提示警告:

Box<String> stringBox = new Box<>();
Box rawBox = stringBox;
rawBox.set(8); // WARNING: unchecked invocation to set(T)

该警告表明原始类型会绕过通用类型检查,从而将不安全代码的捕获推迟到运行时。因此,应避免使用原始类型。

3.3 通用方法(Generic Method)

指引入自己的类型参数的方法,类似于声明一个泛型方法,但类型参数的范围仅限于声明它的方法。允许使用静态和非静态的泛型方法,也允许使用泛型类构造函数。

通用方法的语法包括:类型参数列表,在尖括号内,该列表出现在方法的返回类型之前。对于静态泛型方法,类型参数部分必须出现在方法的返回类型之前:

public class Util {
public static <K, V> boolean compare(Pair<K, V> p1, Pair<K, V> p2) {
return p1.getKey().equals(p2.getKey()) &&
p1.getValue().equals(p2.getValue());
}
}

调用此方法的完整语法如下:

Pair<Integer, String> p1 = new Pair<>(1, "apple");
Pair<Integer, String> p2 = new Pair<>(2, "orange");
boolean same = Util.<Integer, String>compare(p1, p2);

该类型可以明确提供,如上所示,通常可以忽略,编译器可推断出所需类型:

boolean same = Util.compare(p1, p2);

3.4 限定类型参数(Bounded Type Parameter)

限制参数化类型中用作类型参数的类型,比如对数字进行操作的方法只希望接受 Number 或其子类的实例:

public class Box<T> {
private T t;

public void set(T t) { this.t = t; }
public T get() { return t; }

public <U extends Number> void inspect(U u) {
System.out.println("T: " + t.getClass().getName());
System.out.println("U: " + u.getClass().getName());
}

public static void main(String[] args) {
Box<Integer> integerBox = new Box<Integer>();
integerBox.set(new Integer(20));
// integerBox.inspect("do sth"); // error: incompatible types
integerBox.inspect(10); // OK
}
}

3.5 泛型、继承和子类型

通过泛型编程时,有一种常见的误解:Integer 是 Number 类型的子类,那么它的泛型包裹类 Box<Integer>Box<Number> 没有任何关系——它们是不同的类型,不能相互赋值。

// 这是错误的认知:
// Box<Number> box = new Box<Integer>(); // 编译错误!
// List<Number> list = new ArrayList<Integer>(); // 编译错误!

// 原因:泛型在 Java 中是不可变的(invariant)
// 数组是协变的(covariant):
Number[] nums = new Integer[10]; // 合法(但有 ArrayStoreException 风险)
// 泛型是不可变的:
// List<Number> nums = new ArrayList<Integer>(); // 编译错误

可以通过扩展来实现泛型通用类或接口。一个类或者接口的类型参数与另一个类或接口的类型参数之间的关系由 extends 和 implements 来确定。

四、类型推断

类型推断是 Java 编译器查看每个方法调用和相应声明以确定使用适用的类型参数的能力。

  • 类型推断和通用方法:通常 Java 编译器可以推断出通用方法调用的类型参数。
  • 泛型类的类型推断和实例化:可以使用一组空的类型参数(<>)替换调用通用类的构造函数所需的类型参数,只要编译器可以从上下文中推断出类型参数都可以使用。
  • 泛型和非泛型类的类型推断和泛型构造函数
class MyClass<X> {
<T> MyClass(T t) {
// ...
}
}

实例化时:

new MyClass<Integer>("");  // X=Integer, T=String

4.1 Java 8/11/17 中的类型推断增强

// Java 8: 目标类型推断改进
// 允许在更多上下文中推断泛型类型参数
List<String> list = Collections.emptyList(); // Java 7 需要: Collections.<String>emptyList()

// Java 11: Lambda 参数的 var (JEP 323)
// BiFunction<Integer, Integer, Integer> add = (var a, var b) -> a + b;

// Java 17: 密封类(Sealed Classes)与泛型的交互
sealed interface Result<T> permits Success, Failure { }
record Success<T>(T data) implements Result<T> { }
record Failure<T>(String error) implements Result<T> { }

五、通配符

在通用代码里,通配符(?)表示未知类型。通配符可以在多种情况下使用:作为参数、字段或局部变量的类型;作为返回类型。请注意:通配符从不用作泛型方法调用、泛型类实例创建、超类型的类型参数。

5.1 上限通配符

声明上限通配符,使用通配符(?),后跟 extends 关键字,然后是其上限:

public static void process(List<? extends Foo> list) {
// 可以读取(返回类型为 Foo 或其子类)
Foo item = list.get(0);
// 但不能写入(除了 null)
// list.add(new Foo()); // 编译错误!
list.add(null); // 只能添加 null
}

5.2 下限通配符

下限通配符使用通配符(?)表示,后跟 super 关键字,接着再跟下限:

public static void addNumbers(List<? super Integer> list) {
// 可以写入 Integer 及其子类
list.add(10);
list.add(20);
// 但读取时只能作为 Object
Object obj = list.get(0);
// Integer num = list.get(0); // 编译错误!
}

5.3 无限通配符

无限通配符类型使用通配符(?)来指定:

// 适用于:
// 1. 只需要使用 Object 类的方法
public static void printList(List<?> list) {
for (Object elem : list) {
System.out.println(elem); // Object.toString()
}
}

// 2. 代码使用不依赖于类型参数的通用类方法
// 例如:List.size(), List.clear(), Collection.isEmpty()
public static boolean isEmpty(List<?> list) {
return list.isEmpty(); // 不依赖类型参数
}

5.4 PECS 原则(Producer Extends, Consumer Super)

PECS 是通配符使用的黄金法则:

  • Producer Extends:如果你需要从集合中读取(产生数据),使用 ? extends Type
  • Consumer Super:如果你需要向集合中写入(消费数据),使用 ? super Type
// 经典示例:Collections.copy
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
for (int i = 0; i < src.size(); i++) {
dest.set(i, src.get(i)); // src 是 producer (extends), dest 是 consumer (super)
}
}

// 更多 PECS 示例
// 生产者:你从中取数据 → ? extends
public void processAnimals(List<? extends Animal> animals) {
for (Animal a : animals) { // 从列表中"取"数据
a.makeSound();
}
}

// 消费者:你向其中放数据 → ? super
public void addDogs(List<? super Dog> dogs) {
dogs.add(new Dog()); // 向列表中"放"数据
dogs.add(new Husky()); // Husky 是 Dog 的子类,也可以
}

// 既是生产者也是消费者 → 不用通配符
public <T> void swap(List<T> list, int i, int j) {
T tmp = list.get(i); // 生产者
list.set(i, list.get(j)); // 消费者
list.set(j, tmp);
}

六、类型擦除(Type Erasure)

6.1 类型擦除原理

Java 的泛型是 JDK 5 新引入的特性,为了向下兼容,虚拟机其实是不支持泛型的,所以 Java 实现的是一种伪泛型机制——也就是说 Java 在编译期擦除了所有的泛型信息,这样 Java 就不需要产生新的类型到字节码,所有的泛型类型最终都是一种原始类型,在 Java 运行时根本就不存在泛型信息。

Java 编译器具体是如何擦除泛型的:

  1. 检查泛型类型,获取目标类型
  2. 擦除类型变量,并替换为限定类型
    • 如果泛型类型的类型变量没有限定(<T>),则用 Object 作为原始类型
    • 如果有限定(<T extends XClass>),则用 XClass 作为原始类型
    • 如果有多个限定(<T extends XClass1 & XClass2>),则使用第一个边界 XClass1 作为原始类型
  3. 在必要时插入类型转换以保持类型安全
  4. 生成桥方法以在扩展时保持多态性
// 类型擦除的实际效果
// 编译前:
public class Box<T> {
private T value;
public void set(T value) { this.value = value; }
public T get() { return value; }
}

// 编译后(等价于):
public class Box {
private Object value;
public void set(Object value) { this.value = value; }
public Object get() { return value; }
}

// 有限定的泛型
// 编译前:
public class NumericBox<T extends Number> {
private T value;
public T get() { return value; }
}

// 编译后(等价于):
public class NumericBox {
private Number value;
public Number get() { return value; }
}

6.2 桥方法(Bridge Method)

桥方法是类型擦除的一个重要副作用。当子类在继承泛型父类时指定了具体类型参数,编译器会自动生成桥方法来保持多态性:

// 编译前
public class Parent<T> {
public T getValue() { return null; }
public void setValue(T value) { }
}

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

@Override
public void setValue(String value) { }
}

// 编译后,Child 类中实际上有 4 个方法(字节码层面):
// public String getValue() — 开发者定义的方法
// public void setValue(String) — 开发者定义的方法
// public Object getValue() — 编译器生成的桥方法(bridge method)
// public void setValue(Object) — 编译器生成的桥方法
//
// 桥方法实现:
// public Object getValue() {
// return this.getValue(); // 调用 String 版本
// }
// public void setValue(Object value) {
// this.setValue((String) value); // 调用 String 版本
// }

桥方法在反射中可见——当你用 getDeclaredMethods() 时,会看到两个同名方法(一个返回 Object,一个返回具体类型)。可以通过 Method.isBridge() 判断是否为桥方法。

6.3 类型擦除的后果

// 1. 不能实例化泛型类型
public <T> T create() {
// return new T(); // 编译错误
return null;
}

// 2. 不能对泛型类型使用 instanceof
// if (obj instanceof List<String>) // 编译错误
if (obj instanceof List) { /* OK */ } // 只能用原始类型

// 3. 不能创建泛型数组
// List<String>[] array = new ArrayList<String>[10]; // 编译错误
List<String>[] array = (List<String>[]) new ArrayList<?>[10]; // 需要强制转型

// 4. 静态字段不能使用泛型参数
class MyClass<T> {
// static T value; // 编译错误
static Object value; // OK
}

七、Gson 泛型反序列化与 TypeToken

Gson 在处理泛型反序列化时需要保留泛型信息,因为运行时泛型已被擦除:

// 问题:如何反序列化泛型类型?
// 直接使用 Response.class 会丢失泛型信息
// Response<Data> resp = gson.fromJson(json, Response.class); // data 字段会是 LinkedTreeMap!

// 解决方案:使用 TypeToken
Response<Data> resp = gson.fromJson(json,
new TypeToken<Response<Data>>() {}.getType());

为什么 TypeToken 必须是抽象类(或接口)?

因为只有定义为抽象类或接口,才能在使用时创建匿名子类(new TypeToken<Response<Data>>() {}),这个匿名子类会保留父类的泛型签名信息。JVM 规范要求类的字节码中记录其父类的泛型信息——即使方法体中的泛型被擦除了,类声明中的泛型信息仍然保留在常量池的 Signature 属性中。TypeToken 通过 getClass().getGenericSuperclass() 获取 ParameterizedType,从中提取出实际的类型参数。

// TypeToken 核心原理(简化版)
public abstract class TypeToken<T> {
private final Type type;

protected TypeToken() {
// 获取匿名子类的父类(即 TypeToken<ConcreteType>)
Type superclass = getClass().getGenericSuperclass();
if (superclass instanceof ParameterizedType) {
// 提取出实际的类型参数
this.type = ((ParameterizedType) superclass).getActualTypeArguments()[0];
} else {
throw new IllegalArgumentException("Must specify type parameter");
}
}

public Type getType() {
return type;
}
}

八、Kotlin 的泛型 vs Java 的泛型

特性 Java Kotlin
类型擦除 是(运行时无泛型) 是(JVM 后端)
声明处型变 不支持 支持(out/in 关键字)
使用处型变 支持(? extends/? super 支持(型变投影)
reified 类型参数 不支持 支持(内联函数中 reified T
星投影 不支持 支持(*
基本类型泛型 不支持(需要装箱) 编译期特化处理
// Kotlin 声明处型变
interface Source<out T> { // out = Java 的 ? extends T
fun nextT(): T
}

interface Comparable<in T> { // in = Java 的 ? super T
operator fun compareTo(other: T): Int
}

// Kotlin reified:在运行时保留泛型信息(仅限内联函数)
inline fun <reified T> Gson.fromJson(json: String): T {
return fromJson(json, T::class.java) // 运行时可直接获取 T::class
}

// Java 中需要:
// new TypeToken<T>() {}.getType()

九、小结

  1. Java 泛型(generics)是 JDK 5 中引入的一种参数化类型特性
  2. 使用泛型好处:代码更健壮、代码更简洁、代码更灵活、可复用性高
  3. 类型擦除是 Java 泛型的核心实现机制,理解它才能理解泛型的各种限制
  4. PECS 原则是正确使用通配符的关键指南

十、面试常问题目

Q1: 什么是类型擦除?为什么 Java 选择这种实现方式?

类型擦除是 Java 泛型的实现机制——编译器在编译期间将泛型类型参数替换为它们的边界(未限定的则替换为 Object),并在需要时插入强制类型转换。这样做主要是为了向后兼容:JDK 5 之前的大量代码(包括 JDK 自身的集合框架)没有泛型,类型擦除使得泛型化的新代码可以与老代码二进制兼容。代价是运行时无法获取泛型类型信息(无法 T.class、无法 new T()、无法 instanceof List<String>)。

Q2: <? extends T> 和 <? super T> 有什么区别?什么场景下分别使用?

<? extends T> 是上限通配符,表示类型是 T 或其子类。适用于”生产者”角色——你只从集合中读取数据。因为不能确定具体类型,所以不能写入(除 null)。典型场景:List<? extends Number> 可以传入 List<Integer>List<Double>,从其中读取的值类型为 Number。<? super T> 是下限通配符,表示类型是 T 或其父类。适用于”消费者”角色——你只向集合中写入 T 类型的数据。因为从集合中读取的类型不确定,只能以 Object 方式读取。典型场景:List<? super Integer> 可以传入 List<Integer>List<Number>List<Object>,可以向其中安全添加 Integer。PECS 原则:Producer Extends, Consumer Super。

Q3: 桥方法是什么?它在什么情况下产生?

桥方法是编译器为了保持泛型方法多态性而自动生成的方法。当子类继承(或实现)泛型父类(或接口)并指定了具体类型参数时,类型擦除后,子类方法的签名与父类签名不匹配(父类期望 Object 参数,子类定义了 String 参数)。编译器在子类中生成一个桥接方法(签名为父类的擦除后签名),内部调用子类具体类型的方法。Method.isBridge() 可以判断一个方法是否为桥方法。桥方法在字节码层面可见,可能导致调用 getDeclaredMethods() 时看到”重复”方法。

Q4: 为什么 Gson 反序列化泛型类型需要 TypeToken?

因为 Java 泛型在运行时被擦除,Response.class 不包含 Response<Data> 中 Data 的类型信息。如果直接使用 Response.class 进行反序列化,Gson 不知道 data 字段应该是什么类型,会退化为使用 LinkedTreeMap。TypeToken 通过创建匿名子类(new TypeToken<Response<Data>>() {}),将泛型信息保存在类的 Signature 属性中(属性在字节码的常量池中,不会被擦除)。然后通过反射获取 ParameterizedType,从中提取出实际的类型参数 Data。

Q5: 为什么不能创建泛型数组?

因为 Java 的泛型是通过类型擦除实现的,运行时不保留类型信息。如果允许创建泛型数组(如 new List<String>[10]),那么运行时数组的实际类型是 List[](擦除后),但元素类型丢失。这会导致数组的协变特性(Object[] arr = new List<String>[10] 是合法的)加上泛型擦除后,可以在运行时向数组中放入 List<Integer> 而不会抛出 ArrayStoreException——这就打破了泛型的类型安全承诺。Java 设计者选择在编译期禁止泛型数组创建(但在一些场景下可以通过 (T[]) new Object[n] 绕过,此时开发者和编译器共同承担类型安全责任)。


参考文档:

  • Java Language Specification, Chapter 4 (Types, Values, and Variables) and Chapter 18 (Type Inference)
  • Angelika Langer’s Java Generics FAQ: http://www.angelikalanger.com/GenericsFAQ/JavaGenericsFAQ.html
  • Gson TypeToken 源码:https://github.com/google/gson
  • Kotlin Generics: https://kotlinlang.org/docs/generics.html
打赏
  • 微信
  • 支付宝

评论