ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

TypeReference:解决Java泛型擦除与JSON反序列化难题的万能钥匙

TypeReference:解决Java泛型擦除与JSON反序列化难题的万能钥匙 在项目里干了这么多年Java后端几乎每个项目都逃不过JSON解析。但有个问题我印象特别深明明写了ListUser结果解析出来全是LinkedHashMap一调用getUserName()就抛ClassCastException。后来查了一圈发现问题的根源就是Java的泛型擦除机制——而绝大多数人第一次意识到这个机制的存在都是在JSON反序列化翻车的那一瞬间。今天要聊的就是解决这类问题的一把钥匙TypeReference。这东西在Jackson、Fastjson里都有名字几乎一样作用也一样在运行时把“目标泛型类型”完整地保留下来告诉反序列化器“我要的不是一个普通的List而是ListUser”。这篇文章会从泛型擦除的原理讲起拆解TypeReference内部到底做了什么然后结合Jackson、Fastjson、Gson三大主流库的实际用法最后再分享几个我在真实项目里踩过的坑。无论你是刚接触泛型的新人还是已经写过无数次TypeReference的老手这篇文章里应该都有一两个值得你看的点。1. 泛型擦除为什么反序列化总是收到一堆LinkedHashMap1.1 一次真实的线上事故还原先还原一下我经历过的场景。当时项目里对接一个外部数据源对方返回的JSON结构长这样{ code: 0, message: success, data: [ {id: 1, name: 张三, email: zhangsanexample.com}, {id: 2, name: 李四, email: lisiexample.com} ] }我定义了一个ResultT包装类然后写了这段代码Result result objectMapper.readValue(jsonString, Result.class); ListUser users (ListUser) result.getData();第一行没问题第二行也没报错——因为Java的泛型在编译后就被擦除了(ListUser)这个强转在运行时根本不会检查元素类型。真正的报错发生在后面String name users.get(0).getName();运行到这里ClassCastException才姗姗来迟。当时的报错信息我到现在都记得java.lang.ClassCastException: java.util.LinkedHashMap cannot be cast to com.example.User原因很简单Jackson看到Result.class知道了Result这个类但Result的泛型参数T在运行时已经被擦除它能做的只有把data解析成一个List元素则全部变成LinkedHashMap。你后续强转ListUser只是骗过了编译器运行时的类型检查根本拦不住。1.2 擦除机制编译期说的“鬼话”运行期一概不认Java泛型是JDK 1.5才引入的为了兼容之前没有泛型的海量代码Java选择了类型擦除方案编译阶段检查泛型类型是否匹配但编译后生成的字节码里泛型信息会被“擦掉”。具体来说ListString strList new ArrayList(); ListInteger intList new ArrayList();这两行代码编译后的List在运行时其实是同一个东西。你去用反射打印它们的getClass()返回的都是java.util.ArrayList根本看不到String还是Integer。泛型擦除有几个具体的表现泛型类ResultT在运行时只保留原始类型Result泛型方法T T doSth(T arg)在运行时只保留方法签名类型参数被替换为边界类型通常是Object你不能new T()、不能创建T[]数组因为在运行时T是什么根本查不到所以说泛型更像是编译期的一种契约只在编译时生效到了运行期就变成了一张废纸。而JSON反序列化恰恰发生在运行期——Jackson拿到的是一个字符串它必须在运行时决定“这段JSON该转成什么类型”。可你给它的Result.class里泛型参数已经被擦没了它怎么知道T是User还是Order还是String这就是所谓的泛型擦除与运行时类型信息的矛盾。解决方案只有一条想办法在运行时把泛型参数的真实类型当作数据传进去。TypeReference就是为此而生的。1.3 为什么Class对象救不了嵌套泛型有人可能会说那我不用Result.class直接传ListUser.class总行了吧不行因为Java编译器压根就不允许你这样写。ListUser.class是非法语法List.class才是合法的——而它代表的又只是原始的List没有任何泛型信息。你可能会想那我写一个方法参数用ClassT让调用方传User.class这不就知道类型了吗public T T parse(String json, ClassT clazz) { return objectMapper.readValue(json, clazz); }这个方案对付单层类型比如直接把JSON解析成一个User对象确实有效因为User.class就是User不存在泛型参数。但一旦目标类型变成ListUser、ResultListUser这种嵌套泛型Class对象就完全不够用了——Class只能描述“类的原始类型”无法描述“类的类型参数具体是什么”。Class解决不了的问题就需要更强大的类型描述工具。Java标准库提供了一个接口家族就是java.lang.reflect.Type它的子接口们专门用来描述带泛型参数的类型。TypeReference干的事情本质上就是捕获并持有这个Type。2. 匿名内部类与super关键字的把戏TypeReference的工作原理2.1new TypeReferenceListUser(){}那对神秘的花括号几乎所有第一次接触TypeReference的人都会有同样的疑惑为什么用的时候必须要带一对空的花括号直接new TypeReference()不行吗先看三个写法// 写法A直接新实例编译报错因为TypeReference是个抽象类 TypeReferenceListUser ref1 new TypeReference(); // 写法B匿名内部类正确用法 TypeReferenceListUser ref2 new TypeReferenceListUser() {}; // 写法C在另一个类里继承TypeReference public class UserListRef extends TypeReferenceListUser {}写法B和写法C的本质是一样的——都是创建了一个TypeReference的子类。写法B是匿名内部类写法C是命名子类。区别只在于写法B不用新开一个类文件写法C可以复用。关键点来了泛型信息在继承链上是不会被擦除的。一个类继承了TypeReferenceListUser那么这个子类上就会留下一条元数据“我这个类的父类泛型参数是ListUser”。Java的Class对象提供了getGenericSuperclass()方法来读取这条元数据。这才是整个机制的基石。我在给团队培训的时候经常用一句话概括直接new拿不到泛型但子类可以。所以TypeReference必须靠“创建一个知道目标类型的子类”来工作那一对花括号就是“我创建了一个子类”的语法标记。2.2 getGenericSuperclass()与ParameterizedType的读取链路现在来看TypeReference的经典实现。以下面这段代码为例这是Jackson的TypeReference核心逻辑protected TypeReference() { Type superClass getClass().getGenericSuperclass(); Type type ((ParameterizedType) superClass).getActualTypeArguments()[0]; this.type type; }构造方法里做了三件事一步步拆开看第一步getClass()。在匿名内部类场景下getClass()返回的是匿名内部类的Class对象也就是那个TypeReferenceListUser的匿名子类。第二步getGenericSuperclass()。它返回的是父类的泛型版本不是父类的原始Class。比如UserListRef extends TypeReferenceListUser那么getGenericSuperclass()返回的就是一个ParameterizedType这个对象内部完整保留了TypeReference和它的类型参数ListUser。第三步getActualTypeArguments()[0]。ParameterizedType的特性就是能取出“实际的类型参数”索引0代表第一个参数。这里只有一个参数所以取第0个拿到的就是ListUser这个完整类型的Type表示。如果你好奇这个Type对象内部长什么样可以试着自己打印一下TypeReferenceListUser ref new TypeReferenceListUser() {}; System.out.println(ref.getType()); // 输出java.util.Listcom.example.User不同的JSON库内部各自的TypeReference实现略有差异但核心思想完全一致。Fastjson的TypeReference还加了缓存机制会把解析出来的Type缓存起来避免重复解析——这个后面再细说。2.3 Type接口家族速览Class、ParameterizedType、GenericArrayType、WildcardType理解了TypeReference的构造原理之后有必要把java.lang.reflect.Type接口家族梳理一遍。因为后面遇到复杂的嵌套泛型场景你会频繁接触到这几个接口。Type是一个顶级接口所有“类型”都实现它。它的主要子接口有四个接口代表含义典型例子Class原始类型、数组或基本类型User.class、String.class、int[].classParameterizedType带类型参数的类型ListUser、ResultTGenericArrayType类型参数是泛型的数组T[]、ListString[]WildcardType通配符类型? extends Number、? super TTypeReference里最常返回的是ParameterizedType——因为ListUser本质就是一个带参数的类型。如果你要判断目标类型究竟是普通类还是泛型类关键就在检查ref.getType()是不是ParameterizedType的实例以及它的getRawType()是什么Type type ref.getType(); if (type instanceof ParameterizedType) { ParameterizedType pt (ParameterizedType) type; Type rawType pt.getRawType(); // 原始类型如 List.class Type[] args pt.getActualTypeArguments(); // 实际类型参数如 [User.class] }后面讲嵌套泛型的实战时代码要对ParameterizedType做这种判断和拆解先在这里打个底。3. 三大主流JSON库的TypeReference用法与差异3.1 JacksonTypeReference是内建APIJackson库中com.fasterxml.jackson.core.type.TypeReference是核心类型之一。用法如下ObjectMapper mapper new ObjectMapper(); String json [{\id\:1,\name\:\张三\}]; TypeReferenceListUser typeRef new TypeReferenceListUser() {}; ListUser users mapper.readValue(json, typeRef);mapper.readValue有两个重载一个接收Class一个接收TypeReference。传TypeReference时Jackson会从TypeReference里取出完整的Type然后基于这个Type构建一个JavaType再进行反序列化。这里有个细节值得注意TypeReference本身并不执行任何反序列化逻辑它只是一个类型信息的载体。真正干活的还是ObjectMapper。所以TypeReference可以被任意复用——同一个TypeReference实例只要线程安全地持有就能反复传给ObjectMapper使用。Jackson的TypeReference抽象类还有一个重载的构造方法和compareTo实现支持对类型进行排序。当然实际开发中极少用到compareTo了解即可。3.2 FastjsonTypeReference偏工具化Fastjson也提供了com.alibaba.fastjson.TypeReference用法非常接近Jackson版TypeReferenceListUser typeRef new TypeReferenceListUser() {}; ListUser users JSON.parseObject(json, typeRef.getType());注意Fastjson的parseObject方法主要接收的是Type所以你要先通过typeRef.getType()取出类型。因为它上手非常顺滑早期很多Java开发者都是从Fastjson的TypeReference开始认识这个概念的。Fastjson的TypeReference内部有一个静态缓存机制以TypeReference子类的Class为key把解析好的Type缓存进去。这样做的好处是重复创建同一个TypeReference子类实例时不需要反复解析泛型元数据算是性能上的一点点优化。不过实际项目里这点优化感知不强重点是别被API差异绕晕。顺带一提Fastjson还有TypeUtils.cast方法可以结合TypeReference做类型转换ListUser users TypeUtils.cast(data, new TypeReferenceListUser() {}.getType());这个TypeUtils.cast能处理很多隐式类型转换比如把Integer转成Long、字符串转数字等。但这种“太智能”的转换有时候也会掩盖数据异常我在项目里用得比较克制常规场景还是走parseObject。3.3 Gson名字换成了TypeTokenGson的做法和前面两者不同。它没有TypeReference对应的类是com.google.gson.reflect.TypeToken。用法如下Type type new TypeTokenListUser() {}.getType(); ListUser users gson.fromJson(json, type);内部机制几乎一模一样构造方法里通过getGenericSuperclass()读取父类的泛型参数拿到Type。区别在于Gson的TypeToken提供getType()、getRawType()等方法API更丰富TypeToken在构造时会做一些类型校验比如禁止使用TypeToken本身作为类型参数Gson一般不需要TypeToken的情况下用fromJson(String, Class)就能搞定只有处理泛型时才需要TypeToken如果你在Gson和Jackson之间切换只要记住一句话——Jackson叫TypeReferenceGson叫TypeToken动作都是new一个匿名子类目的都是捕获Type。3.4 三个库的对比表格把三种实现放在一张表里对照方便实际选型时快速查阅对比点JacksonFastjsonGson类型载体TypeReferenceTTypeReferenceTTypeTokenT读取类型readValue(json, typeRef)parseObject(json, type)fromJson(json, type)是否需要{}需要匿名子类需要匿名子类需要匿名子类核心原理构造器解析泛型父类构造器解析泛型父类带缓存构造器解析泛型父类额外能力compareTo排序TypeUtils.cast强转getRawType等反射API适用场景Spring Boot默认、生态成熟国内项目惯用、API简单Android/轻量项目常用提示不管用哪个库千万不要图省事直接用new TypeReferenceXxx(){}然后每次创建一个新实例放入循环——Type的解析虽然不重但循环里反复创建匿名内部类实例、反复解析没必要。正确做法是把Type或TypeReference定义成static final常量复用。4. 实战嵌套泛型与继承场景下的正确姿势4.1ResultListUser这种多层包装怎么还原回到开头那个线上事故。要正确处理ResultListUser写法是这样的TypeReferenceResultListUser typeRef new TypeReferenceResultListUser() {}; ResultListUser result objectMapper.readValue(jsonString, typeRef); ListUser users result.getData();注意这里泛型参数本身又是一个泛型——Result的参数T是ListUser。TypeReference的构造器通过getGenericSuperclass()拿到的Type是一个嵌套的ParameterizedType内部完整包含了Result - List - User这条链路。Jackson等库在反序列化时会递归解析这个类型树先创建Result对象再把data字段按照ListUser来构建元素逐个创建成User实例。如果你觉得new TypeReferenceResultListUser() {}写起来太长可以定义一个具名类复用public class ResultUserListRef extends TypeReferenceResultListUser {}这样每次用的时候objectMapper.readValue(json, new ResultUserListRef())代码会清爽很多。类似的具名类在项目里可以集中管理。4.2 泛型字段与继承父类声明泛型子类能拿到吗泛型擦除有一个反直觉的边界泛型信息在“继承链”上会保留但在“字段声明”上不会自动保留到运行期。什么意思看个例子。假设我们有这样的类结构public abstract class BaseRespT { private int code; private T data; // getter/setter... } public class UserResp extends BaseRespListUser { }然后你想在运行时解析UserResp的data字段类型用getGenericSuperclass()Type type UserResp.class.getGenericSuperclass();拿到的type能解析出BaseRespListUser这个ParameterizedType进而拿到data字段的真实泛型ListUser。因为这个泛型信息是“写死在类定义里”的属于编译期元数据保留在Signature属性中。但如果一个类没有通过继承链绑定泛型只是“在代码里使用了”泛型字段那么运行期拿不到任何泛型信息。比如public class Holder { private ListUser users; // 运行期这里只有 List元素类型不可知 }你通过反射拿Holder.class.getDeclaredField(users).getType()得到的只是List.class拿不到User。要拿User得用getGenericType()返回ParameterizedType再解析。这一层区别是很多人都没注意过的。落实到JSON解析结论就是不要指望“代码里写了ListUser字段运行时就能按User来解析”。反序列化器读不到字段上的泛型参数它需要你通过TypeReference把目标类型明确传进去。4.3 在方法参数里传TypeReference而不是传Class实际项目里我们经常要封装一个JsonUtil工具类让业务方传入目标类型来解析。这里有个很重要的设计决策方法签名到底用ClassT还是TypeReferenceT我的选择是如果工具类要支持嵌套泛型必须用TypeReferenceT。签名长这样public static T T parseJson(String json, TypeReferenceT typeRef) { return objectMapper.readValue(json, typeRef); }调用方这样用User user JsonUtil.parseJson(json, new TypeReferenceUser() {}); ListUser users JsonUtil.parseJson(json, new TypeReferenceListUser() {}); ResultListUser result JsonUtil.parseJson(json, new TypeReferenceResultListUser() {});如果你只用ClassT能支持User这种单层类型但ListUser、ResultListUser全部无能为力。所以工具类设计时优先考虑用TypeReference做参数类型这样才有最好的兼容性。4.4 Type与TypeReference的复用把类型常量提升为static final还有一个性能相关的细节。有些同学写代码喜欢这样public ListUser parseUserList(String json) { return objectMapper.readValue(json, new TypeReferenceListUser() {}); }这个方法每次被调用都会创建一个新的匿名内部类实例还要解析一次泛型。虽然实测影响微乎其微——毕竟TypeReference的构造过程非常轻量——但在高并发场景下优雅的做法是把TypeReference提升为static final常量private static final TypeReferenceListUser USER_LIST_TYPE new TypeReferenceListUser() {}; public ListUser parseUserList(String json) { return objectMapper.readValue(json, USER_LIST_TYPE); }这样整个类加载时解析一次之后反复使用同一个实例。ObjectMapper的readValue内部不会修改传入的JavaType所以并发调用同一个TypeReference实例是线程安全的。如果用的是Fastjson传入的也是同一个Type对象同样安全。不要因为“解析一次”的成本低就忽略这个习惯。在高QPS的服务里任何一个无谓的对象创建和反射解析都是可以优化的点。积累多了性能差异就出来了。5. 我踩过的坑类型擦除的伪装者与自研工具类的教训5.1 反序列化成功但强转失败最经典的“伪装者”问题我在这个坑里栽过一次还排查了很久。当时用Fastjson解析一个复杂的JSON结构类型写的是MapString, ObjectMapString, Object map JSON.parseObject(jsonString, typeRef);解析成功没有任何报错。但接着从map里取一个值强转成UserUser user (User) map.get(user);运行时直接ClassCastException。原因还是一样的Object在反序列化时被转换成了JSONObjectFastjson内部的一种Map实现而不是User。你以为map里存的是User其实它只是碰巧“长得像”用户对象的Map。这种“伪装者”问题特别容易在复杂的嵌套JSON结构中出现而且只在运行时才暴露。排查时最好的工具就是debug时查看对象的实际运行时类型比如map.get(user).getClass()输出会清楚告诉你这是sun.reflect.generics.reflectiveObjects.ParameterizedType还是com.alibaba.fastjson.JSONObject。解决这类问题的一个有效手段是从源头就用TypeReference把每一层类型声明清楚而不是大而化之地用MapString, Object接住再逐层强转。5.2 把TypeReference当作Class传给第三方接口的坑还有一次我写的工具方法接收的是ClassT类型但内部需要支持泛型。当时图省事把TypeReference直接传进去了public T T handle(ClassT clazz) { // 内部把 clazz 当作类型信息传给反序列化器 return objectMapper.readValue(json, clazz); } // 调用时 handle(new TypeReferenceListUser() {}.getClass());结果显而易见的糟糕getClass()返回的是那个匿名内部类的Class它既不是List也不是User而是一个TypeReference的子类。反序列化器拿到它完全不知道你想解析成什么类型行为就变得不可预测。正确做法是要么把方法签名定义为接收TypeReferenceT要么接收Type type。不要试图用一个Class参数去承载泛型信息两者不是一回事。这个坑的本质还是对Type和Class的区分不够敏感导致的。5.3 自研JsonUtil工具类的签名设计兼顾简单与泛型踩了几次坑之后我重新设计了项目里的JsonUtil原则是简单场景用Class泛型场景用TypeReference两个方法都提供。public class JsonUtil { private static final ObjectMapper MAPPER new ObjectMapper(); // 简单场景直接给 Class public static T T fromJson(String json, ClassT clazz) { return MAPPER.readValue(json, clazz); } // 泛型场景给 TypeReference public static T T fromJson(String json, TypeReferenceT typeRef) { return MAPPER.readValue(json, typeRef); } }业务方可以根据场景选择User user JsonUtil.fromJson(json, User.class); ListUser users JsonUtil.fromJson(json, new TypeReferenceListUser() {});这套设计在项目里用下来几乎没有再遇到“为什么反序列化出来是LinkedHashMap”的问题。如果你在维护一个工具类强烈建议把这两种形态都提供而不是只留一种。因为“简单场景”确实很多让业务方每次都写new TypeReferenceUser() {}虽然不费劲但完全没有必要。5.4 嵌套泛型的边界思考?通配符要慎用最后一个想提醒的坑TypeReference里的泛型参数不要轻易用?通配符。比如new TypeReferenceList?() {}这种写法虽然能通过编译但反序列化时?会被解析成一个通配符类型很多库无法为它确定实际的元素类型结果可能又把内容解析成Map或者干脆报错。实际开发中我从未见过需要用到带通配符的TypeReference的场景。如果在设计接口时遇到“类型不确定但又必须收下泛型数据”的情况我通常的处理方式是TypeReferenceResultObject接收然后在业务层做显式的类型转换而不是依赖反序列化器去猜。因为“猜类型”这件事反序列化器永远做不好。另外还有一点如果你在用Kotlin开发TypeReference的匿名子类语法会略有不同——Kotlin里object : TypeReferenceListUser() {}需要手动写上object :。这个细节不多讲碰到的时候搜索一下就能解决。最后聊一点个人体会TypeReference这个工具看起来简单但它背后的设计哲学其实挺有意思Java泛型在运行期是不可见的但通过继承关系可以把类型参数“锁”进子类的元数据里——这是语言本身留给我们的一个后门。理解了这个后门你不仅能用好TypeReference以后遇到任何泛型反射场景比如写通用Mapper、通用DAO都能想到用同样思路去解决。我的建议是代码里凡是涉及泛型反序列化的地方都习惯性地用TypeReference不要图省事直接传Class。一次两次没事但一旦遇上嵌套泛型坑的就是线上数据。把这个习惯刻进肌肉记忆比记任何API都管用。
返回列表