Java 项目里用 fastjson 处理枚举,算是我见过问题最多的一类对象。数字、字符串、带别名的字段、加了@JSONCreator的工厂方法,每种写法的反序列化行为都不一样。如果你遇到过枚举反序列化结果是 null、直接抛异常、或者值和预期对不上,多半就是没吃透 EnumDeserializer 的处理逻辑。这篇文章以 fastjson 1.2.x 分支的 EnumDeserializer 源码为主线,把它的注册入口、核心方法、边界分支和几种使用场景完整过一遍,读完你就能明白为什么某些写法会翻车,也能少踩几个坑。
1. EnumDeserializer 在 fastjson 反序列化体系中的位置
1.1 先看反序列化器的注册入口
fastjson 里所有的反序列化器都挂在 ParserConfig 这棵树上。它是一个全局配置类,内部维护了一张 IdentityHashMap,key 是具体的 Class,value 是对应的 ObjectDeserializer 实现。每次调用 JSON.parseObject 的时候,parser 都会从 ParserConfig 里查目标类型对应的反序列化器,查不到就现场创建,创建完顺手塞进缓存。
对于枚举类型来说,这个查询和创建的过程都发生在 createDeserializer 方法里。代码大概长这样:
// ParserConfig.createDeserializer 中的简化逻辑 if (clazz.isEnum()) { if (containJSONCreator(clazz)) { deserializer = createJavaBeanDeserializer(clazz); } else { deserializer = new EnumDeserializer(this, clazz); } }这里面有个很容易被忽略的分支:如果枚举类里存在标注了 @JSONCreator 的静态方法,fastjson 不会走 EnumDeserializer,而是把它当成一个特殊的 JavaBean 来处理。换句话说,EnumDeserializer 只负责那些"没有自定义工厂方法"的普通枚举。很多人给枚举加了 @JSONCreator 之后发现行为没生效,就是因为这一层分流逻辑在 ParserConfig 里的优先级比 EnumDeserializer 更高。
另外一个细节是:ParserConfig 的这个映射是全局共享的。也就是说同一类型的枚举,反序列化器只会创建一次,之后所有解析都复用同一个实例。这意味着 EnumDeserializer 的构造函数里扫描枚举值构建映射表这件事,只会在第一次遇到该类枚举时发生,不会每次解析都重复反射,这是 fastjson 性能设计的一个关键点。
1.2 为什么枚举需要单独一个反序列化器
枚举在 JVM 里虽然是个特殊类,但它有几个和普通 JavaBean 完全不同的特性,导致 fastjson 必须专门写一个类来处理。
第一,枚举实例天然是单例的。一个枚举类的枚举常量是固定的,反序列化时不能 new 一个新实例,只能从已有的常量里挑一个返回。普通 JavaBean 反序列化是构造新对象,枚举则是查表返回老对象。
第二,枚举能够接受的输入形式比普通对象多得多。可以传枚举名字符串,可以传数字序号,还可以传和某个字段绑定的别名文本。fastjson 需要在这几种输入之间做判断和转换,这不是一个通用的 ObjectDeserializer 能搞定的。
第三,枚举的反序列化必须保证返回的是同一个 JVM 实例。如果用 == 比较两个反序列化出来的枚举值,结果必须是 true。如果走普通 JavaBean 逻辑搞出来两个不同实例,equals 虽然可能相等,但 == 就露馅了,这会直接破坏 switch-case 和单例模式的预期。
看到这里你应该能明白,EnumDeserializer 的职责其实很简单:输入一个 JSON 片段,快速定位并返回已经存在的枚举常量。它不需要构造对象,不需要填充字段,核心就是查映射表。
2. deserialze 方法源码全流程拆解
2.1 LITERAL_INT 分支:数字直接映射枚举数组索引
EnumDeserializer 的核心方法是 deserialze,整个方法就是围绕 JSONLexer 的 token 类型做分支。最优先处理的 token 是 LITERAL_INT,也就是 JSON 里直接写数字的情况。
if (token == JSONToken.LITERAL_INT) { int intValue = lexer.intValue(); lexer.nextToken(JSONToken.COMMA); if (intValue < 0 || intValue >= enums.length) { throw new JSONException("parse enum error, value: " + intValue); } return (T) enums[intValue]; }这里的关键点有两个。第一个,fastjson 用 intValue 直接作为数组下标去访问枚举常量数组。而这个 enums 数组来自 enumClass.getEnumConstants(),它的顺序就是枚举在源码里的声明顺序。也就是说,JSON 里传的数字 1,对应的是枚举里第一个声明的常量,而不是什么 hashCode 或者 ordinal 之外的什么值。这个行为本质上是直接利用了枚举的 ordinal 机制。
第二个关键点是边界检查。源码里对 intValue 做了范围校验,小于 0 或大于等于 enums.length 都会抛 JSONException。这个设计我认为是故意暴力的:如果前端传了一个不存在的序号,与其静默返回 null,不如直接报错让调用方发现数据问题。实际项目里见过有人把枚举删掉一个中间值,结果数据库里的旧数字就变成越界了,然后在反序列化的时候直接崩掉。
还有一个小细节是 lexer.nextToken(JSONToken.COMMA)。读取完数字之后,解析器要主动把 token 往前推一格,跳过可能的逗号分隔符。如果不跳,外层对象解析的循环会卡在同一个 token 上死循环。这个习惯在写自定义反序列化器时也要注意。
2.2 LITERAL_STRING 分支:先查 nameMap 再查 valueMap
当 JSON 里传的是字符串时,走的是另一个分支。
else if (token == JSONToken.LITERAL_STRING) { String name = lexer.stringVal(); lexer.nextToken(JSONToken.COMMA); if (name.length() == 0) { return null; } if (nameMap.containsKey(name)) { return (T) nameMap.get(name); } if (valueMap != null && valueMap.containsKey(name)) { return (T) valueMap.get(name); } throw new JSONException("parse enum error, value: " + name); }这里体现了 EnumDeserializer 的两层映射机制。nameMap 是最基础的一层,在构造函数里用一个循环把所有枚举常量的 name 放进去。比如 Color.RED 就对应字符串 "RED"。这一层解决的是 JSON 里直接写枚举名的情况。
valueMap 是第二层,它只有在枚举类里配置了特殊映射时才会被填充。构造函数里有一个重载版本:
public EnumDeserializer(Class<?> enumClass, Map<String, Enum> valueMap) { this.enumClass = enumClass; this.enums = (Enum[]) enumClass.getEnumConstants(); for (Enum e : enums) { nameMap.put(e.name(), e); } this.valueMap = valueMap; }这个 valueMap 的语义是"业务字段值到枚举实例的映射"。fastjson 在创建反序列化器时会扫描枚举类,把标注了 @JSONField 的字段或 getter 方法收集起来,以别名文本作为 key。比如一个订单状态枚举里有 statusDesc 字段,@JSONField(name = "desc"),前端传 "支付成功" 的时候就能通过 valueMap 找到对应的状态。
注意两个分支的查找顺序:先 nameMap 后 valueMap。如果你把业务的别名文本设置得恰好和某个枚举的 name 相同,那么 nameMap 会优先命中。这在实际项目中确实会遇到,所以才提醒大家:别名字段尽量避免取和枚举 name 一样的值。
空字符串在这个分支里会被直接转成 null,这个处理很符合 JSON 解析的语义。JSON 里的空字符串和 null 在很多场景下应当等价对待,fastjson 在这里选择宽容处理而不是报错。
如果两层映射都没命中,最后会抛 JSONException。这就是很多人看到 "parse enum error" 异常的直接来源。报错信息里会带上具体的文本值,排查时多留意这个值是什么。
2.3 兜底分支:parse() 之后的二次转换
前两个分支覆盖了 JSON 里最常见的形式,但 fastjson 还是要考虑一些更复杂的输入,比如嵌套的对象、数组或者其他奇奇怪怪的结构。deserialze 方法最后有一段兜底逻辑。
Object value = parser.parse(); if (value == null) { return null; } if (value instanceof Number) { int ord = ((Number) value).intValue(); if (ord >= 0 && ord < enums.length) { return (T) enums[ord]; } } if (value instanceof String) { String name = (String) value; if (nameMap.containsKey(name)) { return (T) nameMap.get(name); } if (valueMap != null && valueMap.containsKey(name)) { return (T) valueMap.get(name); } }先说这个分支什么时候会进来。当传入的 token 既不是数字也不是字符串,比如 JSON 里写的是一个对象 {"name":"RED"},或者一个布尔值 true,上面两个分支都不会命中,于是走到这里调 parser.parse() 把原始值解析出来,再尝试二次转换。
Number 和 String 的转换逻辑其实是在重复前面 LITERAL_INT 和 LITERAL_STRING 的处理。代码写成这样我认为是出于防御性考虑:你永远猜不到调用方会往枚举字段里塞什么东西,所以前面快速通道处理不了的时候,这里再兜底一次。
比较有意思的是,这里对 Number 类型用的是 intValue() 而不是直接强转。这意味着 JSON 里如果传 3.14 这种浮点数,会被截断成 3 再查表。这种隐式转换容易掩盖数据错误,我个人建议在业务层面做好校验,别依赖这个行为。
还有一点,这个兜底分支如果最终还是没有匹配到任何枚举,并不会主动抛异常,而是直接走到方法最后返回 null。也就是说,枚举反序列化同一个解析器,对畸形输入的处理结果可能不一样:快速通道里字符串匹配不上会报错,兜底通道里匹配不上却返回 null。这个差异在升级 fastjson 版本时很容易踩到,后面章节会详细说。
2.4 三个容易被忽略的实现细节
源码看完了,我再补充几个藏在实现里、不看代码很难发现的细节,这些细节直接影响线上行为。
第一个是 getFastMatchToken 方法的存在。EnumDeserializer 实现了 ObjectDeserializer 接口,其中有一个 getFastMatchToken 方法返回 JSONToken.LITERAL_INT。这个返回值告诉 fastjson 的解析引擎:"如果下一个 token 是数字,直接调用我的快速路径。" 这本质上是一个性能优化的信号。由于大多数枚举反序列化的场景都集中在数字和字符串两种 token 上,LITERAL_INT 被选为快速匹配 token,可以让默认 JSON 解析器减少不必要的分支判断。
第二个是 nameMap 用的是 HashMap 而不是 TreeMap 或 LinkedHashMap。因为枚举查找只需要命中,不需要保证遍历顺序,HashMap 的 O(1) 查找最合适。这个选择和枚举常量数组的声明顺序无关,数组索引只依赖 getEnumConstants 返回的数组位置。
第三个是构造函数的执行时机。EnumDeserializer 的实例是被 ParserConfig 缓存的,所以 nameMap 和 valueMap 的构建只发生在第一次遇到该类枚举时。我见过有人尝试在运行期动态修改枚举类的字段值来更新映射,这完全没有意义,因为枚举实例已经在缓存里,改了也不会触发重新扫描。如果你确实需要更新映射关系,应该重新配置 ParserConfig,而不是动枚举本身。
3. 三种生产场景与源码的对应关系
3.1 场景一:前后端直接传枚举名
最常见的用法就是 JSON 里直接写枚举的 name,这也是 nameMap 设计出来要解决的场景。
public enum Color { RED, GREEN, BLUE } public class Model { private Color color; // getter setter 省略 } String json = "{\"color\":\"GREEN\"}"; Model model = JSON.parseObject(json, Model.class);这个场景反序列化时 token 是 LITERAL_STRING,字符串 "GREEN" 去 nameMap 里命中对应的枚举常量。整个过程没有任何反射调用,就是一次字符串 HashMap 查找,非常快。
不过在真实项目中,我很少建议直接拿枚举名字符串做前后端协议。原因有两个。一是大小写完全敏感,前端如果传 "green" 或者 "Green" 都会抛 JSONException。二是枚举名重构的代价很大,一旦代码里做了重命名,老客户端传过来的字符串就全部失效。如果你只是写单机工具类或者内部调试接口,用枚举名没问题,但一旦涉及跨团队协作,就要谨慎评估稳定性。
3.2 场景二:用数字序号表示枚举
很多老系统喜欢用数字表示枚举值,JSON 里就变成了 {"status":1} 这种形式。这个场景正好落在 LITERAL_INT 分支,数字直接作为数组下标访问 enums。
String json = "{\"status\":1}"; // 如果 statusEnum 声明为 UNPAID, PAID, CANCELED // 那么 1 对应 PAID这里有个致命的坑:数字 1 不来自于任何显式的字段值,而是来自于枚举的声明位置。这意味着你只要调整了枚举常量的顺序,所有旧的数字映射就全乱了。比如你在 UNPAID 前面插了一个 INIT 状态,原本的 1 就从 PAID 变成了 INIT,线上数据会静默地映射到完全不同的业务含义上。
我在项目里排查过一次事故:数据库存的状态码是 2,某次迭代在枚举最前面加了一个新常量,导致所有 >= 1 的状态码整体错位,反序列化出来的实体全部是错误的状态。从那以后,我给自己定了一条规矩:凡是枚举要通过数字反序列化的,一律显式定义一个 code 字段,用 @JSONCreator 工厂方法来映射,绝对不依赖声明顺序。
3.3 场景三:JSONField 别名和 JSONCreator 工厂方法
如果你既不想用枚举名,又不想用声明顺序,那就需要给枚举绑定业务字段。fastjson 提供了两条路,分别对应源码里的两个不同机制。
第一条路是 valueMap。给枚举的某个 getter 或字段标注 @JSONField(name = "xxx"),fastjson 在创建反序列化器时会把 "xxx" 收集进 valueMap,反序列化时文本 "xxx" 就能命中对应枚举。
public enum OrderStatus { UNPAID("待支付"), PAID("已支付"), CANCELED("已取消"); private final String desc; OrderStatus(String desc) { this.desc = desc; } @JSONField(name = "desc") public String getDesc() { return desc; } }这里要特别注意,valueMap 的 key 是 @JSONField 里指定的 name 值,而不是枚举的 name。所以 JSON 传 {"status":"待支付"} 时,走的是 valueMap 分支,能正确映射到 UNPAID。
第二条路是 @JSONCreator 工厂方法。当枚举里存在这个注解标注的静态方法时,fastjson 会绕过 EnumDeserializer,把枚举当 JavaBean 处理,调用工厂方法传入 JSON 里的值。
public enum OrderStatus { UNPAID(1, "待支付"), PAID(2, "已支付"); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } @JSONCreator public static OrderStatus fromCode(int code) { for (OrderStatus status : values()) { if (status.code == code) { return status; } } return UNPAID; } }factory 方法的优势非常明显:映射逻辑完全由你自己掌控,可以基于任意字段、任意规则,甚至可以在找不到匹配项时回落到一个默认值。缺点也很明确,它绕过了 EnumDeserializer 的缓存机制,每次创建反序列化器时都要走 JavaBean 那一套流程,性能比直接查 HashMap 要低一个量级,但胜在灵活和可控。
3.4 与序列化的对称性
研究 EnumDeserializer 的时候,不能只看反序列化这一半,还要理解它和序列化行为的对称关系。fastjson 的 EnumSerializer 在序列化枚举时,会先判断是否存在 @JSONField 指定的 value 映射。如果存在,就输出映射值;否则输出枚举的 name。
这里的对称性意味着:你在 expression 枚举上用什么规则序列化,反序列化时也必须是同一套规则。fastjson 内部通过同一套扫描逻辑来保证这一点,所以只要你没有自己写自定义序列化器,一般不会出现"序列化输出 A,反序列化却解析不了 A"的情况。
但如果你在某个字段上手动加了 @JSONField 去覆盖序列化行为,同时又写了自定义反序列化器或者 @JSONCreator,那就需要自己保证两边的映射规则一致。我在实际项目里看到过这种错位:序列化输出的是业务码数字,反序列化时却期望枚举名,结果完成一次往返数据就变了。所以在设计枚举的传输协议时,第一原则就是选择一种范式并全程贯彻。
4. 高频问题排查与源码佐证
4.1 枚举反序列化结果为 null
反馈最多的问题就是反序列化出来字段值是 null。对照源码看,这个结果通常来自三个地方。
第一个是 JSON 里本身写了 null 或者空字符串。如果 token 是 LITERAL_STRING 且字符串长度为 0,直接返回 null。这个行为在 2.2 节已经说过,是显式设计的。所以如果你的前端框架会把空值序列化成 "",那反序列化拿到 null 是符合预期的。
第二个是 JSON 里写了一个既不在 nameMap 也不在 valueMap 里的字符串。如果是直接字段反序列化,会走 2.2 节的 LITERAL_STRING 分支,匹配不上直接抛 JSONException。但如果是嵌套在某些复杂结构里,走了兜底 parse() 分支,就有可能静默返回 null。因为兜底分支最后不抛异常,只是返回 null。这就解释了为什么相同的字符串,在顶层字段解析时报错,在 List 或者 Map 里解析却是 null。
第三个是你自己写了自定义反序列化器并返回了 null。这个属于使用方的问题,但也要提一下,因为很多人排查到后来才发现不是 fastjson 的锅。我的排查套路是:先看是不是空字符串和 null 输入,再看是不是走的兜底分支,最后再检查自定义反序列化器。
4.2 数字下标越界和声明顺序问题
数字越界会从源码的 LITERAL_INT 分支抛 JSONException,报错信息会包含越界的数字。这个异常本身很好定位,但背后的根因往往是枚举声明顺序变动。
具体来说,前端传的 code 大概率是业务层定义的语义值,而你代码里的枚举声明顺序是程序员眼中的语义值。这两者在初始阶段可能恰好一致,但随着版本迭代会慢慢失序。我在一个订单系统里见过 50 多个状态的枚举,声明顺序完全是按开发时间排的,没有任何业务逻辑,前端却一直在用数字传值,结果就是一个状态列表迭代了三个版本之后,数字映射彻底乱了。
对比源码可以发现一个反直觉的点:EnumDeserializer 只检查数字是否越界,不检查数字是否"有意义"。数字 3 如果落在 [0, enums.length) 区间内,即使它不对应任何业务语义,也会被静默转换成枚举。这种行为源于枚举序号本身就是合法的,fastjson 不可能替你判断业务语义。所以如果要实用数字反序列化,还是建议显式定义业务映射字段,别把顺序当协议的一部分。
4.3 别名失效和大小写敏感问题
valueMap 失效的最大嫌疑是 @JSONField 的 name 属性和实际传输的文本不一致。fastjson 扫描的 key 取自注解的 name,如果你的业务方传的是字段的原始值,那映射就命中不了。这个问题的排查方法是打印一下 valueMap 的 key 集合看看,但 EnumDeserializer 并没有提供访问 valueMap 的公开方法,你需要自己在枚举里写一个静态方法,模拟 fastjson 的扫描逻辑,把 @JSONField 的 name 值打印出来核对。
大小写敏感则是 nameMap 的固定行为。HashMap 的查找本身就是大小写敏感的,fastjson 没有额外做忽略大小写的处理。如果你确实需要大小写不敏感,有两个方案:一是把传输规范统一成大写,二是在枚举里加一个 @JSONCreator 方法手动处理字符串。我一般推荐第一个,因为改动最小且无歧义。
4.4 版本升级带来的行为变化
fastjson 版本迭代时枚举反序列化的行为确实变过几次,主要集中在兜底分支的异常处理上。早期版本里,匹配不上会直接抛异常;后续版本里在某些路径上改成了返回 null。这个变化在社区里引起过讨论,所以升级 fastjson 版本之后,一定要回归枚举反序列化的测试用例。
另外一个值得关注的变化是 JDK 高版本下的模块系统限制。JDK 17 之后,默认启用强封装,枚举类的内部字段反射会受到更多限制。fastjson 在处理带 valueMap 的枚举时,绕不开对私有字段和方法的访问,所以在 JDK 17 的项目里,如果遇到枚举反序列化功能异常,先检查是否需要在启动参数里添加反射开放的配置。这是 JDK 层面的变化,不是 fastjson 单方面能解决的。
5. 个人实操心得
源码看到这里,最后分享几个我自己总结的实操经验,都是踩过坑之后形成的习惯。
第一,除非是纯内部工具,否则不要用枚举名或声明序号做跨系统协议。最稳妥的方案是显式定义业务 code 字段,配合 @JSONCreator 写清楚映射逻辑。这样枚举怎么改名字、怎么增删,只要 code 不变,协议就一直稳定。
第二,如果要走 valueMap 的别名方案,一定要确认 @JSONField 的 name 属性写的是传输层文本,而不是 Java 字段名。这个坑我在新项目里差点又踩一次,前端传 "成功" 而我写的 name 是 "success",结果反序列化一路走到兜底返回 null,查了半天。
第三,在项目里加一个枚举反序列化的单元测试,把每个枚举类型的合法值、非法值、空串、null、数字越界都测一遍。fastjson 升级时只需要跑一遍测试套件,就能快速发现行为变化。这个习惯帮我躲过了两次版本升级带来的映射行为变化。
第四,如果你发现自己需要频繁处理枚举的复杂映射,可以考虑封装一个工具类,把"字符串转枚举"的逻辑收敛到一个地方,而不是散落在各个业务代码里。这样即使 fastjson 的行为将来又调整,你也只需要改一个文件。
最后再说一个容易被忽略的点:当枚举出现在泛型容器里,比如 List 或者 Map<String, SomeEnum> 时,反序列化不一定走 EnumDeserializer,可能先经过集合反序列化器再委托。所以排查问题时,先确认字段的完整类型结构,再判断到底走了哪条反序列化路径。这一点能帮你节省很多无谓的猜测时间。