4.3 SimpleDateFormat线程安全事故


文档摘要

4.3 SimpleDateFormat 线程安全事故 本节摘要: 被声明为非线程安全,但它的并发失效方式不是抛异常,而是安静地返回错误日期——这比崩溃恶劣得多。本节还原一次"账单日期变成 1970 年"的生产事故,拆解 Calendar 内部状态被并发污染的机理,给出 DateTimeFormatter、ThreadLocal、Joda 迁移三条路线,并顺带把 java.time 家族的选型讲全。 事故现场:账单日期漂移,偶发且无法复现 账单中心接到投诉:某用户 8 月的账单显示创建时间为 2026 年 8 月 15 日 04:32,另一笔干脆显示 1970 年。数据库里的数据确实错了,说明写入前就错了。追到代码: 文档里明确写着不线程安全,但没人细看。

4.3 SimpleDateFormat 线程安全事故

本节摘要SimpleDateFormat 被声明为非线程安全,但它的并发失效方式不是抛异常,而是安静地返回错误日期——这比崩溃恶劣得多。本节还原一次"账单日期变成 1970 年"的生产事故,拆解 Calendar 内部状态被并发污染的机理,给出 DateTimeFormatter、ThreadLocal、Joda 迁移三条路线,并顺带把 java.time 家族的选型讲全。

事故现场:账单日期漂移,偶发且无法复现

账单中心接到投诉:某用户 8 月的账单显示创建时间为 2026 年 8 月 15 日 04:32,另一笔干脆显示 1970 年。数据库里的数据确实错了,说明写入前就错了。追到代码:

public class DateUtils { // 工具类嘛 提成静态常量复用 是"优化" private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); public static String format(Date d) { return SDF.format(d); } }

SimpleDateFormat 文档里明确写着不线程安全,但没人细看。多线程并发调用同一个实例时,它内部那个共享的 Calendar 对象被交错读写:解析到一半的年份数组被另一个线程覆盖,月份字段残留上一个线程的中间值。三种症状随机出现:日期错乱(字段串味)、NumberFormatException(字符偏移量被改)、最诡异的——结果"看起来合法但完全错误"。偶发、难复现、数据已落库,是数据事故里最贵的品类。

机理:状态藏在解析器里

SimpleDateFormat.format/parse 的执行过程要往内部 Calendar 写年月日时分秒再读出来格式化。两个线程同时跑,相当于两个人在同一个白板上各写各的算式——结果当然是两份都错。而 DateTimeFormatter 之所以线程安全,是因为它不可变:只保存 pattern 配置,格式化用的中间状态存在新创建的 DateTimeValue/本地对象里,天然线程封闭。这就是不可变对象在并发里的免检通行证(第 2 章的 record、String 同理)。

// 简化示意 两个线程交错执行 format Thread A: calendar.set(2026, 7, 15) // 写入 8 月 15 日 Thread B: calendar.set(1970, 0, 1) // 覆盖成 1970 年 1 月 Thread A: buffer.append(calendar.get(YEAR)) // A 读到 1970

并发污染的交错示意

并发污染的交错示意

三条修复路线

路线一(正解):java.time。JDK 8 引入的 java.time(JSR 310)把整个日期 API 重造了一遍:

private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); LocalDateTime time = LocalDateTime.parse(text, FMT); // 解析 String text2 = time.format(FMT); // 格式化 LocalDate today = LocalDate.now(ZoneId.of("Asia/Shanghai")); // 显式时区

类型分工明确:LocalDate 纯日期、LocalTime 纯时间、LocalDateTime 无时区时间戳、ZonedDateTime 带时区、Instant 机器时间。月份从 1 开始(老 Calendar 的 0 表示一月是多少事故的源头)、DateTimeFormatter 不可变线程安全、加减日期返回新对象而非改自身。新代码没有任何理由再用 Date + SimpleDateFormat

路线二(过渡):ThreadLocal 隔离。改不动的存量代码,给每线程一份实例:

private static final ThreadLocal<SimpleDateFormat> SDF = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

能解决正确性,但要与第 2 章的教训联动:线程池场景 ThreadLocal 不清理会滞留(尤其存的是大对象),SimpleDateFormat 小,风险可控。它仍是"补丁"定位。

路线三(历史):Joda-Time。java.time 的设计直接来自 Joda 作者,JDK 8 之后的迁移是"Joda 换官方",没有理由新引入。

java.time 的工程要点

  • 时区显式化LocalDateTime.now() 取的是 JVM 默认时区,容器时区与预期不一致时全表漂移。服务器统一 UTC、展示层转用户时区,是国际化的标准架构
  • 数据库映射:JDBC 4.2 起实体字段直接用 LocalDateTime/LocalDate,不再过 java.sql.Timestamp 中转
  • 老新互转Date.toInstant()Date.from(instant) 是桥,转换处集中在适配层
  • 周期计算Duration(机器时间差)与 Period(年月日差)别混用,ChronoUnit.DAYS.between 处理跨夏令时的日期差更稳
  • 格式化线程标识:pattern 里 YYYY(week-based year)与 yyyy 在跨年周会差一年,年终事故常客——手写 pattern 时盯紧大小写
维度 Date + SimpleDateFormat java.time
线程安全 否,共享即错 是,核心类型全不可变
月份语义 0 起始 1 起始
可变性 set 修改自身 运算返回新对象
时区表达 隐藏在内部 显式类型区分
API 表意 命名混乱 按语义分型

⚠️ 常见坑:以为"synchronized 一下就行"。加锁保住正确性,但高并发下所有线程排队过同一把锁,吞吐塌方——正确的修法是消除共享可变状态,不是给共享状态加闸。

💡 关键直觉:线程安全问题的最优解几乎总是"换成不可变",而不是"给可变的加锁"。锁是协作约定,不可变是物理保证。

防坑清单

  • 全局检索 static SimpleDateFormatnew SimpleDateFormat 复用点,逐个替换为静态 DateTimeFormatter
  • 新代码禁用 Date/Calendar;存量经 toInstant 桥接逐步迁移
  • pattern 大小写逐字符评审,YYYY 混入 yyyy 直接打回
  • 时区策略写入团队规范:存储 UTC,边界转换显式 ZoneId
  • 偶发"合法但错误"的数据问题,排查清单加一条:共享可变工具类

本节要点回顾

  • 事故机理:内部共享 Calendar 被并发交错读写,静默产出错误日期
  • 症状谱:数据错乱、NumberFormatException、合法但全错,全部偶发
  • 正解:DateTimeFormatter 不可变,静态常量随便复用
  • 补丁:ThreadLocal 隔离可用于存量,注意池化场景清理
  • java.time 纪律:类型按语义选、时区显式、月份 1 起始、pattern 大小写敏感

下一节把视角拉到 IO——阻塞模型如何决定一个系统的连接容量上限。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U