为什么 Java 泛型这么聪明,结果运行时却像什么都不知道?

举报
喵手 发表于 2025/12/08 21:06:31 2025/12/08
【摘要】 开篇语哈喽,各位小伙伴们,你们好呀,我是喵手。运营社区:C站/掘金/腾讯云/阿里云/华为云/51CTO;欢迎大家常来逛逛  今天我要给大家分享一些自己日常学习到的一些知识点,并以文字的形式跟大家一起交流,互相学习,一个人虽可以走的更快,但一群人可以走的更远。  我是一名后端开发爱好者,工作日常接触到最多的就是Java语言啦,所以我都尽量抽业余时间把自己所学到所会的,通过文章的形式进行输出,...

开篇语

哈喽,各位小伙伴们,你们好呀,我是喵手。运营社区:C站/掘金/腾讯云/阿里云/华为云/51CTO;欢迎大家常来逛逛

  今天我要给大家分享一些自己日常学习到的一些知识点,并以文字的形式跟大家一起交流,互相学习,一个人虽可以走的更快,但一群人可以走的更远。

  我是一名后端开发爱好者,工作日常接触到最多的就是Java语言啦,所以我都尽量抽业余时间把自己所学到所会的,通过文章的形式进行输出,希望以这种方式帮助到更多的初学者或者想入门的小伙伴们,同时也能对自己的技术进行沉淀,加以复盘,查缺补漏。

小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦。三连即是对作者我写作道路上最好的鼓励与支持!

前言

写 Java 的同学,谁没跟泛型打过交道?List<String>Map<String, Integer> 一顿敲,IDE 自动补全用得飞起,编译器也相当贴心地给你各种类型检查。
  但一到运行时,你再一看——怎么感觉泛型全“失忆”了?
List<String>List<Integer> 居然在运行时看起来一模一样?instanceof List<String> 还不给写?数组可以写 new String[10],结果 new List<String>[10] 直接红了?

别急,这一切背后,其实都指向一个关键词:泛型擦除(Type Erasure)
  再加上你经常看到的 ? extends T? super T、“PECS 原则”,这套组合拳要是真正搞明白了,你对 Java 泛型的认知会直接从“能用”升到“掌控”。💪

一、泛型到底是干嘛的?——编译器的“贴心保姆”

先把气氛放轻松一点:泛型的本质,其实是编译期的类型检查 + 自动转型工具

一个最常见的例子:

List list = new ArrayList();
list.add("hello");
String s = (String) list.get(0); // 手动强转

VS

List<String> list = new ArrayList<>();
list.add("hello");
String s = list.get(0); // 不用强转,IDE 还帮你补类型

区别在哪?

  1. 没有泛型时

    • 编译器不管你往 List 里加啥,全是 Object
    • 你自己负责强转,错了就运行时 ClassCastException
  2. 有泛型时

    • 编译器会在 编译阶段 帮你检查类型:

      • 不让你往 List<String> 里塞 Integer
    • 生成的字节码里,依然是用 Object 存,只不过自动插入强转;

    • 换句话说:泛型帮你把一些本该运行时报错的事情,提前到了编译期

重点来了:泛型主要是编译器玩的东西,JVM 本身并不知道 List<String>List<Integer> 有啥差别。
这就是——类型擦除

二、类型擦除(Type Erasure):所谓“运行时失忆”是怎么搞出来的?

1. 擦除的基本思想

一句话概括:

编译期间:泛型超级聪明,类型信息很丰富;
编译之后:泛型类型参数被“抹掉”,变回普通类型。

比如有这么一段代码:

public class Box<T> {
    private T value;

    public void set(T value) {
        this.value = value;
    }

    public T get() {
        return value;
    }
}

如果我们用它:

Box<String> box = new Box<>();
box.set("hello");
String s = box.get();

从源码层面看,T 的类型是 String
但在编译之后,JVM 看到的大概长这样(伪代码,仅用于理解):

public class Box {
    private Object value;

    public void set(Object value) {
        this.value = value;
    }

    public Object get() {
        return value;
    }
}

注意几点:

  1. T 被擦除成了 Object(如果有上界约束,就擦成上界类型,比如 T extends Number 会变成 Number);
  2. 泛型类型参数在 class 文件里没有保留(部分场景会以“签名信息”形式保留给反射用,但运行时类型系统不参与)。

所以运行时你问 JVM:

“这个 Box 里装的是 String 还是 Integer?”

JVM 一脸无辜:

“我只知道是个 Object,你自己看着办。”🤷‍♂️

2. 擦除的具体步骤(简化理解版)

对一个带泛型的类 / 方法,编译器大致会做几件事:

  1. 用上界替换类型参数

    • 如:class Box<T extends Number> → 所有 T 位置都换成 Number
    • 没写上界默认是 Object
  2. 插入必要的强制类型转换

    • 源码中看起来返回的是 T,但擦除后返回 Object 或上界类型;
    • 调用时编译器会在调用处生成强转。
  3. 生成桥接(bridge)方法(多态兼容用)

    • 涉及泛型协变时,会生成“桥方法”,保证重写关系正常,这里先不展开(知道有这回事就行)。

三、泛型擦除带来的各种“你不能”:这些限制不是你笨,是 JVM 真做不到 😅

既然运行时不保存真实的泛型类型,那一堆看起来“理所当然”的操作就做不了了。

下面一条条说:

1. 不能 new T():因为 T 在运行时没有具体类型

public class Box<T> {
    private T value;

    public Box() {
        // 编译错误:Cannot instantiate the type T
        // this.value = new T();
    }
}

为什么?

  • 擦除后 T 只变成了 Object 或上界类型,JVM 不知道你到底想要 new String() 还是 new Integer()
  • new 需要一个具体类型,而不是“某种未知的 T”。

常见替代方案:

  • 传入 Class<T>
public class Box<T> {
    private T value;

    public Box(Class<T> clazz) throws Exception {
        this.value = clazz.getDeclaredConstructor().newInstance();
    }
}

2. 不能创建泛型数组:new List<String>[10] 不行

// 编译错误:
List<String>[] lists = new List<String>[10];

为什么?

  • 数组是真正的运行时类型安全结构(会记录自己的组件类型);

  • 但泛型在运行时被擦除成原始类型:

    • List<String>List<Integer> 都只是 List
  • 如果允许 List<String>[] 存在,就会出现下面这种诡异情况:

Object[] arr = new List<String>[10]; // 假设允许
arr[0] = new ArrayList<Integer>();   // 运行时数组检查只看到是 List,放行
// 结果你以为第 0 个位置是 List<String>,其实里面塞了 Integer,彻底乱套

所以 Java 干脆直接禁止创建泛型数组,宁可你烦一点,也不让你作死。

常用替代:

List<String> list = new ArrayList<>();        // 用集合代替数组
List<List<String>> outer = new ArrayList<>(); // 多层嵌套用集合组合

3. 不能在静态上下文使用类型参数

public class Box<T> {
    // 编译错误:Cannot make a static reference to the non-static type T
    // private static T value;

    // 也不允许:
    // public static T create() { ... }
}

原因也很直观:

  • 泛型参数 T和实例绑定的;
  • static和类绑定的,跟具体实例无关;
  • 类加载时还不知道会不会有人用 Box<String> 还是 Box<Integer>,所以静态上下文没有办法知道 T 是啥。

4. 不能 instanceof 精确判断泛型参数

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

// 编译错误:
/*
if (list instanceof List<String>) {
}
*/

// 正确写法只能是:
if (list instanceof List) {
    // ...
}

因为擦除后,运行时只有 List 这个信息,<String> 早就没了。

5. 不能捕获具体泛型异常

// 编译错误:Generic class may not extend java.lang.Throwable
// class MyException<T> extends Exception {}

// 也不能:catch MyException<String> e

JVM 的异常系统只认“具体类型的 Throwable 子类”,不可能在运行时根据 <String> <Integer> 区分不同异常类,泛型异常直接被禁止。

6. 重载与擦除冲突:看起来不一样,其实一个样

public class Demo {
    // 编译错误:name clash
    public void test(List<String> list) {}
    public void test(List<Integer> list) {}
}

编译器抗议说:“对不起,擦除之后你这两个方法长得一模一样。”
因为擦除后变成:

public void test(List list) {}
public void test(List list) {}

方法签名重复,JVM 无法区分,直接报错。

四、通配符 ?、extends、super:你以为只是语法糖,结果是门哲学课 😆

1. 为什么需要通配符?

先看一个很多同学一开始都会踩的坑:

List<String> strings = new ArrayList<>();
List<Object> objects = new ArrayList<>();

// 编译错误:incompatible types
// objects = strings;

很多人心里会想:

StringObject 的子类,那 List<String> 不就是 List<Object> 的子类吗?”

——不是!

泛型在 Java 里是不变的(invariant)

  • StringObject 的子类 ✅
  • List<String>List<Object> 没有继承关系 ❌

这会导致:你想写一个方法“接收任何类型的 List”,结果写成这样:

public void printList(List<Object> list) { ... }

然后你传入 List<String>,会直接编译错误。
于是 Java 引入了一个东西:通配符 ?

2. ?:啥都行,但你就别乱写了

如果你只想说“这里是一个装着某种什么类型的 List,具体是什么我不关心”,可以写:

public static void printList(List<?> list) {
    for (Object o : list) {
        System.out.println(o);
    }
}

你现在可以开心地传任何类型的 List:

printList(new ArrayList<String>());
printList(new ArrayList<Integer>());
printList(new ArrayList<Double>());

但请注意——你基本上不能往 List<?> 里加东西(除了 null):

public static void addSomething(List<?> list) {
    // 编译错误:
    // list.add("abc");
    // list.add(123);
    list.add(null); // 只允许这个
}

原因很简单:

  • 编译器不知道 ? 真实类型是啥;
  • 有可能是 List<String>,也可能是 List<Integer>
  • 你往里面加 Object,也许会破坏原本的类型安全;
  • 所以索性禁止写入(除了 null,对谁都安全)。

一句话:List<?>我什么都能看(读),但我尽量少动(写)。

五、extendssuper:PECS 原则是怎么来的?

终于到了经典口诀时间——PECS 原则

Producer Extends, Consumer Super
生产者用 extends,消费者用 super

这是记住 ? extends T? super T 的最好方法之一。

1. ? extends T:上界通配符 → “我只生产,不消费”

List<? extends Number> list = new ArrayList<Integer>();

意思是:

  • list 是一个“装着某种 Number 子类型 的 List”;
  • 具体是 List<Integer>List<Double>,编译器不知道,也不在乎;
  • 你可以安全地“读出来当 Number 用”;
  • 但你不能随便往里写。

例子:

public static double sum(List<? extends Number> list) {
    double result = 0;
    for (Number n : list) {
        result += n.doubleValue();
    }
    return result;
}

你可以传:

List<Integer> ints = List.of(1, 2, 3);
List<Double> doubles = List.of(1.1, 2.2);

System.out.println(sum(ints));
System.out.println(sum(doubles));

但你不能这样:

public static void addNumber(List<? extends Number> list) {
    // 编译错误:
    // list.add(1);
    // list.add(1.0);

    list.add(null); // 依旧只允许 null
}

因为:

  • 编译器不知道 list 里到底是 Integer 还是 Double 或者别的 Number 子类;
  • 你贸然塞一个 Integer 进去,如果实际是 List<Double>,类型安全就挂了;

所以:

? extends T 的集合适合“生产” T(从里面拿 T),不适合“消费” T(往里面放东西)。

2. ? super T:下界通配符 → “我只消费,不生产”

List<? super Integer> list = new ArrayList<Number>();

意思是:

  • list 是一个“某种 Integer 的父类型 的 List”;
  • 可能是 List<Integer>,也可能是 List<Number>List<Object>

这时:

  • 可以安全地往里面加 Integer 或其子类
  • 但从里面读出来,只能当 Object 看。

例子:

public static void addAllIntegers(List<? super Integer> list) {
    list.add(1);
    list.add(2);
    list.add(3);
}

你可以这样用:

List<Number> nums = new ArrayList<>();
addAllIntegers(nums);

List<Object> objs = new ArrayList<>();
addAllIntegers(objs);

但你从里面读的时候:

Object o = list.get(0); // 只能是 Object
// Integer i = list.get(0); // 编译错误

因为编译器只知道这是某个“Integer 的父类型”,但不确定是啥,干脆按最安全的 Object 处理。

于是我们得到结论:

? super T 的集合适合“消费” T(往里放 T),不适合“生产”精确类型的 T(读出来只能是 Object)。

3. PECS 一句记牢

Producer Extends, Consumer Super:

  • 如果一个集合是数据的生产者,你只是从里面拿数据:
    → 用 ? extends T

  • 如果一个集合是数据的消费者,你只是往里面塞数据:
    → 用 ? super T

这个原则在很多 JDK 源码里都有体现,其中一个经典例子就是 Collections.copy

六、综合例子:用一段 copy 方法串起泛型 + 通配符 + PECS

看这段方法定义(接近 JDK 的实现):

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:数据来源,是一个“生产 T 的集合”,只从里面读 → ? extends T
  • dest:数据目的地,是一个“消费 T 的集合”,只往里面写 → ? super T

使用方式:

List<Integer> src = List.of(1, 2, 3);
List<Number> dest = new ArrayList<>(Arrays.asList(0, 0, 0));

copy(dest, src); // OK:src 生产 Integer,dest 消费 Integer(Number 的子类)

这段签名如果你看懂了,那恭喜,你已经跨过了很多人卡住的那道“泛型理解门槛”。

七、? / extends / super 到底差在哪?来个对比小表格 ✅

写法 能接受的实参类型(以 List<...> 为例) 能不能 add 元素? 读取类型是什么? 典型场景
List<?> 任何 List<X> 不能(除 null Object 只读遍历
List<? extends Number> List<Integer>List<Double> 基本不能(除 null 至少是 Number 只读 Number 数据(生产者)
List<? super Integer> List<Integer>List<Number>List<Object> 可以安全添加 Integer 及其子类 读取时只有 Object 收集 / 插入 Integer(消费者)
List<Integer> 只能 List<Integer> 可以添加 Integer Integer 最正常的使用方式

配合 PECS 提醒自己:

  • 要从集合里“拿 T” → ? extends T
  • 要往集合里“放 T” → ? super T
  • 什么都说不清,就先写 <?>(但记得你几乎不能往里写)。

八、最后整理一下:如何优雅地和 Java 泛型相处?💡

到这儿我们大概把你一开始的问题串了一遍:

  1. 泛型擦除是啥?

    • 编译时照顾你,帮你检查类型、插转型;
    • 运行时类型参数被擦除成上界(默认 Object),JVM 不知道 T 是谁。
  2. 擦除带来了哪些限制?

    • 不能 new T()
    • 不能创建 new List<String>[] 这样的泛型数组;
    • 不能在静态上下文里用类型参数;
    • 不能 instanceof List<String>
    • 不能搞泛型异常、按泛型类型重载方法。
  3. 通配符 ?extendssuper 到底怎么用?

    • ?:我什么类型都能接受,但基本不给你写;
    • ? extends T:生产者,帮我“产出” T(只读);
    • ? super T:消费者,帮我“消费” T(只写)。
  4. PECS 原则怎么落地?

    • 数据来源(只读):List<? extends T>
    • 数据目的地(只写):List<? super T>
    • copy 这类方法的签名就是经典示范。

如果你看到这里,还愿意再抬头回去看看自己项目里的各种 List<?>Map<String, ? extends Something>,你会发现:
  以前那些“看着就头大”的写法,其实每一个都有脾气、有性格、有理由。

下一次当你写出一个带 <? super T> 的方法签名时,不妨在心里小小地得瑟一句:

“这玩意儿不是 IDE 自动生成的,是我知道它为什么要这么写。”😉

… …

文末

好啦,以上就是我这期的全部内容,如果有任何疑问,欢迎下方留言哦,咱们下期见。

… …

学习不分先后,知识不分多少;事无巨细,当以虚心求教;三人行,必有我师焉!!!

wished for you successed !!!


⭐️若喜欢我,就请关注我叭。

⭐️若对您有用,就请点赞叭。
⭐️若有疑问,就请评论留言告诉我叭。


版权声明:本文由作者原创,转载请注明出处,谢谢支持!

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。