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 的时候——类型信息已经被剃得干干净净。
三、类型擦除到底“擦”了什么?规则说清楚了其实没那么玄
我们老说“擦除擦除”,那问题来了:到底是怎么擦的?是全擦,还是有选择地擦?
简单分三种情况来聊:
- 普通泛型类 / 泛型接口
- 有上界(extends)的泛型
- 通配符(
? 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的地方,都统一替换成擦除后的类型。
编译器其实做了两件事:
- 在你写代码的时候,帮你做了类型检查;
- 编译完成后,把你写的泛型“抹平成”非泛型的版本。
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 选择了类型擦除,而不是在运行期保留泛型类型?”
你可以这样聊(别一上来就背标准答案,稍微有点“人味儿”会更好):
-
历史包袱(向下兼容):
- 在 Java 5 引入泛型之前,各种库、框架已经是满天飞;
- 如果直接引入运行期泛型,会导致旧代码和新代码之间出现巨大的兼容性问题;
- 擦除的方式可以让旧的字节码照样跑,新代码也能用泛型。
-
运行期开销问题:
- 保留泛型信息会带来额外的运行期检查、内存占用、类型元数据;
- 对 Java 这种大量跑老项目、企业应用的生态来说,稳定性 + 性能 + 兼容性,是很现实的考量。
-
“折中但实用”的选择:
- 编译期用泛型保证类型安全;
- 运行期擦除,尽量不破坏原有架构;
- 各种框架如果真的需要类型信息,可以通过反射、泛型签名等方式自己去“挖”。
你可以用一句话收尾:
“Java 泛型更像是一个‘编译期安全增强插件’,而不是语言在底层彻底重塑后的那种‘原生泛型’。”
十一、结语:知道它是“假”的,但我们依然可以用得很真
说到底,Java 泛型这套东西有点像一段**“见网友”**的关系:
- 聊天的时候(编译期):对方有名字、有头像、有签名、有兴趣爱好,看着挺靠谱;
- 真正见面的时候(运行期):发现对方戴了口罩、帽子、墨镜,只告诉你:“我就是个人,别问是谁。”
你说失不失望?肯定有点。
但你要是因此就完全不用泛型,那损失就大了——因为:
- 编译期多一道类型检查,是在帮你提前暴雷;
- 好好用
extends/super/ 通配符,可以让你的 API 又安全又优雅; - 理解类型擦除之后,你在面对泛型数组、反射、桥接方法这些“怪现象”时,也不会一脸懵逼。
如果你能做到:
- 不乱用原生类型;
- 知道哪些地方泛型在帮你,哪些地方它已经“失效”;
- API 设计时清楚地表达“这里是只读,这里是只写,这里是强类型”。
那基本上,你就已经从“会用泛型”升级到“懂泛型的人”了。
最后留个小反问给你:
下次你再写出一个
List<T>的时候,
你心里到底清不清楚——这个T会活到哪一步,
是陪你到运行期,还是只陪你到编译期?🤔
… …
文末
好啦,以上就是我这期的全部内容,如果有任何疑问,欢迎下方留言哦,咱们下期见。
… …
学习不分先后,知识不分多少;事无巨细,当以虚心求教;三人行,必有我师焉!!!
wished for you successed !!!
⭐️若喜欢我,就请关注我叭。
⭐️若对您有用,就请点赞叭。
⭐️若有疑问,就请评论留言告诉我叭。
版权声明:本文由作者原创,转载请注明出处,谢谢支持!
- 点赞
- 收藏
- 关注作者
评论(0)