有个 Java 服务,要频繁地把日期字符串解析成 Date、或把 Date 格式化成字符串。为了"复用、省得每次都 new",我图省事,定义了一个 static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"),全局共享这一个实例,到处拿它来 parse 和 format。低并发时一切正常,跑了很久都没事。可流量一上来,诡异的现象就冒出来了:日志里偶发地出现解析错误——有时把 2026-05-31 解析成了一个莫名其妙的、完全不相干的日期;有时直接抛出 NumberFormatException、ArrayIndexOutOfBoundsException 这种和"日期解析"八竿子打不着的诡异异常。频率不高,却足以污染数据、触发告警。
我查了好一阵才锁定真凶,而且它经典得让每个 Java 老手都会心一笑、也让每个新手栽过跟头:SimpleDateFormat 不是线程安全的,而我却把它当成一个全局共享的单例,在多个线程里同时使用。问题出在它的内部实现:SimpleDateFormat 在 parse 和 format 的过程中,会用到一个内部共享的、可变的成员变量(一个叫 calendar 的对象)来暂存中间计算结果。当多个线程同时调用同一个实例时,它们会同时读写这个共享的中间状态——线程 A 算到一半,线程 B 进来把那个中间状态改了,A 再接着算,就基于一个被污染的状态算出了错误结果,甚至触发越界、解析异常。这就是典型的多线程共享可变状态导致的并发 bug。
这就是 Java 里一个极其经典、又极其隐蔽的坑:把线程不安全的对象(尤其是 SimpleDateFormat)当成全局共享实例,在多线程下使用。它平时风平浪静(低并发下很难触发那个"算到一半被打断"的时序),却会在高并发下偶发地出错,而且错得莫名其妙、难以复现。这篇文章,就从这次"共享 SimpleDateFormat 偶发解析错误"的事故出发,把线程安全、共享可变状态、以及正确的日期处理方式,一次讲透。
先摆几个关于线程安全的想当然
动手复盘前,先把我自己曾经深信、后来被这次事故教育的几个念头摆出来。
| 想当然的念头 | 残酷的真相 |
|---|---|
| "工具类对象, 做成 static 共享省资源, 准没错" | 共享的前提是它线程安全, 否则是灾难 |
| "SimpleDateFormat 就是格式化, 能有什么状态" | 它内部有可变的共享中间状态, 线程不安全 |
| "只是读取/解析, 又不改它, 应该安全" | parse/format 内部会写它的成员变量, 照样不安全 |
| "低并发测试没问题, 上线就稳" | 并发 bug 只在高并发特定时序下偶发, 测不出来 |
| "加个 static 是性能优化" | 对线程不安全的对象, 这个"优化"是埋雷 |
这些念头的共同病根,是把"复用一个对象实例"这件看似纯粹是"省资源"的好事,和"这个对象能不能被多线程安全地共享"这个前提脱了钩。共享一个对象,只有当它是线程安全的,才是优化;否则,就是在多线程之间引入了一处危险的、共享的可变状态。要看清这次事故,得先理解为什么 SimpleDateFormat 不能被共享。
第一件事:为什么 SimpleDateFormat 线程不安全
理解这个坑,要看 SimpleDateFormat 的内部。它继承自 DateFormat,而 DateFormat 里有一个受保护的成员变量 calendar。当你调用 parse 或 format 时,方法内部会用这个 calendar 对象来做日期的中间计算——先把它"清空/设置",再往里"塞值",最后从里面"取结果"。问题就在于:这个 calendar 是实例的成员变量,被这个 SimpleDateFormat 实例的所有调用者共享;而它在一次 parse/format 过程中,是被反复读写的、可变的中间状态。
在单线程下,这毫无问题:一次计算完整地走完,再开始下一次。可在多线程共享同一个实例时,灾难就来了:线程 A 正用 calendar 算到一半(比如刚塞进去年份、还没塞月份),线程 B 也进来用同一个 calendar、把它的状态改了;A 回来继续,基于 B 留下的、被污染的状态往下算——结果要么算出一个错误的日期,要么因为 calendar 的内部状态被搞乱而抛出各种诡异的异常。下面这张图,把这个并发污染的过程画出来:
看懂这张图,事故的根就清楚了:问题不在于 SimpleDateFormat 本身有 bug,而在于它从设计上就不是为多线程共享而生的——它内部那个可变的 calendar 中间状态,在被多个线程同时读写时,必然会互相干扰。把一个"内部有可变共享状态、且没有做任何同步保护"的对象,放到多线程里共享使用,就是在制造数据竞争(race condition)。接下来,我们就看怎么正确地用它。
第二件事:根治之道——改用不可变、线程安全的 DateTimeFormatter
最根本、最推荐的解法,是彻底告别 SimpleDateFormat,改用 Java 8 引入的 java.time 包里的 DateTimeFormatter。它和 SimpleDateFormat 最本质的区别是:DateTimeFormatter 是不可变(immutable)的——它内部没有任何可变的共享状态,parse/format 的中间结果都放在方法的局部变量里,不写自己的成员。而"不可变",天然就是"线程安全"的——既然它的状态永远不变、不存在被并发修改的可能,那么任意多个线程同时共享它、使用它,都绝对安全。所以你可以放心地把一个 DateTimeFormatter 定义成全局静态共享。
// 反例:共享 SimpleDateFormat, 线程不安全, 高并发偶发出错
static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
// 多线程同时 SDF.parse(...) / SDF.format(...) -> 数据竞争, 偶发错误
// 正解:用 java.time 的 DateTimeFormatter, 不可变、线程安全, 放心共享
static final DateTimeFormatter FMT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// 解析:字符串 -> LocalDateTime(线程安全)
LocalDateTime dt = LocalDateTime.parse("2026-05-31 12:00:00", FMT);
// 格式化:LocalDateTime -> 字符串(线程安全)
String str = dt.format(FMT);
// 这个 FMT 实例可以被任意多个线程同时使用, 永远不会出错
从 SimpleDateFormat 迁移到 java.time(LocalDateTime、LocalDate、DateTimeFormatter 等),是现代 Java 处理日期时间的标准做法,好处远不止线程安全:这套新 API 设计得更清晰、更不容易出错(比如月份从 1 开始,而老的 Date 月份从 0 开始这种坑就没了),还原生支持时区(呼应我们之前聊过的时区问题)。所以这次事故最好的"修法",其实是一次"升级"——拥抱 java.time,从根上把"日期处理的线程安全"和"API 易用性"两个问题一起解决了。如果你还在用老的 Date + SimpleDateFormat,这就是一个把它们换掉的好契机。
第三件事:还在用 SimpleDateFormat?那就别共享它
如果因为种种原因(老项目、依赖限制),你暂时还得用 SimpleDateFormat,那么核心原则就是:绝不在多线程间共享同一个实例。具体有几种安全的用法,各有取舍。
// 方案一(最简单):每次用时, 在方法内 new 一个局部的
public Date parse(String s) throws ParseException {
// 局部变量, 每个线程/每次调用各有各的, 互不干扰, 绝对安全
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
return sdf.parse(s);
}
// 缺点:每次都 new 有一点点开销, 但通常可忽略, 优先用这个
// 方案二:用 ThreadLocal, 每个线程持有自己独立的一个实例
private static final ThreadLocal SDF_LOCAL =
ThreadLocal.withInitial(() ->
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
public Date parse(String s) throws ParseException {
return SDF_LOCAL.get().parse(s); // 每个线程拿自己的, 不共享, 安全
}
// 注意:线程池场景下, ThreadLocal 用完的清理问题要留意(见 ThreadLocal 那篇)
// 方案三:加锁(synchronized)——能保证安全, 但并发下成了瓶颈, 不推荐
这几种方案的取舍很清晰:方案一"每次 new 局部实例"最简单、最不易错,通常 new 的开销可以忽略,是退而求其次时的首选;方案二 ThreadLocal "每个线程一个实例"避免了重复创建、又不共享,适合对性能敏感的高频场景,但要注意线程池下的清理;方案三加锁虽然能保证安全,却把并发的多个线程强行串行化了,在高并发下会成为性能瓶颈,一般不推荐。但无论哪种,核心思想都是一致的:让每个线程用到的,要么是各自独立的实例,要么是有同步保护的访问——绝不让多个线程"裸奔"地共用同一个可变状态。当然,这些都是"补救";能升级到 DateTimeFormatter 的话,那才是治本。
第四件事:不止 SimpleDateFormat——这些常见类也线程不安全
SimpleDateFormat 只是"线程不安全却常被误共享"的典型代表。Java 里还有一批常用类同样不是线程安全的,如果你也把它们当全局共享实例用,会踩同样的坑。认得这份"黑名单",能帮你举一反三。
// 这些常用类都【线程不安全】, 别在多线程间共享同一个可变实例:
// 1. SimpleDateFormat —— 本文主角(用 DateTimeFormatter 替代)
// 2. 普通集合 ArrayList / HashMap —— 多线程同时读写会数据错乱甚至死循环
// 多线程要用: ConcurrentHashMap / CopyOnWriteArrayList
// 3. StringBuilder —— 线程不安全(它的线程安全版是 StringBuffer)
// 4. 各种带可变内部状态的工具对象(如某些 Random 的共享、某些解析器)
// 对比:这些是【线程安全】的, 可以放心共享:
// DateTimeFormatter(不可变)、String(不可变)、
// ConcurrentHashMap、AtomicInteger、以及无状态的工具类
这份名单背后,藏着一个判断"一个对象能不能被多线程共享"的通用方法:看它内部有没有"可变的状态(成员变量)",以及这些状态在被并发访问时有没有做同步保护。无状态的(纯计算、不存任何成员)、或不可变的(状态创建后永不改变)对象,天生线程安全,随便共享;而有可变成员、又没做同步的对象(如 SimpleDateFormat、ArrayList),就绝不能裸共享。用一个新的对象做共享单例前,养成习惯去查一下它"是不是线程安全的"——这个查文档的几秒钟,能帮你躲掉一个高并发下偶发、又极难排查的并发 bug。
第五件事:不可变性——对抗并发问题的终极利器
从 DateTimeFormatter 之所以安全(因为不可变)这件事,可以引出一个更深刻、更普适的并发编程思想:不可变性(immutability),是对抗多线程问题最简单、最强大的武器。所有的并发 bug,本质上都源于"多个线程同时读写同一份可变的共享状态"。而如果一个对象是不可变的——它一旦被创建,状态就永远不会改变——那么"被并发修改"这件事就从根本上不可能发生,它也就天然地、无条件地是线程安全的,你完全不需要加锁、不需要任何同步措施。
// 不可变对象:状态创建后永不改变, 天然线程安全, 无需任何同步
public final class Money { // final 类, 不可被继承
private final long cents; // final 字段, 创建后不可改
public Money(long cents) { this.cents = cents; }
public long getCents() { return cents; }
// 没有任何 setter; "修改"操作返回一个新对象, 而非改自己
public Money add(Money other) {
return new Money(this.cents + other.cents); // 返回新实例
}
}
// 这样的 Money 对象, 可以被任意多个线程自由共享, 永远不会有并发问题
// java.time 的类、String、BigDecimal、包装类型, 都是这种不可变设计
这就是为什么现代编程越来越推崇"不可变"的设计:它用"修改即创建新对象"的方式,彻底回避了"共享可变状态"这个并发问题的总根源。函数式编程更是把不可变奉为核心。在你设计类、尤其是设计那些会被多线程共享的对象时,优先考虑把它设计成不可变的——能不可变,就别留可变状态;需要"变化"时,返回新对象,而不是修改自己。这个习惯,能让你的代码在并发世界里,从"需要小心翼翼地加锁防护"变成"天生就安全、无需操心"——这是一种境界上的提升。
第六件事:这类并发 bug 怎么排查?
最后说排查。共享线程不安全对象导致的 bug,有几个鲜明的特征,认得它们能帮你快速锁定方向:偶发(不是必现)、与并发量正相关(流量越大越频繁)、错误现象诡异(常常抛出和当前操作逻辑上不相关的异常,如日期解析抛数组越界)、且难以在低并发的测试环境复现。一旦你的 bug 符合这几个特征,就该高度怀疑"是不是有什么线程不安全的对象被共享了"。
// 排查并发 bug 的几个着手点:
// 1. 现象画像:偶发 + 高并发时更频繁 + 异常诡异不相关 -> 高度怀疑并发问题
// 2. 重点排查"被多线程共享的可变对象":
// 搜 static 的字段, 看哪些是 SimpleDateFormat、ArrayList、StringBuilder 等
// 线程不安全的类型, 又被多个线程同时访问
// 3. 用工具:
// - 静态扫描 SpotBugs/FindBugs 能发现部分并发问题
// - 高并发压测 + 数据一致性断言, 主动把偶发问题逼出来
// 4. 验证:把可疑的共享对象改成"每次 new"或"加锁", 看问题是否消失
这套排查思路的核心,是先通过"偶发、随并发量增长、现象诡异"这组特征,把问题归类为"并发 bug";然后顺着"哪些可变对象被多线程共享了"这条线去重点排查那些 static 的、线程不安全类型的字段。并发 bug 之所以难,是因为它不稳定、难复现;但它也有迹可循——只要你建立起"看到偶发+高并发+诡异异常,就怀疑共享可变状态"的条件反射,就能快速地把排查方向对准真凶。到这儿,这次事故的方方面面就齐了。我把它收成一张决策图:
把这套理解建立起来,这类"共享线程不安全对象"的并发 bug 就能被预防和快速定位。最后,拧成几条可直接照做的铁律:
- SimpleDateFormat 线程不安全,绝不要做成全局共享, 它内部有可变的 calendar 状态。
- 优先升级到 java.time 的 DateTimeFormatter,不可变、线程安全, 还更好用。
- 非用 SimpleDateFormat 不可, 就别共享它,每次 new 局部实例或用 ThreadLocal。
- 共享一个对象前, 先确认它线程安全,ArrayList/HashMap/StringBuilder 等都不安全。
- 把可变状态换成并发安全版,如 ConcurrentHashMap、CopyOnWriteArrayList。
- 优先用不可变对象设计,它天生线程安全, 是对抗并发问题的终极利器。
- 偶发+高并发+诡异异常, 就怀疑共享可变状态,这是排查并发 bug 的方向感。
一张线程安全速查表
把常见类的线程安全情况、以及多线程下的替代方案汇成一张表,做共享设计时对照着选。
| 线程不安全的类 | 多线程下的正确做法 |
|---|---|
| SimpleDateFormat | 用 DateTimeFormatter(不可变), 或每次 new / ThreadLocal |
| ArrayList | CopyOnWriteArrayList, 或 Collections.synchronizedList |
| HashMap | ConcurrentHashMap |
| StringBuilder | StringBuffer, 或局部变量(不共享) |
| 普通可变计数器 int/long | AtomicInteger / AtomicLong |
| 自定义的有可变状态的类 | 设计成不可变, 或加锁同步 |
| Calendar | 用 java.time 的 LocalDate/LocalDateTime(不可变) |
一个更深的视角:性能优化的"想当然"
这次事故还藏着一个值得反思的点:我把 SimpleDateFormat 做成 static 共享,初衷是"性能优化"——避免每次都 new 一个、省点开销。可这个"优化",非但没带来什么实质的性能提升(new 一个 SimpleDateFormat 的开销其实很小),反而引入了一个致命的并发 bug。这是一个典型的"过早优化"、或者说"想当然的优化"的反面教材:为了一个微不足道、甚至根本不存在的性能收益,牺牲了正确性,埋下了大坑。
这给我的教训是:任何"优化",都要先掂量清楚它的收益和代价。"共享一个对象省得 new"这种优化,收益往往小得可以忽略,而一旦那个对象线程不安全,代价就是难以排查的并发灾难——这笔账,怎么算都不划算。更普遍地说,很多我们凭直觉做的"优化",其实并没有经过真正的度量,只是一种"感觉这样更快/更省"的想当然;而这种未经验证的优化,常常是 bug 和复杂度的来源。真正成熟的做法是:先写正确、清晰的代码;只在确实有性能问题、且经过测量定位到瓶颈之后,才有针对性地优化——而且优化时,绝不以牺牲正确性为代价。"正确"永远优先于"快";一个又快又错的程序,毫无价值。
写在最后
这次"共享 SimpleDateFormat 偶发出错"的事故,给我最深的体会,是它再次展现了"并发"这个领域独有的、令人敬畏的难度。在单线程的世界里,代码的执行是确定的、顺序的、可预测的——你写下什么,它就一步步地执行什么。可一旦进入多线程,一切都变了:多个执行流在共享的状态上交错穿行,它们之间的相对时序是不确定的、随机的;一个在单线程下天经地义的操作(比如"用一个对象算个日期"),在多线程下可能因为"算到一半被另一个线程打断"而彻底错乱。并发 bug 的可怕,正在于这种"不确定性"——它让 bug 变得偶发、难复现、现象诡异,把排查的难度提升了一个数量级。
而应对这种"不确定性"的智慧,这次事故也给出了清晰的指引,它们层层递进:最高明的,是消除共享可变状态本身——用不可变对象(如 DateTimeFormatter),让"并发修改"从根本上不可能发生,釜底抽薪;退一步,是不共享——让每个线程用自己独立的实例(局部变量、ThreadLocal),井水不犯河水;再退一步,才是同步保护——用锁等机制,让对共享状态的访问串行化。这条"消除共享 大于 不共享 大于 加锁"的优先级,几乎是所有并发问题的通用解题思路。这次教训让我对并发编程多了一份发自内心的谨慎:每当我的代码要在多线程环境下运行、每当一个对象要被多个线程触碰,我都会下意识地问一句——这里有没有共享的可变状态?它安全吗?因为我深知,并发的世界不相信"想当然"的直觉,它只奖励那些对共享状态时刻保持警觉、并用不可变与隔离去驯服不确定性的人。愿你我在踏入多线程这片既强大又凶险的领域时,都怀着这份谦卑与警觉,把每一处共享状态都安顿妥当——因为正是这份对并发的敬畏,守护着我们的系统在高并发的惊涛骇浪中,依然稳稳地、正确地运行。
如果你手上也有 Java 项目,不妨今天就花二十分钟做一次自查:全局搜一下 static 修饰的 SimpleDateFormat(以及 ArrayList、HashMap、StringBuilder)字段,逐个确认它们是不是被多个线程共享访问;凡是符合的,要么升级到 DateTimeFormatter 或并发安全集合,要么改成不共享的写法。这个排查成本不高,却能帮你拆掉一批低并发岁月静好、高并发偶发暴雷的隐蔽地雷。把共享前先确认线程安全内化成一种本能,你的服务在流量洪峰面前,才能真正稳得住。
—— 别看了 · 2026