如果你在 Java 项目中用过 Jackson,你几乎一定写过这样的代码:
1 | ObjectMapper mapper = new ObjectMapper(); |
然后某天 Code Review 的时候,同事指出了一个问题:ObjectMapper 是线程安全的,但 SimpleDateFormat 是线程不安全的。你把一个线程不安全的对象塞进了线程安全的对象里,那这个 ObjectMapper 到底还能不能在多线程下用?
这个问题看起来很简单,但如果你去搜答案,你会发现——三个人有三个说法。
三方混战
Baeldung 说:不安全。
Baeldung 的一篇热门文章 Should Jackson’s ObjectMapper be a static field? 明确指出,注入像 SimpleDateFormat 这样的 mutable collaborator 会”re-introduce unsafe shared state”,破坏 ObjectMapper 的线程安全保证。文章甚至给出了一个并发错误的演示:如果你在 setDateFormat 之后还去 mutate 这个 SimpleDateFormat(比如调用 applyPattern()),ObjectMapper 的输出就会乱掉。
Spring 说:不安全。
Spring 的 Jackson2ObjectMapperFactoryBean 文档中直接标注:non-thread-safe, according to Jackson’s thread safety rules。Spring 官方组件都说不安全,这还能有假?
StackOverflow 说:安全。
StackOverflow 上那个著名的 3907929 号问题,被浏览了超过 50 万次。高赞回答明确指出:ObjectMapper 内部会进行克隆(clone),所以即使传入了 SimpleDateFormat,仍然是线程安全的。
三方意见,两种结论。你该信谁?
我的答案是:StackOverflow 说对了,但说对了一个不完整的结论;Baeldung 和 Spring 说错了,但错在一个有价值的警示上。 真相不在任何一方的结论里,而在源码里。
从源码找真相
讨论线程安全不线程安全,不看源码就是猜。我们一个一个看。
第一层:ObjectMapper.setDateFormat() 做了什么?
1 | // ObjectMapper.java |
它把 DateFormat 原封不动地存进了两个 Config 对象。没有克隆,没有拷贝,就是你传什么它存什么。
继续追,SerializationConfig.with() 最终调到 BaseSettings.withDateFormat():
1 | // BaseSettings.java |
大多数情况下,hasExplicitTimeZone() 是 false,所以 DateFormat 引用被原样保存。
到这里,Baeldung 和 Spring 似乎是对的——ObjectMapper 内部确实持有你传入的 SimpleDateFormat 的同一个引用,没有任何保护。如果你在另一个线程里修改了这个 SimpleDateFormat,那确实会出问题。
然而,这只是故事的一半。
第二层:序列化的时候发生了什么?
当你调用 mapper.writeValueAsString(someObject) 的时候,Jackson 并不是直接拿着 Config 里存的那个 DateFormat 来用。它走了一条精心设计的路径。
先看 SerializerProvider——这是每次序列化操作都会涉及的核心类:
1 | // SerializerProvider.java |
SerializerProvider 在第一次使用 DateFormat 的时候,就克隆了一份。 之后的调用都使用这个克隆版本,跟 Config 里存的那个原始引用再无关系。
但这是 default 序列化路径。当你的对象里有 Date 类型的字段时,Jackson 会走到 DateTimeSerializerBase,这里有更精细的处理:
1 | // DateTimeSerializerBase.java |
这段代码非常精妙,值得逐行解读:
_reusedCustomFormat是一个AtomicReference<DateFormat>,用于在多线程间复用一个已克隆的 DateFormat 实例getAndSet(null)尝试取出缓存的克隆实例,取出后把引用置空- 如果取到了(另一个线程没在用),直接用,不用重新克隆
- 如果没取到(另一个线程正在用),就 clone 一份新的
- 用完后通过
compareAndSet(null, f)尝试放回去给下一个线程用
这是一个线程安全的对象池模式——用 AtomicReference 实现的轻量级池,既保证了线程安全,又避免了每次序列化都克隆的开销。
甚至在 createContextual 创建 per-property 的 Serializer 时,Jackson 也做了克隆:
1 | // DateTimeSerializerBase.createContextual() |
克隆全景图
把所有克隆点汇总一下:
| 位置 | 何时克隆 | 机制 |
|---|---|---|
SerializerProvider._dateFormat() |
每个 Provider 实例首次使用时,克隆一次 | 惰性克隆 |
DateTimeSerializerBase._serializeAsString() |
缓存实例被其他线程占用时 | AtomicReference 对象池 + clone |
DateTimeSerializerBase.createContextual() |
创建 per-property Serializer 时 | clone 或 new |
三条路径,三重保护。Jackson 在每一个可能使用你传入的 DateFormat 的地方,都做了克隆,确保多线程下不会共享同一个 SimpleDateFormat 实例。
所以 Baeldung 和 Spring 错了吗?
不完全错,但结论下得太早了。
Baeldung 文章中演示的那个并发错误,场景是这样的:
1 | SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); |
这确实会导致问题——因为 ObjectMapper.setDateFormat() 存储的是引用,如果你在 set 之后还去修改原始的 SimpleDateFormat,Config 里的引用也跟着变了。但这不是 ObjectMapper 的线程安全问题,这是你自己的使用问题。
打个比方:你把自家的钥匙交给了一个靠谱的保险箱(ObjectMapper),保险箱保证你的东西放进去之后不会被别人动。但你自己手里还有一把同样的钥匙(原始引用),你自己跑去保险箱里的东西上乱涂乱画,然后怪保险箱不安全——这不合理吧?
Jackson 的契约很明确:配置完成后,不要再修改传入的对象。 这不是 Jackson 的问题,这是所有接受可变对象的 API 的通用规则。你在 Collections.unmodifiableList() 之后再修改原始 List,一样会出问题,但没人说 unmodifiableList 不安全。
Spring 的 Jackson2ObjectMapperFactoryBean 那个 “non-thread-safe” 标注,说实话,有点偷懒。它把”如果你不正确使用就不安全”偷换成了”它本身不安全”,这两个命题差别大了去了。
再多唠叨一些
这个问题让我想到一个更深层的东西:我们在讨论线程安全的时候,到底在讨论什么?
很多人把”线程安全”理解成一个非黑即白的布尔值——要么安全,要么不安全。但现实远比这复杂。线程安全是有条件的、有边界的、有契约的。
StringBuffer 是线程安全的,但如果你写这样的代码:
1 | StringBuffer sb = new StringBuffer(); |
每次 append 是原子的,但你得到的字符串可能是 “helloworld” 也可能是 “worldhello”——这不是你想要的。方法级别的线程安全,不等于业务级别的线程安全。
反过来,SimpleDateFormat 是线程不安全的,但如果你每次都 new 一个新的,或者像 Jackson 这样在内部做克隆,它就是安全的。类的线程不安全,不等于它在所有场景下都不安全。
这跟缓存更新的套路是一个道理——很多人只知道”先删缓存再更新数据库”这个口诀,但不去想为什么,也不去想在什么并发条件下这个口诀会失效。基础这玩意全都是相通的。 线程安全的本质不是记住哪些类安全哪些不安全,而是理解共享可变状态在哪里、谁在访问、怎么隔离。
所以下次再有人跟你说”ObjectMapper 配了 SimpleDateFormat 就不安全了”,你可以很自信地告诉他:去读源码。Jackson 在每一条序列化路径上都做了克隆,它是安全的——前提是你别在 set 完之后还去动那个 DateFormat。
这不是妥协,这是契约。