0%

ObjectMapper + SimpleDateFormat:线程安全还是不安全?

如果你在 Java 项目中用过 Jackson,你几乎一定写过这样的代码:

1
2
3
ObjectMapper mapper = new ObjectMapper();
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
mapper.setDateFormat(sdf);

然后某天 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
2
3
4
5
6
// ObjectMapper.java
public ObjectMapper setDateFormat(DateFormat dateFormat) {
_deserializationConfig = _deserializationConfig.with(dateFormat);
_serializationConfig = _serializationConfig.with(dateFormat);
return this;
}

它把 DateFormat 原封不动地存进了两个 Config 对象。没有克隆,没有拷贝,就是你传什么它存什么。

继续追,SerializationConfig.with() 最终调到 BaseSettings.withDateFormat()

1
2
3
4
5
6
7
8
9
10
11
// BaseSettings.java
public BaseSettings withDateFormat(DateFormat df) {
if (_dateFormat == df) {
return this;
}
// 只有设置了显式 TimeZone 时才克隆
if ((df != null) && hasExplicitTimeZone()) {
df = _force(df, _timeZone);
}
return new BaseSettings(..., df, ...);
}

大多数情况下,hasExplicitTimeZone() 是 false,所以 DateFormat 引用被原样保存

到这里,Baeldung 和 Spring 似乎是对的——ObjectMapper 内部确实持有你传入的 SimpleDateFormat 的同一个引用,没有任何保护。如果你在另一个线程里修改了这个 SimpleDateFormat,那确实会出问题。

然而,这只是故事的一半。

第二层:序列化的时候发生了什么?

当你调用 mapper.writeValueAsString(someObject) 的时候,Jackson 并不是直接拿着 Config 里存的那个 DateFormat 来用。它走了一条精心设计的路径。

先看 SerializerProvider——这是每次序列化操作都会涉及的核心类:

1
2
3
4
5
6
7
8
9
10
11
// SerializerProvider.java
protected DateFormat _dateFormat;

protected final DateFormat _dateFormat() {
if (_dateFormat != null) {
return _dateFormat;
}
DateFormat df = _config.getDateFormat();
_dateFormat = df = (DateFormat) df.clone(); // ← 克隆!
return df;
}

SerializerProvider 在第一次使用 DateFormat 的时候,就克隆了一份。 之后的调用都使用这个克隆版本,跟 Config 里存的那个原始引用再无关系。

但这是 default 序列化路径。当你的对象里有 Date 类型的字段时,Jackson 会走到 DateTimeSerializerBase,这里有更精细的处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// DateTimeSerializerBase.java
protected void _serializeAsString(Date value, JsonGenerator g,
SerializerProvider provider) throws IOException
{
if (_customFormat == null) {
provider.defaultSerializeDateValue(value, g);
return;
}

DateFormat f = _reusedCustomFormat.getAndSet(null);
if (f == null) {
f = (DateFormat) _customFormat.clone(); // ← 又是克隆!
}
g.writeString(f.format(value));
_reusedCustomFormat.compareAndSet(null, f);
}

这段代码非常精妙,值得逐行解读:

  1. _reusedCustomFormat 是一个 AtomicReference<DateFormat>,用于在多线程间复用一个已克隆的 DateFormat 实例
  2. getAndSet(null) 尝试取出缓存的克隆实例,取出后把引用置空
  3. 如果取到了(另一个线程没在用),直接用,不用重新克隆
  4. 如果没取到(另一个线程正在用),就 clone 一份新的
  5. 用完后通过 compareAndSet(null, f) 尝试放回去给下一个线程用

这是一个线程安全的对象池模式——用 AtomicReference 实现的轻量级池,既保证了线程安全,又避免了每次序列化都克隆的开销。

甚至在 createContextual 创建 per-property 的 Serializer 时,Jackson 也做了克隆:

1
2
3
4
5
6
// DateTimeSerializerBase.createContextual()
if (hasLocale) {
df = new SimpleDateFormat(df.toPattern(), format.getLocale());
} else {
df = (SimpleDateFormat) df.clone(); // ← 还是克隆!
}

克隆全景图

把所有克隆点汇总一下:

位置 何时克隆 机制
SerializerProvider._dateFormat() 每个 Provider 实例首次使用时,克隆一次 惰性克隆
DateTimeSerializerBase._serializeAsString() 缓存实例被其他线程占用时 AtomicReference 对象池 + clone
DateTimeSerializerBase.createContextual() 创建 per-property Serializer 时 clone 或 new

三条路径,三重保护。Jackson 在每一个可能使用你传入的 DateFormat 的地方,都做了克隆,确保多线程下不会共享同一个 SimpleDateFormat 实例。


所以 Baeldung 和 Spring 错了吗?

不完全错,但结论下得太早了。

Baeldung 文章中演示的那个并发错误,场景是这样的:

1
2
3
4
5
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
mapper.setDateFormat(sdf);

// 然后在另一个线程里
sdf.applyPattern("MM/dd/yyyy"); // ← 修改了原始引用

这确实会导致问题——因为 ObjectMapper.setDateFormat() 存储的是引用,如果你在 set 之后还去修改原始的 SimpleDateFormat,Config 里的引用也跟着变了。但这不是 ObjectMapper 的线程安全问题,这是你自己的使用问题

打个比方:你把自家的钥匙交给了一个靠谱的保险箱(ObjectMapper),保险箱保证你的东西放进去之后不会被别人动。但你自己手里还有一把同样的钥匙(原始引用),你自己跑去保险箱里的东西上乱涂乱画,然后怪保险箱不安全——这不合理吧?

Jackson 的契约很明确:配置完成后,不要再修改传入的对象。 这不是 Jackson 的问题,这是所有接受可变对象的 API 的通用规则。你在 Collections.unmodifiableList() 之后再修改原始 List,一样会出问题,但没人说 unmodifiableList 不安全。

Spring 的 Jackson2ObjectMapperFactoryBean 那个 “non-thread-safe” 标注,说实话,有点偷懒。它把”如果你不正确使用就不安全”偷换成了”它本身不安全”,这两个命题差别大了去了。


再多唠叨一些

这个问题让我想到一个更深层的东西:我们在讨论线程安全的时候,到底在讨论什么?

很多人把”线程安全”理解成一个非黑即白的布尔值——要么安全,要么不安全。但现实远比这复杂。线程安全是有条件的、有边界的、有契约的。

StringBuffer 是线程安全的,但如果你写这样的代码:

1
2
3
4
5
StringBuffer sb = new StringBuffer();
// 线程A
sb.append("hello");
// 线程B
sb.append("world");

每次 append 是原子的,但你得到的字符串可能是 “helloworld” 也可能是 “worldhello”——这不是你想要的。方法级别的线程安全,不等于业务级别的线程安全。

反过来,SimpleDateFormat 是线程不安全的,但如果你每次都 new 一个新的,或者像 Jackson 这样在内部做克隆,它就是安全的。类的线程不安全,不等于它在所有场景下都不安全。

这跟缓存更新的套路是一个道理——很多人只知道”先删缓存再更新数据库”这个口诀,但不去想为什么,也不去想在什么并发条件下这个口诀会失效。基础这玩意全都是相通的。 线程安全的本质不是记住哪些类安全哪些不安全,而是理解共享可变状态在哪里、谁在访问、怎么隔离。

所以下次再有人跟你说”ObjectMapper 配了 SimpleDateFormat 就不安全了”,你可以很自信地告诉他:去读源码。Jackson 在每一条序列化路径上都做了克隆,它是安全的——前提是你别在 set 完之后还去动那个 DateFormat。

这不是妥协,这是契约。