做后端开发这些年,Java处理JSON大概是我写过最多、也最容易被反复追问的话题。它不是简单地把字符串换成对象,也不是把对象序列化成字符串,真正用起来之后你会发现一堆小坑:字段对不上、日期格式千奇百怪、泛型信息被擦除、几MB的JSON一次加载把内存打满……这个题目看似基础,但每一条都能让你在生产环境翻车。下面这些是我在写代码和排查问题时慢慢沉淀下来的Java处理JSON编程实用技巧,从选型到字段映射,从泛型多态到大JSON性能,最后再聊聊安全底线,希望能帮你少走点弯路。
1. 动手之前先想清楚:JSON库选型与最容易被忽略的基础坑
很多人写Java处理JSON,上来就搬一个库开干,等出了诡异问题才开始怀疑是不是用错了。我一直的主张是:在写第一行序列化代码之前,先回答两个问题——项目里用哪个库?全局策略是什么?
1.1 先统一JSON库,别让项目里同时出现三套API
常见的Java JSON库无非Jackson、Gson、Fastjson,各有一批忠实用户。如果你用的是Spring Boot,默认自带Jackson,那就老老实实用它,不要为了某个小特性再引一个Gson进来。我见过很多项目,一个模块用Jackson,另一个模块用Gson,两个库的注解完全不兼容,字段名一个忽略未知字段一个直接报错,最后出问题根本不知道是谁干的。
而且这几个库的默认行为差异很大,换库不等于只改import:
| 行为 | Jackson | Gson | Fastjson |
|---|---|---|---|
| 未知字段 | 默认报错 | 默认忽略 | 默认忽略 |
| null字段序列化 | 默认输出 | 默认不输出 | 默认输出 |
| JDK 8日期时间 | 需要模块支持 | 需要适配器 | 部分支持 |
举个例子:同样的对象,Jackson序列化出来可能是{"name":"a","age":null},Gson可能直接变成{"name":"a"}。前端如果写死判断age字段是否存在,两个库之间一切换,行为就变了。所以第一步不是学某个库的API,而是和团队约定好:统一一个库,统一一套全局配置,不要混。
1.2 无参构造器、getter/setter和不可变对象
反序列化的底层逻辑,说穿了就是“根据JSON的键找到Java对象的字段,然后赋值”。Jackson优先走无参构造器加setter,Gson一般也接受无参构造器。如果一个类没有无参构造器,也没做任何配置,反序列化通常直接报错。
解决办法不是给每个类都补一个无参构造器,而是用@JsonCreator定义构造器。这种写法对不可变对象非常友好,字段都用final修饰,不用再写一堆setter:
public class User { private final String name; private final int age; @JsonCreator public User(@JsonProperty("name") String name, @JsonProperty("age") int age) { this.name = name; this.age = age; } public String getName() { return name; } public int getAge() { return age; } }这里有个细节:@JsonProperty必须放在构造器参数上,否则Jackson只能靠参数名去猜,编译时没开-parameters的话参数名会变成arg0、arg1,字段映射就乱了。我当初第一次这么写的时候没注意,结果反序列化出来的全是null,排查了半天才发现是参数名反射问题。
如果你的类属性不多,又想保持不可变,这个方案是最好的。但要注意:既然类内部字段是最终的,如果JSON里冒出来一个多余字段,Jackson默认会抛异常,最好在类上加上@JsonIgnoreProperties(ignoreUnknown = true),或者全局忽略未知字段,这点下一节详细说。
2. 字段映射:注解、命名策略与多余/缺失字段处理
JSON处理中最常见的业务痛点就是字段匹配不上。数据库表字段是user_id,Java对象字段是userId,前端传给你的是userID,外部接口返回的是userIdStr……字段映射这件事看似简单,实际门道不少。
2.1 JSON字段名和Java字段名不一致时的三种解法
第一种是在字段上用@JsonProperty:
public class Product { @JsonProperty("product_id") private String productId; }这种方案适合“个别字段特殊”的情况。哪个字段不一致,就在哪个字段上面加注解,直观、可读性好。缺点是如果几十个字段都要加注解,代码会很啰嗦。
第二种是配置全局命名策略,让Jackson自动把Java的驼峰格式转成下划线格式:
ObjectMapper mapper = new ObjectMapper(); mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);这样productId字段在序列化时自动变成product_id,反序列化时product_id也能自动映射到productId。如果你的接口风格统一,这是最省事的做法。需要注意的是,旧版本里这个策略叫PropertyNamingStrategy.SNAKE_CASE,升级到新版本之后被移到了PropertyNamingStrategies下,老API虽然还能用但已标记过期,不要被迷惑。
第三种是改Java字段名本身,比如把字段直接命名成product_id,配上JSON字段名一致。这不推荐,因为Java命名规范不允许下划线连单词,而且代码里到处都是product_id,可读性会很差。
还需要提一种混合场景:同一个类里的JSON字段既有下划线又有驼峰。全局配置SNAKE_CASE会直接把驼峰字段也转成下划线,导致原来对不上。我最早在一个对接外部支付系统的接口里踩过这个坑,后来恢复成全局不配置,只在个别字段上加@JsonProperty,反而清晰得多。
2.2 未知字段要不要忽略,缺失字段怎么办
Jackson默认遇到未知字段会抛UnrecognizedPropertyException,Gson则默认忽略。两条路各有理由,但线上系统我建议统一设置为忽略未知字段:
mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);原因很简单:接口升级之后,服务端多返回一个字段,老客户端如果因此直接解析失败,属于典型的无效故障。特别是很多第三方API的返回结构我们没法完全控制,宽容一点可以避免不必要的崩溃。
但忽略未知字段也有隐患——如果你代码里把orderId拼成了orederId,字段对不上,Jackson不会报错,只会静默地让这个字段变成null,你很难发现。所以我的习惯是:开发环境保持FAIL_ON_UNKNOWN_PROPERTIES开启,生产环境关闭,用不同的配置profile控制。
关于缺失字段,要区分两种情况。如果JSON里的字段压根不存在,反序列化时通常不会调用setter,对象的属性保持构造器里设置的初始值,比如null、0或者你给的自定义默认值。如果JSON里的字段存在但值是null,setter会被调用,传进来的就是null。换句话说,配置一个默认值并不能保证“JSON里没有这个键”时也能用上,想要区分这两种情况,可以使用@JsonSetter(nulls = Nulls.SKIP):
public class Order { private String remark = "NO_REMARK"; @JsonSetter(nulls = Nulls.SKIP) public void setRemark(String remark) { this.remark = remark; } }这样只有当JSON里remark字段存在且值非null时,才会覆盖默认值。避免空字符串覆盖默认值则复杂一些,可能需要自定义反序列化器,这里不展开。
2.3 空字符串统一转null的土办法
很多老系统对接时喜欢把空字符串当作null传给后端,但Java字段类型是Integer、Long、Boolean时,空字符串会导致转换失败。与其在业务代码里到处做空值判断,不如给Jackson写一个简单的反序列化器。
public class LenientStringDeserializer extends JsonDeserializer<String> { @Override public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { String value = p.getValueAsString(); if (value != null && value.trim().isEmpty()) { return null; } return value; } }然后注册到对应字段:
@JsonDeserialize(using = LenientStringDeserializer.class) private String email;这只是最基础的版本,实战中你可能会遇到“空字符串转null”“null转空字符串”“时间字段传0”等各种脏数据,全都可以用自定义反序列化器兜底。但要记住一点:自定义反序列化器只在明确知道数据来源不可控时使用,不要为了图省事把所有字段都套上,否则掩盖了真实数据问题,排查起来更费劲。
3. 泛型、嵌套与多态:处理“类型”时需要比想象中多走一步
Java泛型在运行时会被擦除,这是JSON处理里最容易掉坑的地方。很多看起来“理所当然”的代码,实际跑起来全在强转时报ClassCastException。
3.1 为什么List .class不能直接用
我见过太多次这样的写法:
List<User> users = mapper.readValue(json, List.class);这段代码编译没问题,运行时也不会立刻报错,但users里的元素其实不是User,而是LinkedHashMap。等你拿到users.get(0)想要调user.getName()时,才知道自己错得离谱。因为List.class丢失了泛型参数User,Jackson只知道要构建一个List,不知道里面装什么,只能按Object处理成Map。
正确写法是用TypeReference:
List<User> users = mapper.readValue(json, new TypeReference<List<User>>() {});new TypeReference<List<User>>() {}这种匿名类的写法,目的是让Jackson在编译期捕获List<User>的真实泛型信息。这个对象可以定义成静态常量,不用每次解析都new。
3.2 嵌套泛型和动态类型
当泛型嵌套多层,比如Map<String, List<Order>>,思路还是一样的:
Map<String, List<Order>> map = mapper.readValue(json, new TypeReference<Map<String, List<Order>>>() {});如果TypeReference写起来太别扭,也可以用TypeFactory动态构造类型:
JavaType listType = mapper.getTypeFactory().constructCollectionType(List.class, Order.class); JavaType mapType = mapper.getTypeFactory().constructMapType(HashMap.class, String.class, listType); Map<String, List<Order>> map = mapper.readValue(json, mapType);TypeFactory适合类型信息不是写死、需要根据运行时变量构造的场景。比如说你的程序要解析一个“字段名不定,但value都是订单列表”的JSON,TypeReference就没法预先写死,TypeFactory就派上了用场。
但更稳妥的建议是:能用DTO就用DTO。像Map<String, List<Order>>这种结构,一旦key的含义变得复杂,Map里的类型信息还是不够安全,远不如直接定义一个OrderGroup类,把字段映射关系固化下来。
3.3 父类引用和多态:反序列化后为什么全是LinkedHashMap
还有一种情况,你定义了:
Animal animal = mapper.readValue(json, Animal.class);Animal是个抽象类,然后你期望反序列化后能根据JSON内容得到Dog或者Cat,结果运行时animal的实际类型是LinkedHashMap。原因很简单:Jackson不知道Animal该实例化成哪个子类,于是给了你一个Map,希望你“自己看着办”。
解决方法是使用Jackson的多态注解:
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ @JsonSubTypes.Type(value = Dog.class, name = "dog"), @JsonSubTypes.Type(value = Cat.class, name = "cat") }) public abstract class Animal { private String name; // getter setter }这样JSON里只要带"type":"dog",Jackson就会把对象反序列化成Dog;带"type":"cat",就反序列化成Cat。注意type字段是JSON输入的一部分,如果外部数据不可信,这种多态机制本身有安全风险,下一章会专门聊。
另外,如果你的JSON里根本没有类型标记字段,又必须反序列化成具体子类,那只能靠自定义Deserializer或者自己先读JsonNode判断一下关键字段,再手动转换成对应类型。任何框架都做不到“无中生有”地猜出你想要哪个子类。
4. 日期、枚举和精度:需要单独校准的字段类型
字符串和数字映射起来相对机械,但日期、枚举、小数这三个类型几乎每一个人都踩过坑。它们看起来简单,实际上涉及格式、时区、精度这些容易忽略的细节。
4.1 日期时间格式要全局统一
Jackson默认会把java.util.Date序列化为时间戳数字,比如1710000000000,而不是你常用的2024-03-10 12:00:00。如果你直接把它返回给前端,前端会一脸懵。解决办法是全局配置:
ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); mapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));JavaTimeModule是为了支持LocalDate、LocalDateTime这些JDK 8时间类型。如果你不注册这个模块,直接把LocalDateTime字段序列化,通常会报错。
这里有几个容易出问题的点:
SimpleDateFormat不是线程安全的,不要把它定义成static字段给所有请求共用。实际上Jackson内部会有自己的格式化处理,不建议手动塞一个SimpleDateFormat全局用,更好的做法是用format配置或自定义序列化器。- 时区问题比格式问题更隐蔽。同样一个
2024-03-10 12:00:00,服务端在北京机器解析出来和在新加坡机器解析出来的时间戳可能不一样。最省心的办法是服务启动时统一TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai")),或者明确给mapper.setTimeZone(...)。 - 如果你用Gson,默认日期格式同样不好看,可以用
GsonBuilder().setDateFormat("yyyy-MM-dd HH:mm:ss").create()。
4.2 枚举到底该输出name、code还是ordinal
枚举的反序列化规则在不同库里差别很大。默认情况下,Jackson把枚举序列化成name()字符串,比如:
public enum Status { CREATED, PAID, CANCELLED }序列化结果是"CREATED"。如果有一天你改了枚举值名称,或者前端传的是中文、数字,默认行为就撑不住了。
比较规范的做法是给枚举加一个业务编码,并且用@JsonValue标记序列化时输出的值:
public enum Status { CREATED(1), PAID(2), CANCELLED(3); private final int code; Status(int code) { this.code = code; } @JsonValue public int getCode() { return code; } }这样序列化出来的就是数字1、2、3,而不是字符串CREATED。反序列化时,如果输入是数字1,Jackson会根据@JsonValue反查吗?不一定。保险的做法是再加上@JsonCreator:
@JsonCreator public static Status fromCode(int code) { for (Status status : values()) { if (status.code == code) { return status; } } throw new IllegalArgumentException("unknown status code: " + code); }这里还有一条铁律:永远不要用枚举的ordinal()当业务编码。ordinal()是枚举定义的位置序号,一旦你在中间插入一个新枚举值,所有后续枚举的顺序全部变化,已经存进数据库的数据会全部错乱。我在代码审查里看到用ordinal()的地方,基本都会让改成显式code。
4.3 小数的精度:Double、Float和BigDecimal
金额、汇率这类数据,绝对不要用double或float。JSON里写0.1,Java里用double表示其实是0.1000000000000000055511151231257827,运算之后会出现意想不到的误差。正确的做法是字段类型用BigDecimal:
public class Transaction { private BigDecimal amount; }Jackson对BigDecimal的反序列化是直接支持的,不需要额外配置。但要注意另一个场景:如果你把一个JSON反序列化成Map,里面的数字0.1很可能会变成Double,然后又丢精度。这也是我建议固定结构用DTO而不是Map的原因之一。
如果你确定接口里返回的浮点数统一需要高精度,也可以全局开启:
mapper.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS);但这会让所有小数都变成BigDecimal,如果你有很多字段其实用double就行,反而增加性能开销,所以这个开关要谨慎用。
5. 大JSON和性能优化:流式解析、复用和减少反射
当JSON小到几十KB时,怎么解析都无所谓;但到了几十MB、几百MB,问题就来了。大JSON处理不是序列化框架的问题,而是你的使用方式出了问题。
5.1 几百MB的JSON不能直接readValue
假设你有一个日志文件,每行是一个JSON对象,整文件有500MB。如果直接mapper.readValue(file, List.class),一次性加载所有对象进内存,大概率直接GC掉或者内存溢出。这时候可以用流式解析配合逐条读取:
JsonFactory factory = mapper.getFactory(); try (JsonParser parser = factory.createParser(new File("large.json"))) { while (!parser.isClosed()) { JsonToken token = parser.nextToken(); if (token == JsonToken.START_OBJECT) { MyRecord record = mapper.readValue(parser, MyRecord.class); // 处理这条记录 } } }这里的关键是mapper.readValue(parser, MyRecord.class)会从当前START_OBJECT开始读取一个完整的JSON对象,读完后parser自动移动到下一个token。这样内存里始终只有一条记录,不会把整个文件加载进来。
如果是JSON数组而不是逐行对象,写法类似:
JsonToken arrayStart = parser.nextToken(); // START_ARRAY while (parser.nextToken() != JsonToken.END_ARRAY) { MyRecord record = mapper.readValue(parser, MyRecord.class); // 处理 }这个思路跟XML的StAX很像,适合“一条一条处理”的场景。如果你需要聚合统计大量记录,先流式读取生成中间结果,再在Java内存里合并,比一次性加载整个List要优雅得多。
5.2 ObjectMapper要复用,配置要集中
ObjectMapper线程安全吗?配置完成后安全。但创建ObjectMapper本身成本不低,涉及大量反射元数据缓存。很多项目里在工具类中每次方法调用都new ObjectMapper(),这是典型的性能浪费。
正确做法是把ObjectMapper做成全局单例,应用启动时构造一次,配置集中在同一个地方:
@Configuration public class JacksonConfig { @Bean public ObjectMapper objectMapper() { ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); return mapper; } }Spring Boot环境下,更推荐用Jackson2ObjectMapperBuilderCustomizer定制ObjectMapper,这样不会破坏框架内部已经做好的自动配置,也方便在不同环境覆盖某几项配置。
还有一个提升性能的细节:尽量用字节数组而不是字符串。同一个JSON内容,mapper.readValue(byteArray, MyClass.class)比mapper.readValue(string, MyClass.class)快,因为省掉了字符串自身的字符集转换。如果你从网络流里拿数据,直接传InputStream给Jackson,也比先转成字符串再解析更高效。
另外,自动探测字段的时候,如果你定义POJO时属性已经很明确,可以关闭一些不必要的auto-detect:
mapper.disable(MapperFeature.AUTO_DETECT_FIELDS); mapper.disable(MapperFeature.AUTO_DETECT_GETTERS);这能减少反射扫描的范围,提高序列化性能。但前提是你的类上都写清楚了注解和getter,不然后果自负。我一般默认不关,只有在压测发现反射耗时占比明显时才动手。
5.3 树模型vs DTO:根据变动频率决定
当JSON结构不固定、字段可能随时增减时,硬编码DTO会很痛苦。这时候可以用树模型:
JsonNode root = mapper.readTree(json); JsonNode data = root.get("data"); String name = data.get("name").asText();树模型的好处是灵活,拿到一个节点随便改,还能写回去:
ObjectNode objectNode = (ObjectNode) root; objectNode.put("extra", "value"); String newJson = mapper.writeValueAsString(objectNode);缺点是类型不安全,所有数字、布尔都要自己手动转换,字段存在性也要自己判断。我的建议是:结构频繁变化、或者内部字段不重要的场景用树模型;核心业务对象、字段固定、需要IDE重构的,一律用DTO。两者不是替代关系,是不同场景的工具。
6. 安全与健壮性:解析外部JSON时的底线
JSON解析不只是“把字符串变成对象”,很多时候你解析的是外部传进来的数据。来自公网接口、用户上传、消息队列的JSON,都可能带着恶意内容。这里有几条安全底线,我建议每个Java开发者都在脑子里面记着。
6.1 反序列化漏洞:自动类型绑定是个高风险开关
JSON反序列化漏洞本质上是“多态类型处理”被滥用。攻击者在JSON里指定一个危险类的全限定名,解析器就尝试实例化这个类,配合某些类的readObject或setter方法,可能造成命令执行。历史上Jackson就出过不少这类CVE。
所以第一原则是:除非你完全控制了JSON数据源,否则不要随便开启mapper.enableDefaultTyping(),也不要依赖JSON里的类型字段来动态加载任意类。
如果你的业务确实需要多态,尽量用白名单方式限定子类:
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ @JsonSubTypes.Type(value = Dog.class, name = "dog"), @JsonSubTypes.Type(value = Cat.class, name = "cat") })只允许Dog和Cat两个子类,比允许所有类安全得多。新版本Jackson还提供了StreamReadConstraints等限制,可以配置最大字符串长度、最大嵌套深度,这些都能有效缓解恶意输入带来的压力。
6.2 深度嵌套与拒绝服务
一个JSON字符串可以写几十万层嵌套括号:
{"a":{"a":{"a":...}}}解析器如果是递归下降的,就会不断压栈,最终抛出StackOverflowError,严重时把整个进程带崩。这种攻击消耗的资源很少,但影响很大。
从Jackson 2.15开始,解析器默认限制了最大嵌套深度为1000,如果超过会直接报错。旧版本可以手动配置:
StreamReadConstraints constraints = StreamReadConstraints.builder() .maxNestingDepth(1000) .maxStringLength(5000000) .build(); JsonFactory factory = JsonFactory.builder() .streamReadConstraints(constraints) .build(); ObjectMapper mapper = new ObjectMapper(factory);除了解析器自身限制,还要做好传输层限制。比如上传JSON文件的大小限制、HTTP请求体的最大字节数,避免有人一次性塞给你一个2GB的JSON体。
6.3 外部JSON的键名和日志注入
有些JSON会故意使用__proto__、constructor这种键名。在JavaScript里可能触发原型链污染,在Java里虽然没有原生的原型链问题,但如果你把解析出来的Map直接当成内部白名单外的数据去操作,仍可能出问题。最稳妥的方式是不接收泛型Map,定义明确的DTO,多余字段直接忽略。
还有一个经常被忽视的点是日志注入。直接把外部JSON字符串拼进日志:
log.info("request body: {}", jsonString);如果JSON里有换行和伪造日志级别,比如{"msg":"\nINFO: balance=0"},日志系统可能会被污染,干扰排查甚至掩盖攻击痕迹。对不可信JSON打日志时,先把换行符、控制字符转义掉,或者截断长度。
6.4 null值策略和容错配置
最后说一个和健壮性相关的配置。生产环境序列化响应时,我一般建议统一NON_NULL,也就是不输出null字段:
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);好处是响应体变小、整洁,也避免前端拿到一堆毫无意义的"field": null。坏处是前端代码如果依赖某个字段是否存在来判断状态,不输出null可能引发前端NPE。所以这个策略要和前端约定好:不输出null,就代表字段没有值。
反序列化端同样要约定:外部传null、传缺失、传空字符串,分别表示什么含义。如果团队没有约定,最容易出现的情况是同一个接口有人传null、有人传空串、有人干脆不传,后端代码为了兼容各种情况写出一堆if (obj == null || obj.isEmpty())。把这些规则统一放到Jackson配置层去处理,业务代码会清爽很多。
我在实际中还有一个习惯:凡是解析外部JSON,第一步先打印原始JSON结构,第二步再定义DTO,很多字段映射的坑都是这一步发现的。等到DTO稳定了,再做全局配置,一个项目下来基本不会被JSON相关的问题卡住。希望这些技巧也能让你在处理Java JSON时少踩几个坑。