很多人把Gson当成一个“JSON转对象”的黑盒工具,拿到就用,遇到问题就瞎猜。我前几年接手过一个老模块,线上日志突然出现大面积的JsonSyntaxException,排查了半天,最后发现全是因为一个JavaBean里的Date字段格式不再被默认解析了。那会儿才开始认真把Gson的解析流程、适配器机制、泛型擦除这些底层逻辑从里到外捋了一遍。这篇文章就把我在实际项目里会用到的Gson核心技巧和踩坑经验一次说清楚,适合那种已经用过Gson但总感觉“差点意思”的同学。
1. 先搞清楚Gson到底在做什么:从String到Bean的完整路径
1.1 你以为的简单调用,背后其实有个状态机
平时我们写new Gson().fromJson(json, User.class)这行代码,觉得就是“把JSON字符串变成User对象”,但Gson内部做的事情比你想象中多得多。
第一步,Gson拿到字符串后,会先创建一个JsonReader,把整个JSON文本解析成一串Token流。这个Token流不是一次性把整个JSON塞进内存的Map,而是像迭代器一样,逐个Kick出token。比如{"name":"tom"}会依次产生BEGIN_OBJECT、NAME、STRING、END_OBJECT这些token,Gson的fromJson内部是一个递归下降的过程,会根据目标类型的字段列表,去匹配JSON里的key,匹配上就用对应的TypeAdapter读取值,匹配不上就跳过。
第二步是类型适配器。Gson内置了基本类型适配器,比如int、String、Boolean都有对应的adapter,JavaBean对象则走ReflectiveTypeAdapterFactory。这个工厂类会通过反射获取目标类的所有字段,然后为每个字段绑定一个BoundField,每个BoundField都有自己的json序列化名称、是否忽略、是否强制序列化等配置。
这两步配合起来,才是完整的解析过程。很多人以为Gson是“先把JSON转成JsonObject,再手动取字段”,其实不是。你直接调用fromJson(json, User.class),全程根本不会生成中间态的Map或JsonObject,而是边读边反射赋值,这一步省去了大量中间对象,也是Gson相比某些库更快的原因之一。
1.2 为什么说toJson和fromJson是不对称的
我见过不少人在序列化和反序列化时踩到同一个坑:明明toJson输出的字符串没问题,fromJson却报错。这是因为序列化时,Gson遍历JavaBean字段反射取值拼接字符串;反序列化时,则完全依赖JSON文本里的key名去匹配字段名。
举个例子,如果一个字段是private String userName;,toJson时输出的是"userName":"tom"。如果对端服务是用下划线风格输出的"user_name":"tom",Gson解析时找不到userName这个key,这个字段就是null,而且不会报错。这种“静默丢失”比报错可怕多了,因为数据看起来像是解析成功了,实际上核心字段全丢了。
解决办法是统一命名风格,最简单的是在字段上直接加@SerializedName("user_name")。但一个类几十个字段,逐个加注解很烦,我后来直接配合GsonBuilder的setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)统一策略,省心很多。
提示:
FieldNamingPolicy只影响序列化和反序列化时的key映射,不会改变Java类里字段本身的名字。如果项目里同时存在多种命名风格,还是建议用@SerializedName更可控。
1.3 解析入口别只想fromJson,还有fromJson(Reader)
我们平时传String的场景最多,但Gson还支持接收Reader、JsonElement和JsonReader。这几个入口的差别在性能上非常明显。
如果你的JSON字符串是从InputStream里读出来的,比如HTTP接口返回体,直接reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8),然后fromJson(reader, User.class),可以省掉先把InputStream读成String的这一步。尤其当返回体达到几百KB甚至几MB时,能节省一小段内存和耗时。
如果你拿到的JSON已经是一个JsonElement(比如JsonParser.parseString得到的),直接fromJson(jsonElement, User.class),会让Gson跳过从String创建JsonReader的过程,相当于直接从内存里的对象图开始转换,这条路适合做二级缓存或中间处理场景。
还有一个看着不起眼但是很关键的细节:JsonReader需要手动设置setLenient(true)才能容忍一些非严格模式的JSON,比如key不带引号、数字前有加号、单引号字符串等。如果你自己new了JsonReader,默认是严格模式,很多线上接口返回的JSON用外层Gson解析没问题,传进这个自定义reader反而报MalformedJsonException。
2. 泛型List的经典问题:TypeToken到底解决了什么
2.1 为什么List .class会报错,List.class只会丢数据
我最早学Gson时候,第一反应是写Type type = new TypeToken<List<User>>(){}.getType(),但没想明白为什么不能直接传List.class。后来看了Gson源码才明白,JVM的泛型擦除导致运行时拿不到List<User>中User这个元素类型。
如果你传List.class,Gson会拿到的原生类型是List,它只能判断出要构建一个ArrayList,但里面每个元素解析成什么类型是不知道的,所以Gson默认会把JSON数组里的每个对象解析成LinkedTreeMap,也就是LinkedHashMap的变体。等你在代码里做(User) list.get(0)强转时,就会抛ClassCastException。
这就好比你告诉快递员“帮我签收一箱东西”,但没说箱子里装的是笔记本还是鸡蛋,快递员只能给你一个没拆封的箱子。你拿回去自己拆开才知道里面是什么,不好意思,你拆开发现是鸡蛋,但你代码里按笔记本用,就会炸。TypeToken的作用就是精确告诉Gson“箱子里是笔记本”。
2.2 TypeToken的核心价值:让Gson拿到泛型上界
new TypeToken<List<User>>(){}.getType()这行代码的关键在于匿名内部类。匿名内部类会保存父类的泛型信息,TypeToken通过这个信息拿到完整的ParameterizedType,包含RawType(List)和ActualTypeArgument(User)。Gson根据TypeToken里携带的信息,在解析数组元素时就知道要用User.class去反射构建对象。
不仅仅是List,像Map<String, User>、Response<List<Order>>、Result<User, List<Item>>这种嵌套泛型,TypeToken都能完整处理。我遇到多层的泛型结构,如TypeToken<ApiResponse<List<OrderDetail>>>,使用方法和单层一样,关键是别省掉外层。
2.3 嵌套泛型中的两个常用变通写法
一个是局部TypeToken。不能总是把TypeToken定义成类成员,特别是不同方法里解析不同结构时,直接在方法里写new TypeToken<Map<String, List<User>>>() {}.getType()就行,短期存在,用完即弃,完全可行。
另一个是Type的复用。如果你在一个类里要多次解析同样的结构,可以把TypeToken得到的Type保存成类常量,比如private static final Type USER_LIST_TYPE = new TypeToken<List<User>>() {}.getType();,这样每次解析不必重复创建匿名类,性能更好,代码也更整洁。
避坑提醒:
TypeToken的构造器被设计成如果是无参直接new普通实例,由于没有匿名类的泛型捕获,拿到的其实是擦除后的原生类型,这时getType()返回的是List.class,解析出来的就不是User。所以务必使用{}语法,这是TypeToken能工作的核心条件,没有这个花括号,一切等于白写。
3. 日期时间格式的适配:从setDateFormat到秒级时间戳
3.1 默认日期策略让人头疼,但可以统一配置
Gson默认对java.util.Date的序列化结果是字符串,格式是"Jun 7, 2025, 8:30:00 AM",这种格式既不好看也不好解析。项目里往往有统一要求,比如yyyy-MM-dd HH:mm:ss。
最简单的方式是在GsonBuilder上设置:
Gson gson = new GsonBuilder() .setDateFormat("yyyy-MM-dd HH:mm:ss") .create();这样全局的Date都会被按照这个格式序列化和反序列化。如果你只有一个日期格式,这招够用。但如果接口同时存在多种日期格式,比如"2025-06-07"和"2025-06-07 08:30"混用,setDateFormat就不够用了,因为只能设置一种格式。这时需要走自定义TypeAdapter。
3.2 自定义TypeAdapter处理多格式日期,兼容性拉满
我处理过一个对接老系统的情况,同一个JSON里,一个是"2025-06-07 08:30:00",另一个是时间戳1750000000000。想要同时兼容,最好的做法是自定义Date类型适配器:
private static class FlexibleDateAdapter extends TypeAdapter<Date> { private final SimpleDateFormat[] formats = { new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"), new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US), new SimpleDateFormat("yyyy-MM-dd") }; @Override public void write(JsonWriter out, Date value) throws IOException { if (value == null) { out.nullValue(); } else { out.value(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(value)); } } @Override public Date read(JsonReader in) throws IOException { if (in.peek() == JsonToken.NULL) { in.nextNull(); return null; } if (in.peek() == JsonToken.NUMBER) { long ts = in.nextLong(); return new Date(ts); } String raw = in.nextString(); for (SimpleDateFormat fmt : formats) { try { return fmt.parse(raw); } catch (ParseException ignored) { } } throw new JsonSyntaxException("无法解析日期: " + raw); } }注册方式:
Gson gson = new GsonBuilder() .registerTypeAdapter(Date.class, new FlexibleDateAdapter()) .create();这里有几个细节要注意。in.peek()很关键,因为数字字符串在JSON里是没有引号的,String类型的是有引号的,Gson可以通过token类型区分,这样时间戳字符串和数字时间戳都能接住。还有SimpleDateFormat不是线程安全的,不要在TypeAdapter里声明成一个static共享实例到处解析,每个线程单独创建会安全很多。
3.3 JSR310的LocalDateTime不能直接依赖默认适配
如果你用的是java.time.LocalDateTime,Gson原生不认这个类。很多低版本的Gson会直接用反射硬new,然后发现没有无参构造器直接崩。高版本Gson对java.time的适配也是有限制的,它需要你注册JavaTimeModule对应适配器,或者手动加自定义适配器。
Gson gson = new GsonBuilder() .registerTypeAdapter(LocalDateTime.class, new TypeAdapter<LocalDateTime>() { private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); @Override public void write(JsonWriter out, LocalDateTime value) throws IOException { out.value(value.format(formatter)); } @Override public LocalDateTime read(JsonReader in) throws IOException { return LocalDateTime.parse(in.nextString(), formatter); } }) .create();这里你可能会想,为什么不用Gson官方提供的GsonBuilder扩展?因为那依赖额外的库依赖,很多老项目不敢随意引入新模块。自己手写一个也就二十来行,链路清晰,也方便在解析前做时区转换。
4. 多态、继承与循环引用的适配器魔法
4.1 多态反序列化的痛:父类引用丢子类字段
Java的多态在Gson这里是个天然大坑。要是你有这样一个结构:
public abstract class Animal { public String name; } public class Dog extends Animal { public String breed; } public class Cat extends Animal { public boolean indoor; }反序列化时如果类型是Animal.class,Gson反射到Animal这个类,只会看到name字段,子类的breed和indoor全部丢失。你希望根据JSON里的某个字段(比如type)来判断创建Dog还是Cat,Gson默认不支持,所以得自己写。
比较流行的方案是基于RuntimeTypeAdapterFactory,思路是让Gson在解析父类时,主动查看JSON里的类型标识字段,然后动态适配到对应的子类TypeAdapter。核心代码大致像下面这样:
RuntimeTypeAdapterFactory<Animal> animalFactory = RuntimeTypeAdapterFactory .of(Animal.class, "type") .registerSubtype(Dog.class, "dog") .registerSubtype(Cat.class, "cat"); Gson gson = new GsonBuilder() .registerTypeAdapterFactory(animalFactory) .create();这里"type"就是JSON里用来区分子类的字段名,值为dog或cat。这样解析Animal时,Gson会根据type字段选择对应的子类适配器。
注意:这种方案要求子类都必须有默认无参构造器,否则反射会失败。
4.2 序列化时如何保证多态信息不丢失
如果只有反序列化时动态选择子类,序列化时同样会踩坑。你定义一个Animal引用,实际是Dog对象,Gson默认按Animal的字段序列化,breed直接没了。这时把子类信息写进JSON的关键是给序列化的对象加一层包装,把字段信息补进type字段里。
如果你不想用RuntimeTypeAdapterFactory,也可以自己写TypeAdapter,在write时判断value instanceof Dog来写不同字段。不过有一个更稳妥的做法是:不要用Animal类型去做序列化,直接操作Object类型让Gson根据运行时类型反射字段。比如:
gson.toJson(dog, Dog.class); // 明确声明子类但如果你在一个List里存放Animal,代码不可能挨个判断子类。针对这种情况,我给元素类型加一个组合包装的type字段,写在父类里:
public class Animal { public String type; public String name; }然后序列化前手动设置type字段值。虽然有些土,但在老项目中生产验证过,稳定性高于自动反射判断,逃过了很多奇奇怪怪的强转问题。
4.3 循环引用的防守:不是所有循环都要炸
Gson在处理父子互相引用的对象时,会出现无限递归,直接StackOverflowError。但有一种循环不会炸,就是当你给这两端都设置了忽略,或者通过自定义TypeAdapter截断递归。
实际项目里,我比较常用transient关键字来切断循环:
public class Parent { public String name; public transient List<Child> children; } public class Child { public String name; public Parent parent; }因为Parent的children字段被transient修饰,所以序列化Parent时根本不会进入Child,循环不会发生。这个做法也同时适用于反序列化,transient字段默认不参与Gson反序列化,所以子节点里的parent不会被打进来,数据会相对“干净”地断开关系。
如果你要保留children,又不想循环,方法是在Parent里用@Expose配合GsonBuilderexcludeFieldsWithoutExposeAnnotation(),只序列化加了注解的字段,再加上自定义TypeAdapter控制深度,这种适合复杂树结构。
5. 字段过滤与空值策略:build一个高度可控的Gson
5.1 五种拦截字段的方式,按场景选
我总结下来,Gson里控制字段是否参与序列化至少有五种方式:transient关键字、@Expose注解、excludeFieldsWithModifiers、excludeFieldsWithoutExposeAnnotation、@Since/@Until版本控制。
如果只是临时不让某个字段出去,用transient最直接,Java原生机制不需要Gson额外配置。但要注意,transient会被其它Java序列化机制一并处理,如果你这个对象同时走JVM原生的序列化,可能收到“意想不到的丢弃”。
@Expose就灵活得多,它需要配excludeFieldsWithoutExposeAnnotation()才会生效。这个适合做版本化输出,比如开发环境打印完整信息,生产环境只输出部分字段。
public class User { @Expose private String name; @Expose(serialize = false, deserialize = false) private String secret; private String score; // 没有@Expose,全局开启Expose后会被忽略 }@Since(2.0)和@Until(3.0)适合做接口版本控制。比如你有多个版本共存,可以构建多个Gson实例,分别设置为setVersion(1.0)、setVersion(2.0),让同一个类字段在不同版本输出不同内容。
心得:实际接口对接时,我更多用@Expose,因为它能精确控制序列化和反序列化是否参与,而不像transient那样一刀切。
5.2 序列化时null字段到底是写还是不写
Gson默认是序列化null字段的,会输出"key":null。但很多接口要求不输出null字段,或者要求必须输出null字段来保持数据结构一致。这需要显式配置:
Gson gson = new GsonBuilder() .serializeNulls() .create();默认不调用serializeNulls时,null字段会被直接略过。这个行为经常跟@Since、@Expose结合使用时出现认知偏差,比如你希望null字段输出但某些内部字段不要输出,那么两者配置同时生效,必须测试确认。
特别提醒一点:如果JSON里某个字段缺失,反序列化后对应Java字段是null,不会走你自定义的默认值构造。所以你想让缺失字段有默认值,得在Java类里直接初始化字段,或者写一个Object默认值的适配器。
public class User { private int age = -1; // JSON里没有age时,保留-1而不是变成0 }这里要小心:Gson反射构建对象时,如果存在无参构造器,它会走构造器,那么字段初始值会得到。但如果一个类没有无参构造器,只有带参构造器,Gson会通过Unsafe直接分配内存,字段初始值会丢失,所有字段都是默认零值。这是深层坑,建议这种带参构造器的类,要么显式写一个无参构造,要么给Gson注册InstanceCreator。
5.3 反序列化时遇到“多余字段”会怎样
Gson默认行为是忽略JSON里多出来的key,不会报错。这个行为就像一把双刃剑。好处是对接老接口时,对方多了几个字段完全不慌;坏处是你拼错了一个字段名,比如把Java里的userAge拼成userage,数据丢了你根本不知道。
想发现这类问题,Gson有setStrictness但官方一直没有完全制裁多余字段的工具。我自己的做法是:在开发环境跑一个自定义的TypeAdapterFactory,凡是解析完成后对比一下JsonReader走过的path是否覆盖所有key。但考虑到代码成本,实际用得更多的还是反序列化后做断言:关键业务字段如果是null,抛异常拦下来,这样不会等到数据入库了才意识到丢了数据。
6. 常见异常与排查对照:别再靠猜
6.1 JsonSyntaxException到底想告诉你什么
JsonSyntaxException是Gson统一包装的解析异常,很多新手看到这个名字很头疼,因为它下面可能隐藏着各种低层异常。实际上它包装了三种常见情况:
- JSON文本本身语法不对,比如多了一个逗号,少了右括号。低层是
MalformedJsonException。 - 类型转换失败,比如JSON里值是字符串,但Java字段是int。低层是
NumberFormatException或IllegalStateException。 - 对象构造失败,比如类没有无参构造器且Gson反射创建失败。低层是
InvocationTargetException。
排查的时候,可以先把异常栈打印完整。我习惯在处理异常时打印e.getCause()的完整堆栈,而不仅仅是e.getMessage()。很多关键线索在cause里。比如你看到一个com.google.gson.JsonSyntaxException: java.lang.IllegalStateException: Expected BEGIN_OBJECT but was STRING,这表示JSON在期望对象开始的地方拿到了字符串,极有可能是JSON结构变成了["a","b"],但代码端期望{"a":"b"}。
6.2 类型不匹配问题:数字与布尔值的隐形转换
Gson解析JSON时,对于Java的int、long、double、boolean、String等类型,都有内置适配器,但这些适配器对输入的类型限制很严格。比如JSON里是"age":"25"这种带引号的数字,映射到Java字段int age时,默认适配器会直接抛IllegalStateException: Expected a number but was STRING。
很多线上接口因为历史原因会产出带引号的数字,这种字段建议在Java侧就定义成String类型,自己业务层再转int。或者写一个宽松的int适配器,手动strip字符串引号。
private static class LenientIntegerAdapter extends TypeAdapter<Integer> { @Override public void write(JsonWriter out, Integer value) throws IOException { if (value == null) { out.nullValue(); } else { out.value(value); } } @Override public Integer read(JsonReader in) throws IOException { if (in.peek() == JsonToken.STRING) { return Integer.parseInt(in.nextString()); } if (in.peek() == JsonToken.NUMBER) { return in.nextInt(); } in.skipValue(); return null; } }类似的现象还有Java布尔类型。有些接口返回的"flag":1,Gson解析boolean时不一定直接报错,但会有不可预期的行为。最好都统一用适配器或String接收,避免类型黑洞。
6.3 通过JsonReader的peek调试复杂问题
遇到非常复杂的JSON解析失败时,我一般会写一个调试用的适配器,在read方法里打印当前in.getPath()和in.peek()。getPath()会显示像$.data.list[2].name这样的路径,可以快速定位到具体是哪个节点出错。
public class DebuggingAdapter extends TypeAdapter<Object> { @Override public void write(JsonWriter out, Object value) throws IOException { out.value(String.valueOf(value)); } @Override public Object read(JsonReader in) throws IOException { System.out.println("path=" + in.getPath() + ", token=" + in.peek()); switch (in.peek()) { case STRING: return in.nextString(); case NUMBER: return in.nextDouble(); case BOOLEAN: return in.nextBoolean(); case NULL: in.nextNull(); return null; default: in.skipValue(); return null; } } }然后注册成Object适配器,先跑一遍看有没有解析到预期字段。平时排查问题的时间大半都能缩短。debug完记得把这个适配器移除,不然性能影响明显。
7. 性能与内存调优碎片:Gson在生产环境的小细节
7.1 不要反复new Gson,一个实例足以
Gson本身是线程安全的,应用程序里一个实例全局共享即可,不需要每个调用点都new。每次new Gson都会重新初始化所有内置TypeAdapterFactory,虽然成本不高,但高频调用时积少成多,白白消耗CPU。
另外GsonBuilder在构建过程中会扫描注册的TypeAdapterFactory。如果你的注册很多,比如十几个自定义适配器,每次new都是重复劳动。我在公共模块里一般封装一个静态的GsonHolder,所有业务共享同一个Gson实例。
7.2 反射开销想省就省:直接注册TypeAdapter
Gson对JavaBean的默认反射适配性能不算差,但如果你有一个高频对象解析的场景,比如每秒解析上千条日志,可以考虑直接手写TypeAdapter。
手写TypeAdapter的好处是:不用反射获取字段,不用每次遍历Block字段。直接顺序读JSON的key,基于switch分支处理,速度能提升好几倍。代价是每个类都要维护一套手写代码。在实际项目中,我会挑性能关键路径上的2~3个核心做手写适配,其余保持反射,平衡维护成本。
7.3 超大JSON怎么处理:流式读取与分割
如果单个JSON文本超过几十MB,直接fromJson(String)会将整个String放到内存,然后创建JsonReader,如果再有中间状态,内存压力会很大。这种情况下可以切成InputStream流式解析,配合JsonReader一步步推进token。
另一种更常见的方式是让服务端做分页或裁剪,不要返回超大JSON。但如果你确实在本地必须解析大文件,建议用JsonReader逐层级skipValue,只解析需要的部分。比如一个JSON数组有10万条记录,但只需要里面的总数,可以用JsonReader跳过数组内容,只取外层size字段,避免全量解析。
实操心得:我处理过一个周末任务,每天要解析两百万行日志,最开始用String读取再逐行fromJson,JVM内存涨到接近上限,GC非常频繁。后来改成BufferedReader逐行读取,每行单独fromJson,内存直接降下来一半。如果连单行都很大,可以考虑在json字符串里按顶层元素切割,一次只解析一个子Json。
8. 最后的工程化建议:封装一个模块级Gson工具类
为了让项目的Gson使用体验一致,我一般会封装一个轻量工具类,统一配置所有日期格式、命名策略和自定义适配器。
public final class JsonUtil { private static final Gson GSON = new GsonBuilder() .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) .registerTypeAdapter(Date.class, new FlexibleDateAdapter()) .registerTypeAdapter(LocalDateTime.class, new LocalDateTimeAdapter()) .disableHtmlEscaping() .create(); private JsonUtil() { } public static <T> T fromJson(String json, Type typeOfT) { return GSON.fromJson(json, typeOfT); } public static String toJson(Object obj) { return GSON.toJson(obj); } public static <T> T fromJson(String json, Class<T> clazz) { return GSON.fromJson(json, clazz); } }这个工具类有几个小设计点值得参考。第一,disableHtmlEscaping()让Gson不至于把<、>、&转成Unicode,这对包含HTML片段的数据很友好。第二,统一封装后,后续需要加适配器时只改一处即可,所有调用方自动生效。第三,工具类里少写花哨功能,只保留最常用入口,避免变成“垃圾场”。
如果你要用到不同的版本策略去做接口兼容,也可以构建多个Gson实例放在不同的内部类里,比如UserApiV1Holder、UserApiV2Holder。这个模式对老接口兼容特别有效,比每次从外部参数传版本号要干净。
我个人在实际项目里最深的体会是:Gson的能力边界不在库本身,而在你对自己的数据结构是否有清晰认知。你遇到的大部分解析难题,本质上都是“Java类型和JSON结构之间没有达成一致”。用TypeToken把泛型说清楚,用TypeAdapter把特殊格式管起来,用字段过滤把输出范围控制住,剩下坑也就没多少了。还有一个我保留了很久的习惯:每注册一个自定义适配器,至少留一个单元测试,用真实的生产JSON片段去跑一把,比在console里手动粘数据靠谱太多。这套组合拳打下来,线上Gson相关的告警基本就绝迹了。