01 泛型深入

泛型(Generics)是 Java 5 引入的特性,也是 C++ 背景读者最容易”想当然”的部分——它看起来像模板,用起来像模板,但实现机制与模板截然不同。入门篇你已经会用 List<String>、会定义简单的泛型类,本章深入三个层次:类型擦除的底层原理、通配符与 PECS 原则、以及泛型在数组与桥方法上的种种限制。理解了擦除,你就理解了 Java 泛型的一切”怪癖”。

前置知识:数组继承与多态。本章建议边读边用 javap -c 观察字节码验证结论。


一、为什么需要泛型:从 Object 的缺陷说起

没有泛型的年代(JDK 5 之前),集合只能装 Object

import java.util.ArrayList;
import java.util.List;
 
public class BeforeGenerics {
    public static void main(String[] args) {
        List list = new ArrayList();      // 装的是 Object
        list.add("hello");
        list.add(42);                     // 什么都能塞,编译器不管
 
        String s = (String) list.get(0);  // 取出来必须强转
        // String t = (String) list.get(1); // 运行时 ClassCastException!
        System.out.println(s);
    }
}

两个痛点:

  1. 取元素必须强制转换,样板代码满天飞;
  2. 错误推迟到运行时才暴露——把 Integer 塞进本应只装字符串的列表,编译器毫无察觉。

泛型把类型检查提前到编译期:

import java.util.ArrayList;
import java.util.List;
 
public class WithGenerics {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("hello");
        // list.add(42);            // 编译错误!类型不匹配,运行前就被拦下
        String s = list.get(0);     // 无需强转,编译器已知元素类型
        System.out.println(s);
    }
}
维度用 Object用泛型
类型检查时机运行时(可能 ClassCastException)编译期
取出元素必须强转自动为目标类型
能装的类型一切(失去约束)明确约束的类型及其子类
可读性差,看不出容器意图好,Map<String, List<Integer>> 一目了然

二、类型擦除:泛型只存在于编译期

2.1 核心事实

Java 泛型采用**擦除法(Type Erasure)**实现:泛型信息只在编译期用于类型检查,编译生成的字节码中不包含泛型参数,运行时 JVM 看到的只是原始类型

验证一下:

import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
 
public class ErasureProof {
    public static void main(String[] args) throws Exception {
        List<String> a = new ArrayList<>();
        List<Integer> b = new ArrayList<>();
 
        // 运行时两个对象的 Class 完全相同!
        System.out.println(a.getClass() == b.getClass());   // true
 
        // 反射看 ArrayList 内部的存储字段
        Field f = ArrayList.class.getDeclaredField("elementData");
        System.out.println(f.getType());                    // class [Ljava.lang.Object;
 
        // 甚至可以用反射绕过泛型检查,往 List<String> 里塞 Integer
        List<String> strings = new ArrayList<>();
        strings.add("hello");
        strings.getClass()                       // 擦除后的原始 Class
               .getMethod("add", Object.class)   // add 方法签名就是 add(Object)
               .invoke(strings, 42);
        System.out.println(strings);             // [hello, 42] —— 脏数据进来了
    }
}

javap -c ErasureProof 查看字节码,对 strings.get(0) 的调用被编译成:

invokeinterface #x {InterfaceMethod java/util/List.get:(I)Ljava/lang/Object;}
checkcast     #y    // class java/lang/String

get 的返回类型就是 Object,随后由一条 checkcast 指令完成”自动强转”。所谓 List<String>,本质上是编译器替你写好了强转和类型检查

2.2 擦除规则

泛型声明擦除后
T(无边界)Object
T extends NumberNumber
T extends Comparable<T>Comparable
字段/方法签名中的 T替换为擦除类型
调用处的返回值编译器插入 checkcast 强转
// 源码
class Box<T extends Number> {
    T value;
}
// 擦除后等价于
class Box {
    Number value;   // T 被 Number 替换
}

2.3 擦除带来的限制清单

因为运行时不存在 T,以下操作全部非法:

public class Limits<T> {
    // 1. 不能 new T()——编译器无法确定调用哪个构造器
    // T obj = new T();
 
    // 2. 不能 new 泛型数组(详见第六节)
    // T[] arr = new T[10];
 
    // 3. 不能 instanceof 泛型类型——运行时无 T 信息
    // public boolean check(Object o) {
    //     return o instanceof T;
    // }
 
    // 4. 泛型参数不能用基本类型
    // List<int> bad;   // 编译错误:只能 List<Integer>
}

每条限制的原因都指向同一件事:运行时不知道 T 是什么

非法操作原因变通方案
new T()无法确定构造器传入 Supplier<T> 或反射
new T[n]与数组的运行时类型检查冲突(T[]) new Object[n] + 抑制警告
o instanceof T擦除后 T 不存在传入 Class<T>,用 clazz.isInstance(o)
List<int>泛型参数必须是引用类型用包装类 List<Integer>(有装箱开销)
静态成员引用 TT 属于实例而非类改用方法级泛型参数

2.4 为什么 Java 选择擦除而不是模板展开

JDK 5 引入泛型时面临硬约束:必须兼容存量代码。当时全球已有海量无泛型的字节码和类库,如果像 C++ 那样为每个实例化生成独立代码,List<String>List<Integer> 就是两个不同的类,所有旧集合 API 全部报废。擦除法让 List<String> 在虚拟机层面就是 List,新旧代码无缝互操作。代价则是本章列举的种种限制——这是历史包袱下的工程折中。

2.5 擦除的编译期流程

flowchart LR
    A["源码<br/>List&lt;String&gt; list"] --> B["javac 类型检查"]
    B -- 不通过 --> ERR["编译错误"]
    B -- 通过 --> C["擦除:泛型参数替换为上界<br/>方法签名去泛型化"]
    C --> D["调用处插入 checkcast 强转"]
    D --> E["字节码:只有原始类型 List<br/>Signature 属性保留元数据"]

三、通配符与 PECS 原则

3.1 泛型没有继承协变

先看一个反直觉的事实:

import java.util.ArrayList;
import java.util.List;
 
public class NoCovariant {
    public static void main(String[] args) {
        // Object 是 String 的父类
        // 但 List<Object> 不是 List<String> 的父类!
        List<String> strings = new ArrayList<>();
        // List<Object> objects = strings;   // 编译错误!
 
        // 原因:如果允许,下面的代码将破坏类型安全
        // List<Object> objects = strings;    // 假设允许
        // objects.add(42);                   // 往 List<String> 里塞了 Integer
        // String s = strings.get(0);         // 取出来爆炸
    }
}

数组恰恰相反——Java 数组是协变的

Object[] arr = new String[10];   // 编译通过(数组协变)
arr[0] = 42;                     // 编译通过,运行时 ArrayStoreException!

数组把类型错误推迟到运行时,泛型把它拦截在编译期。这正是《Effective Java》建议”优先用列表而非数组”的原因之一。

3.2 三种通配符

写法名称含义
List<?>无界通配符某种未知类型,等价于 ? extends Object
List<? extends T>上界通配符T 或 T 的某个子类(生产者)
List<? super T>下界通配符T 或 T 的某个父类(消费者)
import java.util.ArrayList;
import java.util.List;
 
public class WildcardDemo {
    public static void main(String[] args) {
        List<Integer> ints = List.of(1, 2, 3);
        List<Double> doubles = List.of(1.5, 2.5);
        List<Number> nums = new ArrayList<>();
 
        System.out.println(sum(ints));     // 6.0
        System.out.println(sum(doubles));  // 4.0
        System.out.println(sum(nums));     // 0.0
 
        fillWithZero(nums);
        System.out.println(nums);          // [0, 0, 0]
    }
 
    // 上界通配符:只读取元素(求和)
    // ? extends Number 表示"某种 Number 子类型的列表"
    public static double sum(List<? extends Number> list) {
        double total = 0;
        for (Number n : list) {    // 可以读为 Number
            total += n.doubleValue();
        }
        // list.add(1);           // 编译错误!不能往里写(不知道具体是什么子类)
        return total;
    }
 
    // 下界通配符:只写入元素(填充)
    // ? super Integer 表示"Integer 或其某种父类型的列表"
    public static void fillWithZero(List<? super Integer> list) {
        for (int i = 0; i < list.size(); i++) {
            list.set(i, 0);        // 可以写 Integer 进去
        }
        // Object o = list.get(0);  // 读出来只能是 Object
    }
}

3.3 PECS 原则

Joshua Bloch 在《Effective Java》中总结为 PECS:Producer Extends, Consumer Super

  • 如果参数是生产者(你从中读取数据)-> 用 ? extends T
  • 如果参数是消费者(你向其中写入数据)-> 用 ? super T
  • 又读又写 -> 不用通配符,精确声明类型

标准库的例子——Collections.copy

public static <T> void copy(List<? super T> dest, List<? extends T> src)
// src 只被读 -> extends;dest 只被写 -> super
场景通配符选择理由
方法从集合读数据计算List<? extends T>任何 T 子类的列表都能传入
方法向集合写数据List<? super T>能装 T 的容器必然能装它的父类型引用
JDK 的 Optional.or 返回Supplier<? extends T>生产者
线程池 execute(Runnable)不用通配符Runnable 层次简单,无必要

四、泛型方法与边界约束

4.1 泛型方法

泛型参数声明在方法上,独立于类的泛型参数:

import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
 
public class GenericMethods {
 
    // 最简单的泛型方法:<T> 声明在返回类型之前
    public static <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);
    }
 
    // 带边界:要求 T 可比较,才能调用 compareTo
    public static <T extends Comparable<T>> T max(List<T> list) {
        if (list.isEmpty()) {
            throw new IllegalArgumentException("空列表");
        }
        T best = list.get(0);
        for (T item : list) {
            if (item.compareTo(best) > 0) {
                best = item;      // 只有 Comparable 边界才允许调 compareTo
            }
        }
        return best;
    }
 
    // 泛型 + Supplier 解决"无法 new T()"的问题
    public static <T> List<T> filledList(int size, Supplier<T> maker) {
        List<T> result = new ArrayList<>();
        for (int i = 0; i < size; i++) {
            result.add(maker.get());   // 构造逻辑交给调用方
        }
        return result;
    }
 
    public static void main(String[] args) {
        List<String> words = new ArrayList<>(List.of("banana", "apple", "cherry"));
        swap(words, 0, 2);
        System.out.println(words);                    // [cherry, apple, banana]
        System.out.println(max(words));               // cherry
 
        List<Integer> zeros = filledList(3, () -> 0);
        System.out.println(zeros);                    // [0, 0, 0]
    }
}

4.2 多重边界

一个类型参数可以有多个边界,但类(若有)必须写在第一位

// T 必须既是 Comparable 的子类型,又实现 Serializable
public static <T extends Comparable<T> & java.io.Serializable> void sort(T[] a) {
    java.util.Arrays.sort(a);   // Arrays.sort 要求元素可比较
}

注意:边界只能有一个类,其余必须是接口。这与 C++ 模板的鸭子类型完全不同——C++ 只要语法上能调 operator< 就通过实例化,Java 必须用边界显式声明。


五、桥方法

泛型与继承多态相遇时会产生一个编译器偷偷生成的产物——桥方法(Bridge Method)

import java.lang.reflect.Method;
 
class MyString implements Comparable<MyString> {
    private final String value;
    MyString(String v) { this.value = v; }
 
    @Override
    public int compareTo(MyString other) {     // 参数类型是 MyString
        return this.value.compareTo(other.value);
    }
}
 
public class BridgeMethodDemo {
    public static void main(String[] args) throws Exception {
        // 接口 Comparable<T> 擦除后方法是 compareTo(Object)
        // 而 MyString 实现的是 compareTo(MyString)
        // 为了让"接口多态"继续工作,编译器生成桥方法:
        // public int compareTo(Object o) { return compareTo((MyString) o); }
 
        for (Method m : MyString.class.getDeclaredMethods()) {
            System.out.println(m + "  isBridge=" + m.isBridge());
        }
        // 输出类似:
        // public int MyString.compareTo(MyString)  isBridge=false
        // public int MyString.compareTo(java.lang.Object)  isBridge=true
    }
}

擦除后 Comparable<T> 变成 Comparable,其抽象方法是 compareTo(Object);但子类实现的是 compareTo(MyString),签名不同、不构成覆盖。为了让 Comparable c = myString; c.compareTo(otherObj) 这类多态调用正常分发,编译器自动插入一个 compareTo(Object) 桥方法,内部强转后再委托给真正的实现。

要点说明
谁生成javac 自动生成,源码不可见
标识Method.isBridge() 返回 true,字节码标志 ACC_BRIDGE
反射陷阱getDeclaredMethods() 会列出桥方法,遍历时需过滤,否则可能重复处理
与 C++ 对比无此问题——模板每个实例化都是独立类,签名天然一致

六、泛型数组限制

6.1 为什么 new T[n] 非法

数组的两个特性冲突了:

  1. 数组携带运行时类型,创建时就要确定,且 ArrayStoreException 依赖它做写入检查;
  2. 擦除后运行时不知道 T 是什么,JVM 无法创建”T 数组”。
public class GenericArrayIssue<T> {
    private final T[] items;
 
    @SuppressWarnings("unchecked")
    public GenericArrayIssue(int capacity) {
        // 唯一可行方案:造 Object 数组再强转
        // 强转 (T[]) 实际不发生——擦除后就是 Object[]
        items = (T[]) new Object[capacity];   // OK,仅编译器警告
    }
 
    public void set(int idx, T val) { items[idx] = val; }
    public T get(int idx) { return items[idx]; }
 
    public static void main(String[] args) {
        GenericArrayIssue<String> box = new GenericArrayIssue<>(10);
        box.set(0, "hello");                  // 正常
        System.out.println(box.get(0));
        // box.items 本身若泄漏出去,外部拿到的是 Object[],
        // 可以塞入任意对象 —— 所以绝不要把 items 字段暴露出去
    }
}

6.2 varargs 泛型的堆污染

可变参数本质是数组,于是泛型 varargs 有一个著名漏洞:

import java.util.ArrayList;
import java.util.List;
 
public class HeapPollution {
    public static void main(String[] args) {
        // 下面这行能通过编译,但存在隐患:
        List<String>[] lists = createLists(List.of("a"), List.of("b"));
        Object[] objs = lists;              // 数组协变,合法
        objs[0] = List.of(42);              // 运行时不报错!泛型检查已擦除
        // String s = lists[0].get(0);      // 此处才会 ClassCastException
 
        // 正确姿势:直接用 List<List<String>>
        List<List<String>> safe = List.of(List.of("a"), List.of("b"));
        System.out.println(safe);
    }
 
    @SafeVarargs   // 声明"我不会往这个数组里写东西",消除调用方警告
    static <T> List<T>[] createLists(List<T>... arrays) {
        return arrays;
    }
}

术语叫堆污染(Heap Pollution):变量的运行时类型与声明的静态类型不一致。规则很简单——优先使用 List<...> 而非数组来持有泛型数据


七、与 C++ 模板深度对比

这是 C/C++ 背景读者最关心的一节。两者解决同一个问题(编写与类型无关的代码),但哲学完全相反。

维度Java 泛型C++ 模板
实现机制类型擦除,一份字节码编译期按类型展开,每份实例化独立代码
实例化时机无实例化概念,运行时统一为擦除类型编译期逐个实例化(code bloat 来源之一)
类型检查时机声明处即检查(边界约束)使用处检查(隐式接口,缺成员则实例化报错)
特化不支持支持(全特化/偏特化,如 vector<bool>
非类型参数不支持支持 template<int N>
元编程能力几乎没有(反射可部分模拟)SFINAE、constexpr、 Concepts 前时代 TMP
运行时获取类型参数不能(擦除)可以(typeid、模板实参推导)
基本类型支持必须装箱(List<int> 非法)直接支持 vector<int>,零开销
性能开销装箱拆箱 + 强转 checkcast通常零开销抽象
错误信息质量清晰(编译器直接报边界不符)历史性灾难(模板报错几十行),Concepts 后改善
语法<T extends Bound> 显式约束C++20 Concepts 之前靠约定

几个关键差异展开:

1. 一份代码 vs N 份代码

Java 中 ArrayList<String>ArrayList<Integer> 在虚拟机里是同一个类;C++ 中 vector<string>vector<int>两套独立编译的机器码。后者换来零抽象成本,前者换来体积小、动态加载友好、二进制兼容。

2. SFINAE vs 边界

C++ 经典技巧”替换失败不是错误”:根据表达式是否合法在重载决议中选择模板。Java 没有对应物——约束必须在声明处用 extends 写明,表达力弱但可读性和报错质量远胜。可以说 Java 用”表达力”换了”工程可用性”。

3. Project Valhalla 的方向

Oracle 的 Valhalla 项目正在探索具化泛型(Specialized Generics),目标是让 List<int> 合法、消除装箱,同时保持擦除的二进制兼容。这相当于向 C++ 模板借性能,值得持续关注(见 Java 新特性)。


八、通配符捕获助手模式

通配符类型有一个令人困惑的限制:List<?> 不能直接传给接受 List<T> 的泛型方法。解决方法是通配符捕获(Wildcard Capture)

import java.util.List;
 
public class WildcardCapture {
 
    // 问题:swap 需要两个类型完全相同的 List
    // 但 swap(List<?>, int, int) 里 ? 是未知的,编译器不信任
    // static <T> void badSwap(List<? extends T> a, int i, int j) {
    //     T temp = a.get(i);   // OK
    //     a.set(i, a.get(j));  // 编译错误!a 的元素类型是 ? extends T,不能 set
    // }
 
    // 解法:通配符捕获——用一个非通配符的泛型方法"接住"通配符
    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);
    }
 
    // 包装方法:捕获通配符并委托给上面的精确类型方法
    static void wildcardSwap(List<?> list, int i, int j) {
        swapHelper(list, i, j);   // 编译器推断 T 为 list 的实际元素类型
    }
 
    static <T> void swapHelper(List<T> list, int i, int j) {
        swap(list, i, j);
    }
 
    public static void main(String[] args) {
        List<Integer> nums = List.of(1, 2, 3);
        wildcardSwap(nums, 0, 2);
        System.out.println(nums);   // [3, 2, 1]
    }
}

为什么需要这个模式? 因为通配符 ? 是匿名类型,你没法直接声明一个 ? 类型的局部变量。捕获助手通过泛型方法让编译器推断出具体的 T,然后在 T 上操作就自然可以 get 和 set 了。标准库中 Collections.reverse() 就用了这个模式。


九、泛型方法 vs 泛型类

// 泛型类:T 属于整个类的实例,每个实例的 T 在创建时固定
class Box<T> {
    private T value;
    Box(T value) { this.value = value; }
    T getValue() { return value; }
}
 
// 泛型方法:T 只属于方法调用,每次调用可推断不同类型
class Tool {
    static <T> T first(List<T> list) {
        return list.isEmpty() ? null : list.get(0);
    }
 
    static <T extends Comparable<T>> T min(T a, T b) {
        return a.compareTo(b) <= 0 ? a : b;
    }
}
 
public class GenericVsMethod {
    public static void main(String[] args) {
        // 泛型类:声明时指定 T
        Box<String> box = new Box<>("hello");
 
        // 泛型方法:调用时推断 T,无需声明
        String s = Tool.first(List.of("a", "b"));       // T 推断为 String
        Integer n = Tool.min(3, 5);                      // T 推断为 Integer
        // Tool.min("a", 5);   // 编译错误:String 和 Integer 不能共同满足 T
    }
}
维度泛型类泛型方法
T 的作用域整个类实例单次方法调用
声明位置class Box<T>static <T> T first(List<T>)
类型固定时机new Box<>()编译器调用时自动推断
典型场景容器、缓存、DAO工具方法、静态工厂

经验法则: 如果 T 只在一个方法里用,写泛型方法;如果整个类都要持有一个类型参数,写泛型类。


小结

知识点一句话
类型擦除泛型只在编译期存在,运行时全是原始类型
通配符读用 extends,写用 super,PECS 口诀
边界T extends Comparable<T> 才能调用约束的方法
桥方法编译器为擦除后的多态补的垫片,反射时要过滤
泛型数组new T[n] 非法,优先用 List<...>
vs C++ 模板擦除换兼容,模板换性能与元编程
通配符捕获用泛型助手方法接住通配符,让编译器推断具体类型
泛型方法 vs 类方法级 T 用方法泛型,类级 T 用类泛型


练习

题号题目链接知识点
23合并K个升序链表https://leetcode.cn/problems/merge-k-sorted-lists/泛型、分治
146LRU缓存https://leetcode.cn/problems/lru-cache/泛型、数据结构