4.3 SimpleDateFormat 线程安全事故 本节摘要: 被声明为非线程安全,但它的并发失效方式不是抛异常,而是安静地返回错误日期——这比崩溃恶劣得多。本节还原一次"账单日期变成 1970 年"的生产事故,拆解 Calendar 内部状态被并发污染的机理,给出 DateTimeFormatter、ThreadLocal、Joda 迁移三条路线,并顺带把 java.time 家族的选型讲全。 事故现场:账单日期漂移,偶发且无法复现 账单中心接到投诉:某用户 8 月的账单显示创建时间为 2026 年 8 月 15 日 04:32,另一笔干脆显示 1970 年。数据库里的数据确实错了,说明写入前就错了。追到代码: 文档里明确写着不线程安全,但没人细看。
本节摘要:
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 换官方",没有理由新引入。
LocalDateTime.now() 取的是 JVM 默认时区,容器时区与预期不一致时全表漂移。服务器统一 UTC、展示层转用户时区,是国际化的标准架构LocalDateTime/LocalDate,不再过 java.sql.Timestamp 中转Date.toInstant() 与 Date.from(instant) 是桥,转换处集中在适配层Duration(机器时间差)与 Period(年月日差)别混用,ChronoUnit.DAYS.between 处理跨夏令时的日期差更稳YYYY(week-based year)与 yyyy 在跨年周会差一年,年终事故常客——手写 pattern 时盯紧大小写| 维度 | Date + SimpleDateFormat | java.time |
|---|---|---|
| 线程安全 | 否,共享即错 | 是,核心类型全不可变 |
| 月份语义 | 0 起始 | 1 起始 |
| 可变性 | set 修改自身 | 运算返回新对象 |
| 时区表达 | 隐藏在内部 | 显式类型区分 |
| API 表意 | 命名混乱 | 按语义分型 |
⚠️ 常见坑:以为"synchronized 一下就行"。加锁保住正确性,但高并发下所有线程排队过同一把锁,吞吐塌方——正确的修法是消除共享可变状态,不是给共享状态加闸。
💡 关键直觉:线程安全问题的最优解几乎总是"换成不可变",而不是"给可变的加锁"。锁是协作约定,不可变是物理保证。
static SimpleDateFormat 与 new SimpleDateFormat 复用点,逐个替换为静态 DateTimeFormattertoInstant 桥接逐步迁移下一节把视角拉到 IO——阻塞模型如何决定一个系统的连接容量上限。