Java 泛型都懂了?那你知道自己其实在和“假泛型”谈恋爱吗?

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

开篇语

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

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

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

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

一、前言:那个写着 <T> 却什么也看不见的夜晚

老实说,我第一次听到“类型擦除”这四个字的时候,脑子里蹦出来的画面是:编译器拎着个橡皮,把我写的 <T><K, V> 一顿猛擦,然后一脸无辜地说:“我都帮你抹平了,你还要啥类型信息?”

后来写项目写多了,才发现:Java 泛型这玩意儿,80% 的时间在保你平安,20% 的时间在背后捅你一刀。尤其是当你开始问这种问题时:

  • List<String>List<Integer> 在运行期到底有什么区别?
  • 为啥不能写 new List<String>[10],数组到底惹谁了?
  • 明明有泛型,instanceof 却好像看不见它?
  • 为什么我重写方法的时候,编译器给我“送”了个桥接方法(bridge method)

如果你也被这些东西恶心过,那这篇就当是一次“和 Java 泛型坦诚相见”的深度对话:
我们不聊背教程的定义,专门聊:类型擦除到底干了啥、它坑在哪、以及我们该怎么优雅地和它共处。


二、先讲人话:Java 泛型到底是“真泛型”还是“贴纸泛型”?

很多语言(比如 Kotlin、C#、部分 JVM 语言)会搞所谓的 “reified generics”,也就是运行期保留类型信息。而 Java 的玩法比较“老派”:

Java 泛型是“编译期泛型 + 运行期擦除”的组合拳。

通俗翻译一下:

  • 编译期:泛型很认真,帮你做各种类型检查,拒绝你把 Dog 塞进 List<Cat> 里;
  • 运行期:类型信息被“擦除”,List<String>List<Integer> 都长成一个样子——就是个 List

2.1 来个小实验:List<String>List<Integer> 到底一样不一样?

import java.util.ArrayList;
import java.util.List;

public class ErasureDemo {
    public static void main(String[] args) {
        List<String> stringList = new ArrayList<>();
        List<Integer> intList = new ArrayList<>();

        System.out.println(stringList.getClass() == intList.getClass()); // true
        System.out.println(stringList.getClass()); // class java.util.ArrayList
    }
}

输出:

true
class java.util.ArrayList

所以很现实的一点是:
你以为你在操作 List<String>,但 JVM 眼里真的就只是个 ArrayList 而已。

编译器在前面辛辛苦苦帮你守门,运行到 JVM 的时候——类型信息已经被剃得干干净净


三、类型擦除到底“擦”了什么?规则说清楚了其实没那么玄

我们老说“擦除擦除”,那问题来了:到底是怎么擦的?是全擦,还是有选择地擦?

简单分三种情况来聊:

  1. 普通泛型类 / 泛型接口
  2. 有上界(extends)的泛型
  3. 通配符(? extends? super)这种“更模糊的”类型

3.1 普通泛型类:<T> 最后都变成了啥?

来看个极简泛型类:

public class Box<T> {
    private T value;

    public Box(T value) {
        this.value = value;
    }

    public T getValue() {
        return value;
    }
}

类型擦除后的“精神版本”大概是这样:

public class Box {
    private Object value;

    public Box(Object value) {
        this.value = value;
    }

    public Object getValue() {
        return value;
    }
}

要点:

  • 没有边界的泛型参数 <T>会被擦除成 Object
  • 所有使用到 T 的地方,都统一替换成擦除后的类型。

编译器其实做了两件事:

  1. 在你写代码的时候,帮你做了类型检查;
  2. 编译完成后,把你写的泛型“抹平成”非泛型的版本。

3.2 有上界的泛型:<T extends Number> 又是什么样?

public class NumberBox<T extends Number> {
    private T value;

    public NumberBox(T value) {
        this.value = value;
    }

    public T getValue() {
        return value;
    }
}

擦除后长这样:

public class NumberBox {
    private Number value;

    public NumberBox(Number value) {
        this.value = value;
    }

    public Number getValue() {
        return value;
    }
}

规则很简单:

有上界:擦除成上界类型
没上界:擦除成 Object

如果你写的是多上界如:

class Foo<T extends Number & Comparable<T>> { }

擦除的时候会擦成:

class Foo {
    // 擦成第一个上界类型 Number
}

后面的 Comparable<T> 在字节码里会保留在接口约束信息里,但不会作为真正的“擦除类型”。


四、桥接方法(Bridge Method):你以为是多写了一次,其实是编译器救了你一命

桥接方法是很多人看字节码时最懵的地方之一:

“我明明没写这个方法啊,谁给我加的?”

来个经典例子:

public class Parent<T> {
    public T getValue() {
        return null;
    }
}

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

你以为擦除之后是这样:

public class Parent {
    public Object getValue() {
        return null;
    }
}

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

但问题来了:

  • 父类方法签名:Object getValue()
  • 子类方法签名:String getValue()

这俩在 JVM 层面来说,不是同一个方法签名,那就意味着——子类没有“真正重写”父类的方法
这可不行,Java 的“重写语义”不能崩。

所以编译器会悄悄给你补一个桥接方法

public class Child extends Parent {
    // 桥接方法(编译器自动生成)
    @Override
    public Object getValue() {
        // 调用真正的 String 版本
        return getValue();
    }

    // 你写的那个
    public String getValue() {
        return "child";
    }
}

这就是为啥你用反射或者看字节码的时候,会看到多一个 synthetic 的方法。这玩意儿就是编译器负责“擦屁股”的:在类型擦除之后维持住重写语义的一致。


五、类型擦除给我们挖的“坑”:你踩过几个?

理论都懂了不算本事,关键是——**哪些地方经常被它恶心?**我们直接点几个高频坑位。

5.1 经典噩梦:不能创建泛型数组

你肯定见过这样的报错:

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

编译器非常不爽地告诉你:“Generic array creation” 不允许。

为什么?

  • 数组在 Java 里是协变的String[] 是可以赋值给 Object[] 的;
  • 泛型是擦除的 + 不协变List<String> 不能赋值给 List<Object>

如果 Java 允许这样写:

List<String>[] array = new ArrayList<String>[10];
Object[] objArray = array; // 合法,因为数组协变

objArray[0] = new ArrayList<Integer>(); // 运行期才出事
List<String> list = array[0]; // 你以为全是 String

这时候,编译器已经没法救你了,因为泛型在运行期被擦掉了,array[0] 里到底是 List<String> 还是 List<Integer>,JVM 根本分不清。
所以干脆直接在编译期就禁止你创建泛型数组

实战建议:

  • 想要类似 List<T>[] 的结构,优先用 List<List<T>> 代替;
  • 或者用 @SuppressWarnings("unchecked") + 强转,但请确认你真的知道自己在干嘛。

5.2 instanceof 看不见泛型参数

List<String> list = new ArrayList<>();
if (list instanceof List<String>) {  // ❌ 编译错误
}

报错信息大意就是:不能对带泛型参数的类型使用 instanceof

正确写法:

if (list instanceof List) {
    // 只能这样
}

原因也很简单:

  • 运行期没有 List<String> 这个具体类型,只有 List(或 ArrayList);
  • 所以你只剩下 list instanceof List 这种“粗糙”的判断。

想要在运行期做更细的检查,一般只能结合反射 + 约定,或者在逻辑层用别的方式保证。

5.3 方法重载 + 泛型擦除:看上去不冲突,其实编译不过

比如你想要搞个重载:

public void print(List<String> list) { }

public void print(List<Integer> list) { } // 编译错误

编译器直接给你一巴掌:“ name clash ”

因为擦除后变成:

public void print(List list) { }

public void print(List list) { } // 签名重复

所以:

重载的参数如果只靠泛型参数来区分,很大概率会翻车。


六、实战:做一个小小的“泛型仓库”,顺便把通配符和边界都玩一遍

光说理论太虚,我们来撸一个很常见的模式:泛型仓库(Repository)
假装我们在搞一个简单的内存版“数据库”。

6.1 定义一个通用实体接口

public interface Entity {
    Long getId();
}

来两个具体实体:

public class User implements Entity {
    private Long id;
    private String name;

    public User(Long id, String name) {
        this.id = id;
        this.name = name;
    }
    @Override
    public Long getId() {
        return id;
    }
    // getter / setter / toString ...
}

public class Product implements Entity {
    private Long id;
    private String title;

    public Product(Long id, String title) {
        this.id = id;
        this.title = title;
    }
    @Override
    public Long getId() {
        return id;
    }
    // getter / setter / toString ...
}

6.2 写一个泛型仓库接口:Repository<T extends Entity>

public interface Repository<T extends Entity> {

    void save(T entity);

    T findById(Long id);

    List<T> findAll();
}

这里出现了第一个最佳实践点

如果某个泛型参数必须满足某些能力(例如必须有 getId()),那就一定用上界约束 T extends SomeInterface
这样既能限制使用者,又能减少类型擦除后的“模糊感”。

6.3 一个内存实现:InMemoryRepository<T extends Entity>

import java.util.*;

public class InMemoryRepository<T extends Entity> implements Repository<T> {

    private final Map<Long, T> storage = new HashMap<>();

    @Override
    public void save(T entity) {
        storage.put(entity.getId(), entity);
    }

    @Override
    public T findById(Long id) {
        return storage.get(id);
    }

    @Override
    public List<T> findAll() {
        return new ArrayList<>(storage.values());
    }

    // 提供一个“只读视图”,演示通配符
    public List<? extends Entity> findAllAsEntities() {
        return new ArrayList<>(storage.values());
    }
}

上面这个 findAllAsEntities() 就是一个典型的通配符用法

  • 返回值用 List<? extends Entity>

    • 调用方只能“读”,不能往里 “安全地” 添加任何具体 Entity
    • 用一个更宽泛的视角暴露数据,更方便组装。

6.4 使用示例:玩一下不同类型的仓库

public class RepositoryDemo {
    public static void main(String[] args) {
        InMemoryRepository<User> userRepo = new InMemoryRepository<>();
        userRepo.save(new User(1L, "Alice"));
        userRepo.save(new User(2L, "Bob"));

        InMemoryRepository<Product> productRepo = new InMemoryRepository<>();
        productRepo.save(new Product(1L, "MacBook"));
        productRepo.save(new Product(2L, "Mechanical Keyboard"));

        System.out.println(userRepo.findById(1L));
        System.out.println(productRepo.findAll());

        // 通配符查看所有实体
        List<? extends Entity> entities = userRepo.findAllAsEntities();
        for (Entity e : entities) {
            System.out.println("Entity from userRepo: " + e.getId());
        }
    }
}

这个例子里,其实已经把几个关键点串到一起了:

  • T extends Entity:通过上界约束,保证 T 一定有 getId()
  • 实现类 InMemoryRepository<T extends Entity> 完全可以为多种实体重用;
  • 返回 List<? extends Entity>,通过只读视角让调用方更灵活。

七、通配符 ?:你看起来很迷糊,但其实很有用

很多人看到 ? extends? super 就条件反射:“这玩意儿我能不用就不用。”
其实通配符用好了,真的是写 API 的神器,尤其是在有类型擦除的大环境下,它帮你清晰地表达:“别人能对这个集合做什么,不能做什么。”

7.1 ? extends T:能读不能写,主要用来“消费”

public static void printIds(List<? extends Entity> list) {
    for (Entity e : list) {
        System.out.println(e.getId());
    }
    // list.add(new User(1L, "xxx")); // ❌ 编译错误
}

意义:

  • 我只关心从这个列表里读出东西
  • 你是 List<User> 也行,List<Product> 也行,反正都是 Entity 子类;
  • 为了类型安全,Java 不允许你往里面添加具体元素。

用口诀记:

Producer Extends(PECS) → 生产者用 extends,你从里头“拿东西”。

7.2 ? super T:能写但读不准,主要用来“存放”

public static void addUsers(List<? super User> list) {
    list.add(new User(1L, "A"));
    list.add(new User(2L, "B"));

    Object obj = list.get(0); // 只能当 Object 看
}

含义:

  • 这个列表里放的是 User 或者它的父类;
  • 我向里添加 User 是绝对安全的;
  • 但是从里头取出来,只能当 Object 看待。

还是那个口诀:

Consumer Super(PECS 的另一半) → 消费者用 super,你往里“塞东西”。


八、反射与泛型:看起来有类型,其实是“姿态问题”

虽然类型擦除了,但有时候我们用反射照一照,会发现:
“咦?怎么 getGenericSuperclass() 还能看到泛型参数?”

这就要区分一下:

  • 运行期的“真实类型”:JVM 看你的时候,其实就是一个擦除后的原始类型;
  • class 文件里的“泛型签名信息”:编译器额外塞进去的一些“说明书”,方便工具、反射框架、IDE 使用。

举个例子:

import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;

public abstract class BaseDao<T> {

    private final Class<T> entityClass;

    @SuppressWarnings("unchecked")
    public BaseDao() {
        Type superClass = getClass().getGenericSuperclass();
        if (superClass instanceof ParameterizedType) {
            ParameterizedType pt = (ParameterizedType) superClass;
            this.entityClass = (Class<T>) pt.getActualTypeArguments()[0];
        } else {
            throw new IllegalStateException("No generic type info");
        }
    }

    public Class<T> getEntityClass() {
        return entityClass;
    }
}

再来一个具体子类:

public class UserDao extends BaseDao<User> {
    // ...
}

然后:

public class ReflectionDemo {
    public static void main(String[] args) {
        UserDao dao = new UserDao();
        System.out.println(dao.getEntityClass()); // 输出 class your.package.User
    }
}

所以说:
泛型被擦掉的是“硬类型”,但有时候“注解式的类型说明”还是在的。
Spring、MyBatis 这些框架就很喜欢用这招:
“你在继承时把类型写清楚,我在运行期用反射帮你找回来。”

九、最佳实践:如何在类型擦除的世界里,优雅地用好泛型?

聊了半天原理和坑,不盘一下最佳实践,总感觉像吃火锅只喝了底汤。以下这些建议,基本都是踩坑踩出来的结论:

9.1 避免使用原生类型(raw type)

❌ 不要这样写:

List list = new ArrayList(); // 原生类型
list.add("string");
list.add(123); // 完全放飞自我

✅ 尽量写成:

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

为什么?

  • 原生类型会绕过泛型的编译期检查,把所有坑都留给运行期;
  • 在类型擦除的大环境下,你已经失去一部分类型信息了,能救一点是一点。

9.2 API 设计时,多想一想:调用方要“读”还是要“写”?

比如你写一个方法:

public static void process(List<Entity> list) { }

这个签名看着没啥问题,但实际上很“自私”:

  • 只能接受 准确类型为 List<Entity> 的列表;
  • List<User>List<Product> 都不能传进来。

更优雅的写法是:

public static void process(List<? extends Entity> list) {
    // 这里我们只读
}

再比如你要往列表里添加一堆 User

public static void fillUsers(List<? super User> list) {
    list.add(new User(1L, "Alice"));
}

一言以蔽之:

  • 如果方法只“从里头读数据” → 用 ? extends T
  • 如果方法要往里“塞数据” → 用 ? super T
  • 如果既要读又要写,而且要类型非常精确 → 就用具体类型 <T>

9.3 谨慎对待 @SuppressWarnings("unchecked")

有时候你确实绕不过强转,这时候可以使用:

@SuppressWarnings("unchecked")
List<String> list = (List<String>) someObject;

但前提一定是:

  • 非常确信这个强转是安全的;
  • 最好能通过整体架构上的约束来保证,比如只在某个特定模式下使用,或者有统一的泛型约定。

如果你发现自己在项目里疯狂到处写 @SuppressWarnings("unchecked"),那大概率是:
要么你的抽象层级设计得不够清晰,要么你在和类型擦除硬刚。

9.4 面向接口设计泛型,而不是面向具体实现

比如你写 DAO 或者 Service 时,优先搞成这种:

public interface Service<T> {
    void save(T t);
    T find(Long id);
}

而不是到处放:

public class UserService {
    // 一堆只针对 User 的方法
}

泛型的优势主要体现在这些地方:

  • 能抽象出公共逻辑;
  • 能在编译期做尽可能多的类型约束;
  • 减少 copy-paste 式的重复类。

类型擦除在这里反而是优势之一:
编译后,所有这些泛型逻辑会被统一折叠成原始类型,减少运行时开销。

十、再和面试官聊两句:为什么 Java 一直坚持类型擦除?

有时候面试官会问你一个略带灵魂拷问的问题:

“为什么 Java 选择了类型擦除,而不是在运行期保留泛型类型?”

你可以这样聊(别一上来就背标准答案,稍微有点“人味儿”会更好):

  1. 历史包袱(向下兼容):

    • 在 Java 5 引入泛型之前,各种库、框架已经是满天飞;
    • 如果直接引入运行期泛型,会导致旧代码和新代码之间出现巨大的兼容性问题;
    • 擦除的方式可以让旧的字节码照样跑,新代码也能用泛型
  2. 运行期开销问题:

    • 保留泛型信息会带来额外的运行期检查、内存占用、类型元数据;
    • 对 Java 这种大量跑老项目、企业应用的生态来说,稳定性 + 性能 + 兼容性,是很现实的考量。
  3. “折中但实用”的选择:

    • 编译期用泛型保证类型安全;
    • 运行期擦除,尽量不破坏原有架构;
    • 各种框架如果真的需要类型信息,可以通过反射、泛型签名等方式自己去“挖”。

你可以用一句话收尾:
“Java 泛型更像是一个‘编译期安全增强插件’,而不是语言在底层彻底重塑后的那种‘原生泛型’。”

十一、结语:知道它是“假”的,但我们依然可以用得很真

说到底,Java 泛型这套东西有点像一段**“见网友”**的关系:

  • 聊天的时候(编译期):对方有名字、有头像、有签名、有兴趣爱好,看着挺靠谱;
  • 真正见面的时候(运行期):发现对方戴了口罩、帽子、墨镜,只告诉你:“我就是个人,别问是谁。”

你说失不失望?肯定有点。
但你要是因此就完全不用泛型,那损失就大了——因为:

  • 编译期多一道类型检查,是在帮你提前暴雷
  • 好好用 extends / super / 通配符,可以让你的 API 又安全又优雅;
  • 理解类型擦除之后,你在面对泛型数组、反射、桥接方法这些“怪现象”时,也不会一脸懵逼。

如果你能做到:

  • 不乱用原生类型;
  • 知道哪些地方泛型在帮你,哪些地方它已经“失效”;
  • API 设计时清楚地表达“这里是只读,这里是只写,这里是强类型”。

那基本上,你就已经从“会用泛型”升级到“懂泛型的人”了。

最后留个小反问给你:

下次你再写出一个 List<T> 的时候,
你心里到底清不清楚——这个 T 会活到哪一步,
是陪你到运行期,还是只陪你到编译期?🤔

… …

文末

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

… …

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

wished for you successed !!!


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

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


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

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

评论(0

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

全部回复

上滑加载中

设置昵称

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

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

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