ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

SpringBoot fastjson 1.x → fastjson2 2.0.63 迁移执行手册

2026/8/4 7:18:29 拓冰建站 浏览量
SpringBoot fastjson 1.x → fastjson2 2.0.63 迁移执行手册

SpringBoot fastjson 1.x → fastjson2 2.0.63 迁移执行手册

适用:把com.alibaba:fastjson(1.x)迁移到com.alibaba.fastjson2:fastjson2(2.0.63)。

这份文档是可执行清单,不是背景介绍。按 §1 → §7 顺序做,每步都有检测命令和确定的改法。

三条铁律(违反其中任何一条,迁移就是白做或留下静默故障):

  1. 坐标必须换成com.alibaba.fastjson2:fastjson2。安全扫描按groupId:artifactId匹配 CVE,只升版本不换坐标,告警不会消失
  2. 必须加 §4 的全局兼容配置。缺了它,两个默认行为变化会造成不抛异常、结果悄悄变错的线上事故。
  3. 遇到兼容问题就地解决,不要退版本。目标版本恒定 2.0.63。

§1 依赖改造

1.1 先统一,再迁移

多模块项目的旧版本号通常散落在各模块 pom 里(多个 1.x 版本并存)。先收敛到父 pom 一处,否则改完主线仍有模块在用旧包。

<!-- 父 pom:唯一版本来源 --><properties><fastjson.version>2.0.63</fastjson.version></properties><dependencyManagement><dependencies><dependency><groupId>com.alibaba.fastjson2</groupId><artifactId>fastjson2</artifactId><version>${fastjson.version}</version></dependency></dependencies></dependencyManagement>

子模块只声明坐标、不写 version。有独立父 pom 的模块(不继承主父 pom)要单独同步。

1.2 检测并处理

# 旧坐标(含第三方传递依赖 —— 有的话你换了自己的也白搭)mvn dependency:tree-Dincludes='com.alibaba:fastjson*'# 各模块 pom 里的旧坐标声明grep-rn"<artifactId>fastjson</artifactId>"--include="pom.xml".
情况动作
模块源码有 fastjson 调用换坐标,version 交给父 pom
模块源码零调用(grep 包名无命中)直接删掉依赖,不要只升版本
第三方库传递带入旧坐标单独评估:升级该第三方,或<exclusions>排除

§2 包名与 API 替换

2.1 包名映射

com.alibaba.fastjson.JSON/JSONObject/JSONArray/JSONExceptioncom.alibaba.fastjson2.*
com.alibaba.fastjson.TypeReferencecom.alibaba.fastjson2.TypeReference
com.alibaba.fastjson.annotation.JSONField/JSONTypecom.alibaba.fastjson2.annotation.*
com.alibaba.fastjson.serializer.SimplePropertyPreFiltercom.alibaba.fastjson2.filter.SimplePropertyPreFilter

不要用sed 's/com\.alibaba\.fastjson\./com.alibaba.fastjson2./g'一把梭—— 会漏掉serializerfilter的子包改名,报错是「找不到符号」,容易误判成缺依赖。

2.2 API 替换(编译期会报错的)

原写法改成
JSONObject.toJavaObject(json, Xxx.class)(静态形式)JSON.parseObject(str, Xxx.class)
JSONObject.toJSON(obj)JSON.toJSON(obj)
JSONObject.parseArray(str)JSON.parseArray(str)
JSON.toJSONString(obj, true)JSON.toJSONString(obj, JSONWriter.Feature.PrettyFormat)
JSONObject.toJSONString(obj, filter)JSON.toJSONString(obj, filter)
@JSONType(serializeEnumAsJavaBean = true)@JSONType(writeEnumAsJavaBean = true)

2.3 🔴JSONObject.parse()JSON.parse()(编译期不报错,运行期炸)

这是全流程里最容易漏的一条,优先处理。

// ❌ 危险:v1 里 parse 是从 JSON 继承来的静态方法,返回 Object;// v2 里被重新声明为返回 JSONObject,传入数组直接抛 JSONExceptionObjectresult=JSONObject.parse(text);// ✅ 正确:JSON.parse 保留返回 Object 的多态语义Objectresult=JSON.parse(text);

JSONObject赋给Object类型放宽,编译器绝不会报错。运行时传入[开头的数组才炸:
JSONException: offset 1, character [, line 1, column 1

grep-rn"JSONObject\.parse("--include="*.java".# 命中即改

2.4 立个规矩:统一只用JSON.作为静态入口

JSONObject/JSONArray只当类型用,不当工具类用

v1 里JSONObject继承JSON,所以JSONObject.parseObject(...)这类写法能编译,看起来和JSON.xxx()等价 —— 但 v2 里有的被删、有的被收窄(§2.3)。统一入口能一次性消灭这整类陷阱,且这批改动语义完全等价、零风险。

grep-rnE"JSON(Object|Array)\.(parseObject|parseArray|toJSONString|toJavaObject|toJSON|parse)\("--include="*.java".

§3 🔴🔴 两个静默行为变化(唯一会造成线上事故的)

不抛异常、不打日志,只是结果悄悄变了。编译和普通单元测试都发现不了。
处理方式是 §4 的全局配置 —— 一行覆盖全部实体,不要指望逐个补注解。

3.1 智能字段匹配默认关闭

fastjson1fastjson2 原生
大小写不敏感的 key→字段匹配开启关闭

对接外部系统时,返回的 JSON key 常是全小写或全大写(msgidroomidtolistACTVNM),而实体字段是驼峰且历史代码普遍没加@JSONField(name=...)—— 因为 v1 靠智能匹配自动对上了。

v2 原生下这些字段全部静默变null,后果还会二次放大:

key 匹配不上 → 实体业务主键为 null → 落库时唯一索引冲突 → 第一条侥幸写入,后续全部失败 → 日志只有「已收到」,没有任何异常

排查时看到的是「数据少了、字段空了」,离根因隔三层,极费时。

3.2 Date 默认序列化格式改变

fastjson1fastjson2 原生
java.util.Date毫秒时间戳1784685600000字符串"2026-07-21 10:00:00"

发往外部系统的报文时间格式全变。自己系统察觉不到(反序列化时两种格式 v2 都能读),只有对端解析失败 —— 往往等对方投诉才知道。


§4 必须添加的全局兼容配置

直接复制。三个语句的顺序不能变。

importcom.alibaba.fastjson2.JSON;importcom.alibaba.fastjson2.JSONReader;/** * fastjson2 原生模式全局兼容配置 —— 恢复 fastjson1 的默认行为。 * 缺少本配置会造成「不抛异常、结果悄悄变错」的静默故障,禁止删除。 */publicclassFastjson2CompatConfig{static{applyV1CompatibleDefaults();}publicstaticvoidapplyV1CompatibleDefaults(){// [1] 必须最先执行,且必须早于任何 fastjson2 调用:强制初始化 MethodHandles.Lookup 类。// JDK 8 上 fastjson2 的 JDKUtils 静态块用 Unsafe 读 Lookup.IMPL_LOOKUP 拿 TRUSTED lookup,// 而 Unsafe 读静态字段不触发类初始化 —— 若 Lookup 尚未初始化则读到 null,// fastjson2 退化为无 PRIVATE 权限的 lookup,其 lambda 访问器随即抛// LambdaConversionException: Invalid caller: <Bean类>,// 后果是该 JVM 内所有 Bean 反序列化全部失败。// 返回值无需使用,要的只是「类被初始化」这个副作用。禁止删除本行。java.lang.invoke.MethodHandles.lookup();// [2] 恢复大小写不敏感的智能字段匹配(见 §3.1)JSON.config(JSONReader.Feature.SupportSmartMatch);// [3] 恢复 Date -> 毫秒时间戳(见 §3.2)// ⛔ 不要用 JSONWriter.Feature.WriterUtilDateAsMillis:它是硬覆盖,// 会让字段上的 @JSONField(format=...) 全部失效。// ✅ configWriterDateFormat 设的是「默认格式」,字段级注解仍可覆盖,与 v1 语义一致。JSON.configWriterDateFormat("millis");// 加一行启动日志,测试/运维可据此在启动日志确认配置生效// log.info("fastjson2 兼容配置已应用: SupportSmartMatch + writerDateFormat=millis");}}

4.1 放在哪里

情况落点理由
有共享基础库,所有服务都依赖放共享库,注册为自动配置(Spring Boot 2.x:META-INF/spring.factories;3.x:META-INF/spring/...AutoConfiguration.imports自动配置不受各服务@ComponentScanexcludeFilters影响
某服务显式排除了共享库的包扫描在该服务包内再独立放一份别赌「自动配置一定不受影响」这个假设
非 Spring 应用 / 独立main()入口第一行显式调用applyV1CompatibleDefaults()没有容器帮你加载配置类

⚠️用静态代码块,不要只靠@Bean方法—— 这样类被任何方式加载时配置都会生效,不依赖 Spring 是否真的实例化了它。

4.2 JDK 8 的LambdaConversionException(配置里第 [1] 行解决的问题)

现象

RuntimeException: Failed to create lambda for method: public void XxxDTO.setYyy(java.lang.String) Caused by: java.lang.invoke.LambdaConversionException: Invalid caller: com.example.XxxDTO

一旦出现,该 JVM 内所有 Bean 反序列化全部失败(不只是报错那个类)。

为什么表现为「时好时坏」:只要在 fastjson2 初始化之前有任何代码碰过MethodHandles.Lookup,就一切正常。而门槛极低 ——任何一个 lambda / 方法引用invokedynamic引导时都必须用到Lookup,一个Runnable r = () -> {};就够。

场景是否中招
Spring Boot 服务正常启动❌ 不中招(框架启动过程满是 lambda)
JUnit / surefire 跑测试❌ 不中招(同理)
public static void main()调试类必中招

所以典型症状是:线上和测试环境都正常,只有开发在 IDE 里跑main()报错—— 极易被当成本地环境问题忽略。

配套动作

# 确认没有类在静态初始化里调用 fastjson2(那种类可能比配置类先加载,预热就晚了)grep-rnE"static[[:space:]]+(final[[:space:]]+)?[A-Za-z<>,\[\][:space:]]+=[[:space:]]*(JSON|JSONObject|JSONArray)\."--include="*.java".# 生产代码里残留的调试用 main():这些不走 Spring 容器、加载不到配置类,必然中招。建议删除grep-rn"public static void main"--include="*.java"src/main/

⚠️判定要点:如果生产环境出现的是「字段为 null」而不是这个异常,说明 reader创建成功了(只是 key 没匹配上),那是 §3.1 的问题,不是这里的问题。两者现象完全不同,别搞混。

副作用(要如实记录,别当无害空调用):预热成功后 fastjson2 拿到的是 TRUSTED 级 lookup(对任意类都有 private 访问权)。可接受,因为 fastjson2 既然在 classpath 上,任意一个 lambda 就能让它拿到,这一行没有给它原本拿不到的东西;且 fastjson 系列的历史漏洞是 autoType 反序列化 gadget,与 lookup 权限无关。


§5 验证

5.1 差分测试:以 fastjson1 的真实 jar 为基线逐项比对

这是唯一能发现「静默行为变化」的手段。两套 classpath(一边挂 1.x jar、一边挂 2.0.63),跑同一组用例,逐字节比对输出。不一致的每一项都要能解释清楚,不能一句「应该没影响」带过。

覆盖清单(可直接用作用例模板):

类别覆盖内容
取值 APIgetString/getInteger/getLong/getBoolean/getDate/getObject/getJSONObject/getJSONArray/toJavaList
边界输入key 不存在、空字符串、null入参、类型不匹配
结构嵌套泛型TypeReferenceJSONArray下标访问、值本身是 JSON 字符串时调getJSONArray是否自动二次解析
序列化transient是否跳过、SimplePropertyPreFilter父子类作用域、@JSONField(serialize=false/deserialize=false)
注解组合@JSONField(name)+@JSONField(format)与全局配置的优先级
日期Date/LocalDateTime的序列化与反序列化格式

已知的唯一可接受差异:同一对象被重复引用时,v1 输出 fastjson 私有的$ref标记,v2 直接把对象写两遍。若无代码依赖$ref,v2 的输出对外部消费者反而更安全。

5.2 留一组「必须失败」的回归用例

针对 §3 的静默行为写死断言,注明禁止删除

用例守住的东西
全小写 key 能映射到驼峰字段(断言字段非 null)SupportSmartMatch
Date序列化结果精确等于毫秒时间戳(如{"createTime":1784685600000}日期格式
@JSONField(format)的字段仍按注解格式输出没误用WriterUtilDateAsMillis
JSON.parse("[...]")返回JSONArrayJSON.parse("{...}")返回JSONObject§2.3 的返回类型语义

5.3 单元测试的两个盲区(吃过亏)

  • 测试 Bean 必须用独立顶层类,不要用测试类的静态内部类。Lookup.in()的规则是「同一 top-level 类内部才保留 PRIVATE 权限」,嵌套类恰好绕过 §4.2 的缺陷,把问题完全掩盖。
  • 依赖类初始化顺序的缺陷,单元测试天生测不出。JUnit/surefire 启动本身就会初始化MethodHandles.Lookup,所以 §4.2 那个缺陷在测试里永远是通过。这类问题只能靠独立 JVM 差分(每个场景一个全新进程)。

写用例前先自问:这个用例在缺陷存在时会失败吗?答案是「不一定」,它就不是防线,只是装饰。


§6 收尾审计(应全部为空,或人工逐条确认)

# 1. 残留旧包名(注意末尾的点)grep-rn"com\.alibaba\.fastjson\."--include="*.java".# 2. 残留旧坐标(含传递依赖)grep-rn"<artifactId>fastjson</artifactId>"--include="pom.xml".mvn dependency:tree-Dincludes='com.alibaba:fastjson*'# 3. 🔴 返回类型被收窄的调用grep-rn"JSONObject\.parse("--include="*.java".# 4. 应统一为 JSON. 入口的静态调用grep-rnE"JSON(Object|Array)\.(parseObject|parseArray|toJSONString|toJavaObject|toJSON|parse)\("--include="*.java".# 5. 注解属性改名grep-rn"serializeEnumAsJavaBean"--include="*.java".# 6. prettyFormat 布尔重载grep-rnE"toJSONString\([^)]+,[[:space:]]*(true|false)[[:space:]]*\)"--include="*.java".# 7. 强转 JSON.toJSON() 结果的地方(确认入参是对象而非 JSON 字符串,见 §7.3)grep-rn"JSON\.toJSON("--include="*.java".# 8. 静态初始化里调用 fastjson2(见 §4.2)grep-rnE"static[[:space:]]+(final[[:space:]]+)?[A-Za-z<>,\[\][:space:]]+=[[:space:]]*(JSON|JSONObject|JSONArray)\."--include="*.java".# 9. 生产代码里的调试 main()(见 §4.2)grep-rn"public static void main"--include="*.java"src/main/

无法机械检测、必须人工梳理的一项:所有「接收外部系统 JSON 的实体」清单 —— 靠 §4 的全局配置兜住,再逐步补@JSONField(name=...)

6.1 发版顺序(顺序错会导致服务仍在用旧包)

  1. 先发共享基础库(含全局兼容配置的那个),推私服
  2. 再发直接依赖 fastjson 的服务
  3. 最后发只通过共享库间接依赖的服务

验收项:

  • 各服务依赖树里已无旧坐标
  • 各服务启动日志能看到兼容配置那行 log(看不到就说明配置没加载,§3 两个坑必然复现
  • 重跑安全扫描,确认告警确实消除(这是整件事的目标,别忘了验收)
  • 按各服务 fastjson 调用文件数排测试优先级,用量最大的优先深测

§7 排查时会误导你的信号

7.1 不要用JSON.VERSION判断版本

上游有时忘了同步这个常量,它可能与实际 jar 版本不符,异常堆栈里打的fastjson-version也来自它。会让人误判成「IDE classpath 陈旧」,白花时间清缓存、重导项目。

unzip-pfastjson2-*.jar META-INF/maven/com.alibaba.fastjson2/fastjson2/pom.properties# 权威shasum fastjson2-*.jar&&catfastjson2-*.jar.sha1# 校验 jar 完整性mvn dependency:tree-Dincludes='com.alibaba*:fastjson*'# 实际生效版本

7.2 共享库改完必须重新 install

否则下游模块解析到本地仓库里的旧 POM,表现为「明明改完了,依赖树里 fastjson 还在」。

mvninstall-pl<共享库模块>-am-DskipTests

IDE 也要手动 Reload Maven Project,否则 IDE 内的运行/调试仍用旧 classpath。

7.3 别把所有异常都归因到升级

迁移期间任何报错都会被当成升级引起。先用 fastjson1 的 jar 复现一遍再下结论。

高频误判:(JSONObject) JSON.toJSON(jsonString)ClassCastException
toJSON()的职责是「Java 对象→ JSON 结构」,传字符串进去它原样返回 String ——v1 行为完全相同,是一直存在的错误用法,与迁移无关。

目的正确 API
JSON 字符串 →JSONObjectJSON.parseObject(str)
JSON 字符串 → 未知结构(可能是数组)JSON.parse(str)
Java 对象 →JSONObjectJSON.toJSON(obj)
Java 对象 → JSON 字符串JSON.toJSONString(obj)

三句话总结

  1. 编译通过 ≠ 行为一致唯一造成线上事故的两个坑(§3 智能匹配、日期格式)编译期和单元测试都不报错。精力放在差分测试上。
  2. 审计 API 要看返回类型和语义,不能只看方法是否存在。返回类型收窄 + 赋值给Object,能完美骗过编译器(§2.3)。
  3. 写回归用例前先问:这个用例在缺陷存在时会失败吗?用嵌套静态类当测试 Bean、或测一个依赖类初始化顺序的缺陷,用例永远是绿的。