📚 本系列系统梳理了 Java 开发的详细知识点,从基础语法到工程实践层层递进,内容详实成体系,建议先收藏再慢慢阅读,方便日后随时回顾查阅。

前言

泛型是 Java 类型系统中最容易让人困惑的部分——语法不难,但背后的类型擦除机制、通配符的边界规则、PECS 原则,不搞清楚就会在实际开发中反复碰壁。这篇文章从"泛型为什么存在"讲起,把这些概念一次理清。

1. 没有泛型的年代

Java 5 之前,集合只能存 Object,取出来必须强转:

List list = new ArrayList();
list.add("hello");
list.add(123);           // 什么都能塞,编译器不管

String s = (String) list.get(0);  // 必须强转
String s2 = (String) list.get(1); // 运行时 ClassCastException!

问题很明显:类型错误只能在运行时发现,而且代码里到处是强转。泛型的目标就是把类型检查提前到编译期。

2. 泛型基础

2.1 泛型类

// T 是类型参数,定义时不确定,使用时指定
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> strBox = new Box<>("hello");
String s = strBox.getValue();        // 不需要强转
// strBox.setValue(123);             // 编译报错!类型安全

Box<Integer> intBox = new Box<>(42);
int n = intBox.getValue();           // 自动拆箱

2.2 泛型方法

方法也可以独立声明自己的类型参数,不依赖类的泛型:

public class Util {
    // <T> 声明在返回值之前
    public static <T> T firstOrNull(List<T> list) {
        return list.isEmpty() ? null : list.get(0);
    }
}

String s = Util.firstOrNull(List.of("a", "b"));   // 编译器自动推断 T = String
Integer n = Util.firstOrNull(List.of(1, 2, 3));    // T = Integer

2.3 泛型接口

public interface Comparable<T> {
    int compareTo(T other);
}

public class Student implements Comparable<Student> {
    int score;

    @Override
    public int compareTo(Student other) {
        return this.score - other.score;  // 参数类型确定,不需要强转
    }
}

2.4 多个类型参数

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

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

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

Pair<String, Integer> p = new Pair<>("age", 25);

常见的类型参数命名约定:T(Type)、E(Element)、K(Key)、V(Value)、R(Return)。

3. 类型擦除(Type Erasure)

这是 Java 泛型最核心也最反直觉的机制。

3.1 泛型只存在于编译期

编译器检查完类型安全后,会把所有泛型信息擦除,替换成原始类型:

// 你写的代码
List<String> list = new ArrayList<>();
list.add("hello");
String s = list.get(0);

// 编译后(字节码层面)实际变成
List list = new ArrayList();
list.add("hello");
String s = (String) list.get(0);  // 编译器自动插入了强转

也就是说,泛型是纯编译期的语法糖,运行时 JVM 完全不知道 List<String>List<Integer> 有什么区别。

3.2 类型擦除的后果

// 1. 运行时无法获取泛型类型
List<String> strList = new ArrayList<>();
List<Integer> intList = new ArrayList<>();
System.out.println(strList.getClass() == intList.getClass());  // true,都是 ArrayList

// 2. 不能用基本类型作为类型参数
// List<int> list = ...;  // 编译报错,只能用 List<Integer>

// 3. 不能创建泛型数组
// T[] arr = new T[10];   // 编译报错
// 替代方案:
T[] arr = (T[]) new Object[10];  // 可以,但有警告

// 4. 不能用 instanceof 检查泛型类型
// if (list instanceof List<String>) { }  // 编译报错
if (list instanceof List<?>) { }          // 可以,用通配符

// 5. 不能 new T()
// T obj = new T();  // 编译报错,擦除后变成 new Object(),无意义

3.3 为什么 Java 选择类型擦除?

一个词:向后兼容。Java 5 引入泛型时,必须保证已有的数百万行非泛型代码(原始类型 ListMap)能继续运行,不需要重新编译。类型擦除让泛型代码和非泛型代码在字节码层面完全一样,完美兼容。代价就是运行时丢失了类型信息。

作为对比,C++ 的模板会为每种类型参数生成独立的代码(模板实例化),没有擦除问题,但会导致代码膨胀。

4. 类型边界(Bounded Types)

默认情况下泛型的类型参数 T 可以是任意类型,但有时需要限制 T 的范围,比如要求 T 必须能比较大小,或者必须有某些方法。类型边界就是用来做这个限制的。

4.1 为什么需要上界?

// 没有限制:T 可以是任何类型
public static <T> T max(T a, T b) {
    return a.compareTo(b) >= 0 ? a : b;  // 编译报错!
    // 编译器不知道 T 有没有 compareTo 方法
}

// 加上界:告诉编译器 T 一定实现了 Comparable,所以一定有 compareTo
public static <T extends Comparable<T>> T max(T a, T b) {
    return a.compareTo(b) >= 0 ? a : b;  // 合法!
}

max(1, 2);      // Integer 实现了 Comparable,合法
max("a", "b");  // String 实现了 Comparable,合法
// max(new Object(), new Object());  // 编译报错,Object 没实现 Comparable

T extends Comparable<T> 的意思是:T 必须是实现了 Comparable 接口的类型。这里的 extends 不是继承,是"上界"的意思,既可以限制父类也可以限制接口,统一用 extends

4.2 多重边界

& 同时加多个限制,表示 T 必须同时满足所有条件:

// T 必须是 Animal 的子类,同时实现 Flyable 接口
// 这样方法体里就可以同时调用两者的方法
public <T extends Animal & Flyable> void doSomething(T t) {
    t.eat();  // 合法:T 是 Animal 子类,有 eat()
    t.fly();  // 合法:T 实现了 Flyable,有 fly()
}

// 只有既继承 Animal 又实现 Flyable 的类才能传进来
// 比如前面定义的 Duck(extends Animal implements Flyable)
doSomething(new Duck("Donald"));  // 合法
// doSomething(new Dog("Buddy")); // 编译报错,Dog 没实现 Flyable

语法规则:类必须写在第一位,接口写在后面,因为 Java 只能单继承:

<T extends Animal & Flyable & Swimmable>  // ✅ 一个类 + 多个接口
<T extends Flyable & Swimmable>           // ✅ 全是接口,顺序无所谓
<T extends Flyable & Animal>              // ❌ Animal 是类却不在第一位,编译报错

5. 通配符(Wildcard)

通配符 ? 表示"某个未知类型",和类型参数 T 的区别是:T 是定义时声明的占位符,? 是使用时表示"我不关心具体类型"。

5.1 无界通配符 <?>

// 只需要读取、不关心具体类型时使用
public void printList(List<?> list) {
    for (Object item : list) {
        System.out.println(item);
    }
}

printList(List.of("a", "b"));
printList(List.of(1, 2, 3));

5.2 上界通配符 <? extends T> —— 只能读

// 接受 Number 及其子类(Integer、Double、Long...)的 List
public double sum(List<? extends Number> list) {
    double total = 0;
    for (Number n : list) {
        total += n.doubleValue();  // 读取:合法,取出来的一定是 Number
    }
    // list.add(1);               // 编译报错!不能写入
    return total;
}

sum(List.of(1, 2, 3));           // List<Integer> → 合法
sum(List.of(1.5, 2.5));          // List<Double> → 合法

为什么不能写入? 因为编译器只知道 list 里存的是"某种 Number 的子类",但不知道具体是哪种。如果 list 实际是 List<Double>,你往里塞一个 Integer 就类型不安全了。所以编译器一律禁止写入。

5.3 下界通配符 <? super T> —— 只能写

// 接受 Integer 及其父类(Number、Object)的 List
public void addNumbers(List<? super Integer> list) {
    list.add(1);       // 写入:合法,Integer 一定是 list 元素类型的子类
    list.add(2);
    // Integer n = list.get(0);  // 编译报错!取出来的类型不确定,只能当 Object 用
    Object o = list.get(0);      // 只能用 Object 接收
}

addNumbers(new ArrayList<Integer>());  // 合法
addNumbers(new ArrayList<Number>());   // 合法
addNumbers(new ArrayList<Object>());   // 合法

6. PECS 原则

Producer Extends, Consumer Super。 这是 Effective Java 中总结的通配符使用口诀:

  • 生产者(从集合中读取数据)<? extends T>
  • 消费者(向集合中写入数据)<? super T>
// 一个实际例子:把 src 的内容复制到 dest
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
    for (T item : src) {     // src 是生产者,用 extends,只读
        dest.add(item);       // dest 是消费者,用 super,只写
    }
}

List<Number> numbers = new ArrayList<>();
List<Integer> ints = List.of(1, 2, 3);
copy(numbers, ints);  // 合法:dest 接受 Number(Integer 的父类),src 提供 Integer

如果既要读又要写,那就不用通配符,直接用确定的类型参数 <T>

6.1 一张图记住

<? extends T>  →  能读不能写  →  从集合取数据(Producer)
<? super T>    →  能写不能读  →  往集合塞数据(Consumer)
<T>            →  能读能写    →  既取又塞
<?>            →  只能读Object →  完全不关心类型

7. 泛型的常见使用模式

7.1 泛型工具方法

public class CollectionUtil {
    // 交换列表中两个位置的元素
    public static <T> void swap(List<T> list, int i, int j) {
        T temp = list.get(i);
        list.set(i, list.get(j));
        list.set(j, temp);
    }

    // 过滤列表
    public static <T> List<T> filter(List<T> list, Predicate<T> predicate) {
        List<T> result = new ArrayList<>();
        for (T item : list) {
            if (predicate.test(item)) result.add(item);
        }
        return result;
    }
}

List<String> longNames = CollectionUtil.filter(
    List.of("Alice", "Bob", "Christopher"),
    name -> name.length() > 4
);
// ["Alice", "Christopher"]

7.2 泛型类的继承

泛型类继承时,子类有三种处理父类(Box<T>)类型参数的方式:

方式 写法 含义
固定类型 StringBox extends Box<String> 子类直接指定 T 是什么,不再泛型
透传类型 NamedBox<T> extends Box<T> 子类保留泛型,T 由使用时决定
扩展类型 MappedBox<K, V> extends Box<V> 子类新增自己的类型参数
// 方式 1:固定类型——子类确定父类的类型参数,StringBox 不再是泛型类
public class StringBox extends Box<String> {
    public StringBox(String value) { super(value); }
}
StringBox sb = new StringBox("hello");  // 只能装 String

// 方式 2:透传类型——子类保留泛型,T 由创建对象时决定
public class NamedBox<T> extends Box<T> {
    private String label;
    public NamedBox(String label, T value) {
        super(value);
        this.label = label;
    }
}
NamedBox<Integer> nb = new NamedBox<>("age", 18);  // T 在这里才确定

// 方式 3:扩展类型——子类新增自己的类型参数 K,父类的 V 由子类传入
public class MappedBox<K, V> extends Box<V> {
    private K key;
    public MappedBox(K key, V value) {
        super(value);
        this.key = key;
    }
}
MappedBox<String, Integer> mb = new MappedBox<>("age", 18);  // K=String,V=Integer

8. 实际开发中的注意事项

8.1 泛型与可变参数

可变参数(T... items)允许传入任意数量的参数,等价于传入一个数组,和 Python 的 *args 类似:

// Java
public static <T> List<T> listOf(T... items) { ... }
listOf("a", "b", "c");   // 传几个都行

# Python
def list_of(*args):
    return list(args)
list_of("a", "b", "c")

Python 还有 **kwargs 接收键值对,Java 没有直接对应的语法,键值对一般用 Map 传入。

为什么编译器会报警告?

泛型 + 可变参数组合在一起有一个潜在风险。可变参数底层是数组,而 Java 的泛型数组会有类型安全问题:

public static <T> T[] toArray(T... items) {
    return items;  // 运行时 T 已被擦除,实际是 Object[]
}

String[] arr = toArray("a", "b");  // 运行时抛 ClassCastException!
// 编译器擦除后实际是:String[] arr = (String[]) new Object[]{"a","b"}
// Object[] 无法强转成 String[]

所以编译器看到泛型可变参数就报警告,提醒你"这里可能有堆污染(Heap Pollution)"。

@SafeVarargs 的含义

如果你确认方法内部只是读取数组元素,没有做危险的转型或写入,就可以加 @SafeVarargs 告诉编译器"我知道这是安全的,不用警告":

// 安全:只是把元素读出来放进 List,没有危险操作
@SafeVarargs
public static <T> List<T> listOf(T... items) {
    return Arrays.asList(items);  // 合法,不会有运行时问题
}

listOf("a", "b", "c");     // ["a", "b", "c"]
listOf(1, 2, 3);           // [1, 2, 3]

注意 @SafeVarargs 只能加在 static 方法、final 方法或构造方法上,因为这些方法不能被重写,能保证安全承诺不会被子类破坏。

8.2 获取泛型类型信息(绕过擦除)

虽然运行时泛型被擦除了,但通过反射可以获取类/字段/方法签名上的泛型信息:

需要反射基础,建议学完第 12 篇后再回来看。

// 这就是为什么 Jackson、Spring 等框架能正确反序列化泛型类型
// 它们通过匿名子类保留泛型信息(TypeReference 模式)

// Jackson 示例
List<User> users = objectMapper.readValue(
    json,
    new TypeReference<List<User>>() {}  // 匿名子类保留了 List<User> 的类型信息
);

8.3 菱形推断(Diamond Inference)

// Java 7+:右侧的类型参数可以省略,编译器从左侧推断
List<String> list = new ArrayList<>();          // 不需要写 new ArrayList<String>()
Map<String, List<Integer>> map = new HashMap<>();

// Java 10+:var 让推断更彻底
var list = new ArrayList<String>();  // 推断为 ArrayList<String>
// 注意:var list = new ArrayList<>(); 推断为 ArrayList<Object>,不是你想要的

9. 小结

主题 关键要点
泛型目的 编译期类型检查,消除强转,类型安全
类型擦除 泛型只存在于编译期,运行时全部变成 Object / 上界类型
擦除代价 不能 new T()、不能用基本类型、不能 instanceof 泛型、运行时获取不到类型参数
extends 边界 <T extends X> 限制 T 必须是 X 的子类型
通配符 ? 表示未知类型;? extends 只读,? super 只写
PECS Producer Extends, Consumer Super
实际应用 TypeReference 绕过擦除、@SafeVarargs、菱形推断

下一篇预告:异常体系——Checked vs Unchecked,try-with-resources 与最佳实践


🎯 如果这篇文章对你有帮助,别忘了点赞、收藏、关注三连!关注我,让你在 Java 学习的道路上不迷路,持续为你带来成体系的 Java 干货~

更多推荐