如果你在接口联调时看到过这一行红色报错——JSON parse error: Cannot deserialize instance ofjava.util.ArrayListout of START_OB,那说明你也在POSTMAN传参这件事上栽过跟头。这个报错在Java后端接口联调里出现频率极高,几乎是每个用Spring Boot + Jackson做JSON序列化的团队都会遇到的经典异常之一。光看报错本身,很多人会一头雾水:请求明明发出去了,参数也填了,后端就是不认,到底哪里出了问题?
这篇内容我会把这个报错彻底讲透。从错误信息的含义、触发场景,到前后端两侧的修正方案、排查套路,再到我自己踩坑后沉淀下来的一些习惯,全部整理出来。不管你是刚接触接口调试的新手,还是被这个报错反复折磨的熟练工,这套思路都能帮你快速定位问题并且让接口稳定跑起来。
1. 先看事故现场:这个报错到底触发在哪一个环节
1.1 把错误信息逐段翻译成人话
先别急着改代码,把报错信息拆开看清楚。JSON parse error是Jackson序列化框架抛出的统一前缀,意思是"我在解析你传来的JSON字符串时出了岔子"。真正的核心在后面这句:Cannot deserialize instance ofjava.util.ArrayListout of START_OBJECT。
这里面有两个关键角色:
java.util.ArrayList是后端Java方法期望接收的最终类型。START_OBJECT是Jackson对JSON令牌(token)的称呼,它代表JSON对象开始的那个左花括号{。
所以整句话的意思是:后端这个方法本来想接收一个java.util.ArrayList,结果Jackson在解析时发现,你传过来的JSON开头是一个左花括号{,也就是一个JSON对象的开头,而不是期望的左方括号[——那是JSON数组的开头。Jackson在读取第一个令牌时发现类型对不上,直接判定为输入数据和目标类型不匹配,于是抛出了MismatchedInputException。
你可以把这个过程想成去停车场停车。你的车位是"SUV专用",结果入口识别到你是一辆轿车,闸机当然不会抬杆。车辆类型和车位类型不匹配,就是这里发生的事情。后端要的是数组,你给的是对象,两边对不上,这跟JSON语法有没有写错是两回事。
1.2 三类高频触发场景,看看你中了哪个
根据我在实际项目中看到的案例,绝大多数人遇到这个报错,都逃不出下面三种情况。
第一种,批量接口收到单个对象。后端接口设计成接收List<UserDTO>,也就是一个用户对象数组,前端或Postman里直接传了一个UserDTO对象,外层是花括号而不是方括号。我见过最多的就是这种情况,比如一个人负责后端,另一个人负责前端或者联调,接口文档里写的是"请求体是用户列表",结果前端顺手就传了单个对象。
第二种,泛型参数被传成了非数组。后端接口是List<String>、List<Integer>这种简单类型列表,调试人员在Postman里写了一个裸字符串或者裸数字,比如"hello"或者123,Jackson同样会报这个错,因为它期望的是[。
第三种,整个请求体被多余包装了一层。这种情况更有迷惑性。比如Postman的raw里写的是:
{ "code": 0, "data": { "name": "张三" } }而后端接口写的是@RequestBody List<SomeDTO>,看起来"好像传了JSON,格式也挺完整",但最外层依然是对象开始标记{,和ArrayList不匹配,一样会触发这个异常。
这三种情况的表现形式不同,但根子都在同一个地方:JSON最外层的令牌形态,和后端接口的参数类型没有对齐。
2. 根因拆解:为什么一个花括号就能让Jackson抛异常
2.1 List和JSON数组之间的"双向约定"
Java的List接口以及最常用的实现类ArrayList,在Jackson的世界里对应的是JSON数组,也就是方括号[...]包起来的一组有序元素。这是一个双向约定:Java侧你写List<UserDTO>,Jackson序列化时就会输出一个JSON数组;反过来,JSON里只有数组结构才能被Jackson反序列化成Java的List类型。
对于请求体解析来说,Jackson的逻辑是先看目标类型。如果目标类型是ArrayList,它就会在JSON流里寻找START_ARRAY令牌作为开始标记。碰到START_OBJECT,也就是{,它根本不会去考虑"里面装的是什么",直接判定为类型错误。哪怕你写的JSON对象结构非常完整、字段都对得上、注释也写得漂亮,一样白搭——数组就是数组,对象就是对象,这就是Jackson的规矩。
这个机制是很多类似报错的共同来源,尤其是当你把List改成别的复杂泛型时,报错信息里的"目标类型"会变,但错误的本质逻辑完全一致。明白了这一点,你就知道遇到这种报错不用慌,第一反应应该是看JSON最外面的那个字符到底是[还是{。
2.2 类型不匹配的三种典型姿态
我在维护项目时把实际遇到的POSTMAN入参形态归纳成了三种典型姿态,你可以对照检查。
姿态A:少了外层方括号。最典型的情况,后端是List<UserDTO>,你传的是:
{ "name": "张三", "age": 25 }这已经不仅仅是"少个方括号"的问题,而是JSON整体结构都不对了。正确写法必须是:
[ { "name": "张三", "age": 25 } ]姿态B:元素类型被"对象化"。后端是List<Map<String, Object>>,请求体写成:
{ "key1": "value1", "key2": "value2" }这同样不对,因为Jackson期望的是"数组里面装若干个Map对象",而这里整体是一个Map。改成:
[ { "key1": "value1", "key2": "value2" } ]姿态C:整体结构被包了一层。后端是List<String>,请求体是:
{ "ids": ["a", "b"] }这其实是一个拥有ids属性的对象,不是纯数组。对List<String>来说,正确写法是:
["a", "b"]每一类姿态的修正方向完全不同,所以排查时不要只想着"把括号加上",要先想清楚后端接口真正想要的结构是什么。
2.3 别忽略RequestBody注解与泛型的影响
这个报错经常发生在标注了@RequestBody的Controller方法参数上。@RequestBody告诉Spring把HTTP请求体完整地交给HttpMessageConverter去做反序列化。当你的项目使用的是Jackson时,这个转换器会读取目标参数类型的泛型信息,然后决定用什么结构去匹配JSON。
但这里有一个容易被忽略的细节:泛型信息能不能被正确读取,取决于方法签名上有没有保留泛型。比如:
@PostMapping("/batch") public Result batch(@RequestBody List<UserDTO> users) { // ... }Spring从方法签名里能拿到List<UserDTO>的完整泛型信息,反序列化没有问题。但如果你把请求体参数对象单独抽出来,又做了一层泛型擦除,比如:
public class BatchRequest<T> { private List<T> items; }然后在Controller里写@RequestBody BatchRequest request,那运行时T已经擦除成了Object,Jackson虽然能反序列化成一个ArrayList,但里面的每个元素都会变成LinkedHashMap,而不是你想要的UserDTO。用的时候ClassCastException就会出现。
所以如果你不是直接写List<具体类型>,而是经过了包装类、基类、泛型抽象,那么排查范围就不只是Postman侧的结构问题了,还要确认后端泛型是否完整保留。最省事的办法是:在Controller方法签名里直接写具体类型,不要偷懒写裸的List或者Map。
3. 一步步实操:把Postman请求和后端接口调通
3.1 复现步骤:把问题稳定复现出来再动手
排查问题之前,先把错误稳定复现出来,避免你改了半天,结果发现根本没在同一个问题上。复现步骤很简单,我会按下面几步来做。
第一步,确认后端接口已经启动,并且能正常接收请求。打开日志,或者在Controller方法第一行加一个入口日志,比如:
@PostMapping("/batch") public Result batch(@RequestBody List<UserDTO> users) { log.info("received users: {}", users); // ... }第二步,在Postman里创建一个新请求,方法选POST,URL填接口地址。
第三步,在Headers标签页手动加一行,Content-Type的值必须是application/json。这一步很多人会漏掉,后面我会专门讲为什么。
第四步,切到Body标签页,选择raw,右侧下拉框选JSON,然后填入错误的请求体:
{ "name": "张三", "age": 25 }点击Send,把报错信息完整保存下来。确认报错和标题里的一致,复现就成功了。然后你再把请求体改成数组格式再发一次,观察后端能不能正常处理。两个请求对比着看,你对问题原因就会非常直观。
3.2 方案一:Postman侧修正请求体(最常用)
大多数情况下,后端接口定义并没有问题,问题出在Postman请求体写错了。修正的核心就一句话:把最外层的{改成[,然后把原本的内容作为数组里的一个元素放进去。
举个例子,后端接口接收的是用户列表,原来的错误写法:
{ "name": "张三", "age": 25 }改成正确写法:
[ { "name": "张三", "age": 25 }, { "name": "李四", "age": 26 } ]一次传多个对象,就直接在数组里加逗号分隔项。后端@RequestBody List<UserDTO>就能正常反序列化。
如果后端接口接收的是List<String>,那正确写法就是:
["java", "python", "go"]如果后端接收的是List<Integer>:
[18, 19, 20]有些调试人员希望传一个空列表,那就写成:
[]这也能正常反序列化成一个空ArrayList,不会触发这个报错。这个方法几乎适用于所有"目标类型是List,但请求体最高层是对象"的场景。
3.3 方案二:后端收窄接口参数类型
有一种反向思维也值得掌握:如果这个接口在业务上根本不需要批量,只接收单条数据,那就不该把参数定义为List。我遇到过一些团队里的接口,其实业务上就是创建一条用户记录,但不知道什么原因,方法签名写成了List<UserDTO>,于是前端每次都要煞费苦心地包一层数组。
这种情况可以考虑把后端接口参数改成单个对象:
@PostMapping("/create") public Result create(@RequestBody UserDTO user) { // ... }请求体也自然变成:
{ "name": "张三", "age": 25 }两边都清爽了,POSTMAN传参报错问题直接消失。这个方案的要点是:修改之前先跟接口的调用方确认,是不是所有调用场景都是单条,避免为了调试方便把批量能力砍掉了。如果确实存在少量批量诉求,还是保留List形态,走方案一。
3.4 方案三:用包装对象接收复杂批量结构
还有一种情况,接口不仅要接收列表,还要携带一些辅助信息,比如操作人、来源渠道、批量批次号。如果你继续坚持List<UserDTO>作为整个请求体,那这些额外信息就没地方放。这时候比较干净的做法是定义一个包装对象:
public class BatchCreateRequest { private String operator; private String source; private List<UserDTO> users; // 省略getter/setter }Controller改成:
@PostMapping("/batchCreate") public Result batchCreate(@RequestBody BatchCreateRequest request) { // ... }Postman请求体写成:
{ "operator": "admin", "source": "postman", "users": [ { "name": "张三", "age": 25 } ] }这里要注意,请求体最高层是对象,这是正确的,因为目标类型是BatchCreateRequest,不是List。列表中嵌套的users字段是JSON数组,和后端List<UserDTO>属性对应,也是正确的。整个请求体呈现出的是一种"对象里面套数组"的结构,并不违反任何约定。
如果你要在代码里手动把JSON字符串转成List<UserDTO>,而不是依赖@RequestBody,那建议使用TypeReference显式指定类型:
List<UserDTO> users = objectMapper.readValue(json, new TypeReference<List<UserDTO>>() {});这样Jackson就能拿到准确的泛型信息,避免运行时泛型擦除带来的问题。这是一个很容易踩但极少人提及的细节。
4. 同类报错的排查套路与避坑清单
4.1 按顺序排查这三件事,九成问题能定位
以后再遇到类似的JSON解析报错,我建议你按下面这个顺序排查,效率最高。
第一件事,检查请求头里的Content-Type。Postman中如果你选择了raw但没有把下拉框从Text切到JSON,它发出的Content-Type可能是text/plain,后端Spring接不到JSON语义,解析结果可能完全走样。正确做法是确保请求头里有:
Content-Type: application/json第二件事,检查请求体最外层括号。在Postman的Body编辑区里,把光标移到请求体的第一个字符,看看是{还是[。然后到后端接口定义里看参数类型是对象还是List。两边做一个"外层对外层"的匹配:{对应对象,[对应List。这样一分钟就能定位到80%的问题。
第三件事,确认字段名跟后端DTO对得上。虽然这个报错本身不是字段名不一致导致的,但字段名不一致会引发另一个常见错误,类似Unrecognized field或者Unknown property。所以当请求体结构已经修正、依然报错时,就要检查命名策略问题,比如前端用下划线user_name,后端用驼峰userName,前后要对齐。
4.2 日志、断点与curl三件套
有时候光在Postman里看请求体是不够的,因为请求发出去了,中间还有各种代理、网关、拦截器可能改动请求体。这时候我习惯用三件套来定位。
第一件:Postman Console。打开Postman的底部门户,View菜单里找到Show Postman Console,发送请求后会显示出实际发送的Headers、Body原始内容。这里能看到Content-Type到底是不是application/json,body到底是不是你编辑区的原文,能排除掉编辑器或变量引起的干扰。
第二件:后端日志。在Controller方法入口打日志,打印接收到的原始字符串或者反序列化后的对象。如果日志打出来了,说明请求已经进入方法体;如果打进不了,那问题可能出在拦截器、过滤器或者网关。很多人喜欢直接断点调试,也可以,但在复杂链路里日志更省心。
第三件:curl命令行复现。脱离Postman用curl发一次请求,可以验证是不是Postman本身的问题。比如:
curl -X POST http://localhost:8080/api/users/batch \ -H "Content-Type: application/json" \ -d '[{"name":"张三","age":25}]'如果curl成功而Postman失败,那基本可以锁定是Postman配置的问题。如果curl也报同样的错,那就返回去检查接口定义和请求体结构。
4.3 常见相似报错对照表
我在表里整理了几条经常一起出现的相似报错,方便你日后快速对照。这些报错格式都非常像,但关键信息不同,解决方向也完全不同。
| 报错信息片段 | 含义 | 解决方向 |
|---|---|---|
Cannot deserialize instance of java.util.ArrayList out of START_OBJECT | 目标是List,传入的却是对象 | 给JSON加外层方括号,或改为数组结构 |
Cannot deserialize instance of java.lang.String out of START_OBJECT | 目标是String,传入的却是对象 | 检查是不是参数类型写错,或请求体多包了一层对象 |
Cannot deserialize instance of com.example.UserDTO out of START_ARRAY | 目标是对象,传入的却是数组 | 去掉外层方括号,或检查调用方是否多包了数组 |
Unrecognized field "xxx" | JSON里多了个后端不认识的字段 | 检查字段名拼写、命名策略,或加@JsonIgnoreProperties |
Cannot construct instance of java.util.ArrayList | 无法构造ArrayList实例,通常涉及不可变集合或实现类问题 | 检查List实现类类型、是否有无参构造 |
这些报错信息看着雷同,实际上每个都对应着完全不同的根因。我把这份对照表贴在工位旁边之后,排查序列化问题的速度明显快了。
5. 踩坑之后的几点习惯复盘
5.1 接口参数形态从定义时就定明白
踩过一次这个坑之后,我再看别人设计的接口,会特别关注参数类型和请求体结构之间的关系。接口文档里如果只写"参数是一个List",等于什么都没说,因为List里面装的是什么、请求体最外层是什么,这些关键信息没写明,联调时一定有人会传错。
我现在会要求在接口定义阶段就明确:是单对象还是对象数组,是简单类型列表还是复杂对象列表,有没有包装对象。这些决定直接体现在方法签名上,签名对了,Postman侧的示例就跟着对了。后端写List<UserDTO>,文档里就贴一段数组示例;后端写BatchCreateRequest,文档里就贴一段带嵌套数组的完整示例。不要嫌麻烦,这些示例在未来会节省大量沟通成本。
5.2 让Postman集合变成可复用的接口资产
我建议团队把常用的批量接口、列表接口、单对象创建接口分别存成Postman集合模板,每个请求都预填好正确的Headers和Body示例。这样新人接到任务时不用凭感觉构造JSON,直接复制模板里的示例,改字段值就能发请求。
还有一个很实用的小技巧:在Postman的请求Pre-request Script里,可以不写复杂逻辑,但至少要保证变量替换后的body不破坏最外层括号结构。我见过有人用环境变量拼body时,变量值里意外带了花括号,结果拼接出来的JSON变成了两个对象并列,触发了完全一样的反序列化错误。这个问题排查起来非常痛苦,但如果你把请求体模板固定好,只用变量去替换字段值,就能从源头上避开。
5.3 序列化相关的小灶建议
最后分享三个我自己用过很多次的小技巧。
第一个,如果你在本地调试时反复改后端代码,建议给Jackson开启详细的日志级别。在application.yml里把日志开大:
logging: level: org.springframework.web: DEBUG com.fasterxml.jackson: DEBUG日志会输出Jackson解析过程中遇到的令牌信息,很多时候一眼就能看出它到底卡在哪个令牌上。
第二个,如果你的项目经常需要接收多层嵌套泛型,比如List<Map<String, List<UserDTO>>>,可以写一个单独的Jackson工具类,统一使用TypeReference去处理,避免到处出现裸泛型。比如:
public static final TypeReference<List<UserDTO>> USER_LIST_TYPE = new TypeReference<List<UserDTO>>() {};用的时候直接传这个常量,简洁又不容易出错。
第三个,前端或脚本调用接口时,建议先做一次JSON格式校验。很多在线JSON解析工具就能检查出括号是否配对、逗号是否缺失。一个语法完整的JSON是后续反序列化的前提,如果语法本身就有问题,Jackson抛出来的报错会更复杂,排查难度大增。
我在实际项目中,把这三条建议落地后,团队里因为这个报错来问我的次数基本归零。不是因为我们不再写错,而是大家已经形成了固定的排查路径:先看Content-Type,再看最外层括号,然后对照接口签名。这套方法看起来笨,但非常有效。
最后再分享一个我个人的体会:序列化报错最怕的不是不会修,而是"看着报错信息就开始猜"——改一改这里,动一动那里,最后没解决还把代码改乱了。冷静下来,把报错信息里的目标类型和令牌类型这两段关键词吃透,绝大多数问题都能在几分钟内定位并修复。遇到Cannot deserialize开头的报错时,先别怀疑框架或者工具,先怀疑数据结构,然后在两个花括号和方括号之间找出真相。