我至今记得第一次在Postman里看到这行报错时的表情。接口文档明明写的是更新用户标签,我在请求体里填了一组键值对,结果点下Send,响应区迎面一个500,控制台刷出一行:JSON parse error: Cannot deserialize instance of java.util.ArrayList out of START_OBJECT token。当时我连“token”这个词都看得发愣,以为是后端接口崩了。后来做后端开发久了才明白,这不是什么玄学,就是Jackson在反序列化时发现你要的数据长错了形状。这篇文章不开玩笑、不绕弯,会从这行报错本身开始,讲清楚它的来龙去脉、最常见的发生原因、排查定位方法,以及按场景匹配的修复方案。不管你是前后端联调的新人,还是被同事丢过来一个异常堆栈的后端同学,都能在这里找到能直接上手的办法。
1. 先搞懂START_OBJECT和START_ARRAY:Jackson到底在抱怨什么
1.1 Jackson眼里的JSON不是一串字符串,而是一串Token
很多人第一次看到Cannot deserialize instance of java.util.ArrayList out of START_OBJECT token,第一反应是去翻代码里哪里写了“START_OBJECT”,其实这是Jackson内部的Token类型名。Jackson不是用正则去匹配括号的,它靠一个底层叫做JsonParser的组件,把JSON文本拆成一个接一个的Token流。
举个例子,最简单的JSON对象:
{ "name": "vip" }在Jackson内部,它会被解析成大概这样的Token序列:START_OBJECT表示读到了左花括号,FIELD_NAME表示读到了一个字段名,VALUE_STRING表示读到了一个字符串值,最后END_OBJECT表示读到了右花括号。如果是数组["vip", "normal"],则会有START_ARRAY、VALUE_STRING、VALUE_STRING、END_ARRAY。
反序列化器就是顺着这个Token流,一边看当前Token是什么,一边决定下一步怎么走。它没有“全局视野”,不能回头去重新理解你已经写过的JSON。所以一旦在某个位置拿到了和自己预期不一致的Token,立刻就会抛出异常。
1.2 为什么ArrayList偏偏要从START_ARRAY开始
ArrayList在Jackson里对应的是集合反序列化器。它看到目标类型是List之后,会先判断当前Token是不是START_ARRAY。是数组,它就进入“逐个读元素”的分支;如果当前Token是START_OBJECT,它就认为这个集合没法从对象里构造出来。
这里可以打个比方:你告诉快递员“我要一个塑料袋”,对方却递过来一个鞋盒。鞋盒不是不能装东西,但对于“请你把物品装进塑料袋”这个指令来说,开头就不对。ArrayList也一样,它只接受“数组”形状的起点。遇到{开头的对象,它直接拒绝继续往下处理。
所以Cannot deserialize instance of java.util.ArrayList out of START_OBJECT token这句话,用大白话翻译就是:程序想要一个数组,但你现在给的是一个JSON对象。就这么简单。
1.3 报错信息里的其他隐藏线索
别急着从头看异常堆栈,先看报错尾部。很多时候异常信息后面还会跟着一句:
(through reference chain: com.example.api.UserTagUpdateInput["tags"])这就是Jackson给你指路:出问题的是UserTagUpdateInput这个类里的tags字段。有时候还会附带line: 1, column: 8,说的是从请求体第一个字符开始数的第1行第8列附近。拿到这些线索,再回Postman里看原始报文,基本就能锁死问题字段。
另外,很多日志系统会把超长异常截断,所以有些人看到的报错是out of START_OB就结束了。这不是另一种报错,只是完整单词START_OBJECT被砍掉了。遇到这种情况,优先去服务端完整日志里找原始信息。
2. 九成原因都在这里:DTO字段是List,报文却给了对象
2.1 一个特别典型的翻车现场
假设有一个批量更新用户标签的接口,请求体长这样:
{ "userId": "u_123456", "tags": { "tagName": "vip", "remark": "active" } }后端定义的接收对象是:
public class UserTagUpdateInput { private String userId; private List<String> tags; // 省略构造方法和getter/setter }Postman一点Send,后端立刻抛出文章标题里那个JSON parse error。后端同事看代码,tags明明是个List<String>,没写错;前端同事也委屈,自己明明把tags传上去了。问题就出在:字段名一样,但字段值的结构完全不是一个东西。tags在后端期望的是["vip", "normal"]这种数组,而请求体里给的是{"tagName":"vip"}这种对象。
这是我在实际项目里遇到最多的一类情况,可能占所有同类报错的八成以上。它和代码逻辑无关,纯粹是接口的数据契约没对齐。
2.2 为什么会有人把数组写成对象
一个很常见的原因是,调用方在业务代码里拿到的本来就是一个Map。比如Java里有一个Map<String, String>,在构造请求体时顺手把它塞进了tags字段。JSON序列化之后,自然就变成{"key":"value"}这种对象。另一个常见原因是参考了历史接口的报文,以前这个字段可能是对象,后来后端改成了List,但文档和Postman示例没同步更新。
还有一类丢人情况:手写JSON时,有人把数组的方括号写成了花括号,或者从某个日志里复制了一段被截断的JSON,结果[变成了{。越低级的原因越容易在联调后期突然冒出来。
接口联调本质上是对齐“契约”。一边说“我传的是数组”,另一边说“我收到的是对象”,两边如果只围着异常堆栈猜谜,不回到原始报文上确认,就会浪费大量时间。
2.3 从异常尾部快速锁定问题字段
我现在排查这类报错,几乎不看异常的前半段,直接翻到最后一行through reference chain。它就像一份寻宝地图,从最外层类一路指到最内层字段。
比如,如果你的请求对象被包了一层,异常可能会显示成:
(through reference chain: com.example.api.req.BatchOpReq["input"]->com.example.api.req.UserTagUpdateInput["tags"])意思是:先进入BatchOpReq的input属性,再进入UserTagUpdateInput的tags属性,然后就崩了。拿着这个字段名,回Postman里看请求体中间对应的那一小段。这时候不要凭记忆判断,请把光标点到那段JSON文本上,看第一个字符到底是[还是{。
如果是{,那当前问题和本章描述的场景基本重合。如果是[,则要继续往下看数组里的元素结构,可能是某个元素内部是对象而后面有坑。
3. 另一个容易被忽略的元凶:请求体根本没有被当成JSON发送
3.1 Postman的Body类型和Content-Type
有一种报错,看起来和前面一样,但根因完全不同。那就是Postman的Body类型压根没选对。
在Postman里新建请求,Body的可选类型有:none、form-data、x-www-form-urlencoded、raw、binary、GraphQL。如果你的接口后端方法签名写的是@RequestBody UserTagUpdateInput input,那么Postman这边必须选择raw,并且右侧下拉框要选成JSON,而不是Text。
我见过有人用form-data,在Key里写tags,在Value里写["vip"]。发送后后端拿着multipart/form-data的请求体去走Jackson反序列化,结果自然也是各种解析异常。还有人选了raw,但右侧类型下拉框留在Text,此时请求头Content-Type是text/plain,后端可能先按字符串解析,再到字段转换时报错,甚至报出和标题完全一样的异常。
3.2 怎么快速确认后端收到的原始请求
遇到这种情况,最快的方法是看Postman的Console。打开Postman右下角的Console面板,重新发一次请求,里面会展示你实际发出的请求行、请求头和请求体。重点检查Content-Type这一行有没有application/json。
另一个我常用的招数是,从Postman生成curl命令。点击请求旁边的Code按钮,选择cURL,复制出来的命令里如果有:
-H 'Content-Type: application/json'说明类型是OK的。如果出来的是-H 'Content-Type: multipart/form-data',那问题就很清楚了。
如果你能改后端临时代码,也可以在Controller里加一行日志,把HttpServletRequest的Content-Type和请求体原样打出来。但要注意,请求体流只能读一次,如果直接读流会影响后续@RequestBody参数绑定,建议用ContentCachingRequestWrapper或者临时写一个Filter,再不行就先用Postman的Console判断。
3.3 还有一种手滑:JSON里混进了注释或多余逗号
Postman的raw编辑区默认不是严格的JSON校验器,手滑很容易写出带注释的JSON,或者数组末尾多了一个逗号。比如:
{ "tags": ["vip", "normal",] }这个请求发出去,后端解析时可能报的不是START_OBJECT,而是别的Unexpected character相关错误。但如果你在复制粘贴时把某个]换成了},那么报错就又回到我们今天讨论的主题了。所以定位阶段,一定先确认原始报文是合法JSON。可以先复制到任意JSON校验工具里看一眼,再去纠结代码。
4. 对号入座的修复方案
4.1 最简单直接:把请求体改成真正的JSON数组
如果接口规定tags就是数组,那Postman这边的请求体就应该这么写:
{ "userId": "u_123456", "tags": ["vip", "normal"] }这样Jackson读到tags时会先遇到START_ARRAY,再逐个把字符串放进ArrayList,问题当场消失。下面是错误和正确写法的对照:
| 字段场景 | 错误写法 | 正确写法 |
|---|---|---|
| List<String> | {"tags": {"0":"vip","1":"normal"}} | {"tags": ["vip","normal"]} |
| List<String> | {"tags": {"name":"vip"}} | {"tags": ["vip"]} |
| List<Object> | {"items": {"code":"A"}} | {"items": [{"code":"A"}]} |
注意第三行,如果List元素本身是对象,那数组里的每一项都必须是对象,不要混着写。比如["vip", {"code":"normal"}]这种写法,后端一样会在某个元素上报错。
修改完记得重新点Send,别只改了一半就测试。我在实际联调中见过有人把tags改成了数组,但另一个字段groups还是对象,结果异常还是报,只是报错字段变了。
4.2 如果业务本身就是键值对,后端类型改成Map更合适
还有一种情况,接口语义本来就不是“一组字符串数组”,而是“一组有名字的扩展属性”。比如标签除了名称还要有描述,前端数据结构本身就是:
{ "tags": { "vip": "核心付费用户", "normal": "普通用户" } }这时候后端硬要用List<String>去接,就是自己给自己挖坑。正确做法是跟着业务走,把DTO字段改成Map<String, String>:
public class UserTagUpdateInput { private String userId; private Map<String, String> tags; // 省略构造方法和getter/setter }Jackson内置对Map的反序列化本来就是从START_OBJECT开始的,键值对会被当成Map的key和value。请求体一行不用改,接口直接通了。
我见过很多团队在这类问题上反复拉扯:后端坚持“你就应该传数组”,前端坚持“我这边就是对象”。这时候建议回到业务层面重新思考,到底是“数组”还是“字典”。如果只是标签集合,数组够用;如果需要附带说明,或者要从前端Map直接映射过来,Map更合理。
4.3 两边一时改不动,可以用自定义反序列化器做兼容
有些情况比较现实:前端已经上线,后端接口也已经被多个调用方使用,短期没法强制所有人改报文。这时可以在后端做一个可控的兼容。思路是给tags字段写一个自定义反序列化器,让它遇到START_OBJECT时把对象的所有值收集成一个List,遇到START_ARRAY时保持正常数组解析。
一个简化版的实现如下:
public class StringListOrObjectDeserializer extends JsonDeserializer<List<String>> { @Override public List<String> deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { if (p.currentToken() == JsonToken.START_OBJECT) { List<String> result = new ArrayList<>(); while (p.nextToken() != JsonToken.END_OBJECT) { if (p.currentToken() == JsonToken.FIELD_NAME) { p.nextToken(); result.add(p.getValueAsString()); } } return result; } return ctxt.readValue(p, new TypeReference<List<String>>() {}); } }然后在DTO字段上标注:
@JsonDeserialize(using = StringListOrObjectDeserializer.class) private List<String> tags;这样{"tags": {"a":"vip"}}会被解析成["vip"],而["vip"]也能正常解析。
但我必须泼一盆冷水:这个方案不要当成默认标准。它本质上是掩盖了调用方的不规范,会让下游拿到一个“不知道是谁的value”的列表。如果对象里有多个字段,你只能选择值或键塞进List,这业务上很容易出歧义。我建议只在过渡期使用,并且要在这个字段的注释里写明“兼容旧报文,新接口请传数组”。
4.4 用最小接口复现一遍,建立直观感受
为了彻底搞清楚Jackson在这种场景下的行为,建议自己搭一个最小接口验证。不需要完整的业务代码,只要一个Controller和一个Input类:
@RestController public class DebugController { @PostMapping("/debug/items") public String debug(@RequestBody DebugInput input) { return input.toString(); } }public class DebugInput { private List<String> items; // getter/setter }用Postman分别发这三类请求体:
{"items":["a","b"]},应该正常返回对象内容。{"items":{"a":"b"}},应该复现Cannot deserialize instance of java.util.ArrayList out of START_OBJECT token。{"items":null},通常不会报错,但后端拿到的是null。
把这三种结果记下来,你就知道以后该往哪个方向排查。再把items字段改成Map<String,String>测试,你会发现同样的对象写法直接通过。这种已验证的经验,比背报错信息更可靠。
5. 顺着这个报错,把Jackson反序列化的同类坑也一并避掉
5.1 List字段遇上空字符串
除了START_OBJECT,List还会遇到空字符串。比如请求体里写的是:
{ "tags": "" }后端List<String> tags同样会报反序列化错误,不过异常信息通常会变成Cannot deserialize value of type java.util.ArrayList from String value。这是因为Jackson知道你要List,但""既不是数组也不是对象,无法构造列表。
这种问题常见于前端没有值的时候填了空字符串,而不是null。如果接口允许空,建议前端约定传[]或null,后端在setter里对空字符串做兜底:
public void setTags(List<String> tags) { this.tags = tags == null ? new ArrayList<>() : tags; }5.2 数组里的元素类型不匹配
还有一种情况是List本身传对了,但数组里的元素类型不对。比如后端是:
private List<Integer> scores;请求体是:
{ "scores": ["100", "abc"] }Jackson对字符串"100"可以尝试转成Integer,但遇到"abc"时就会在数组内部报类型转换错误。这类错误的报错信息通常是Cannot deserialize value of type int from String "abc",不是我们今天讨论的START_OBJECT,但同样会包装在JSON parse error外层。排查时记得往Caused by里看,不要停在第一层。
5.3 数组对象里的字段缺失或未知字段
如果List元素是一个自定义对象,而这个对象里缺少必要字段,Jackson可能报InstantiationException、MissingKotlinParameterException等错误;如果多传了后端没有的字段,默认行为可能报Unrecognized field。当然,Spring Boot对不同配置下这些行为会有差异。
这些坑和标题里的报错名字看着像,其实根因都不同。我的建议是:不要把所有JSON parse error都当成一个原因,先看Caused by,再看through reference chain,最后才回代码里找对应字段。
5.4 调试阶段别急着用“忽略未知字段”这类配置
有些同学遇到类似报错,第一反应是全局配置Jackson,把未知字段忽略,或者关闭某些严格模式。这在某些老项目里是常规操作,但用在联调阶段会掩盖真问题。
如果你不知道当前报错是数据结构不匹配,还是字段名拼错,就先不要加@JsonIgnoreProperties(ignoreUnknown = true)这类开关。正确做法是把异常完整打印出来,特别是through reference chain指向的字段名,和DTO里的字段名逐一对比。哪怕最后确实要忽略未知字段,也请等技术方案确定后再动手,而不是为了先让接口不报错再回头清理。
5.5 给团队的接口联调留一份“安全的默认值”
今天这个问题,本质上是接口契约没写清楚。我建议在你维护的接口文档里,给每个List字段附上JSON示例,并且在Postman Collection里导出带示例的请求模板。
我自己习惯在每个DTO里给字段写注释:
/** * 用户标签列表。 * 正确请求体示例:["vip","normal"] * 不要传成对象:{"tagName":"vip"} */ private List<String> tags;这个注释成本很低,但能帮前后端减少很多无谓的沟通。时间长了,团队里“List传成对象”这类事故会明显变少。
最后说个我的习惯。凡是别人发来这种JSON parse error,我第一步一定是先去要原始请求报文,而不是急着看堆栈。因为这类错误的根源九成在报文本身。把报错里那三个英文单词翻译成人话:现在碰到的是对象,但程序想要的是数组。把数据结构对齐,问题自然消失。你可以在自己负责的接口里提前把List、Map的示例写成注释或文档,省得每次联调都在同一类坑里反复横跳。