静态共享一个 SimpleDateFormat 给所有线程,高并发下偶发日期错乱甚至抛异常:一次线程不安全的深度排查与 DateTimeFormatter 正解

把 SimpleDateFormat 设成 static final 给所有线程共享,平时好好的,一上高并发就偶发日期错乱、甚至 NumberFormatException——SimpleDateFormat 内部用一个可变的 Calendar 暂存中间状态,并发 format/parse 时这个共享可变状态被互相覆盖,典型的数据竞争。本文从偶发难复现的现场讲起,剖析它非线程安全的根因,给出 DateTimeFormatter(不可变线程安全)/ThreadLocal/每次新建三种正解,并梳理 Java 线程安全的一系列常见坑。

我把 SimpleDateFormat 设成静态共享的图省事,高并发下却偶尔解析出莫名其妙的日期、还时不时抛异常,我对着 SimpleDateFormat 不是线程安全的这个坑排查了大半天的复盘

这是一个让我对"共享一个对象前,先问它线程安全吗"彻底记牢的 Java 并发坑。它最折磨人的地方在于:它偶发、随机、难复现——绝大多数时候日期格式化/解析都好好的,可在高并发下,会偶尔蹦出一个莫名其妙的日期、或偶尔抛个异常,概率低到让你抓不住、又确实在发生。

需求很常见:代码里很多地方要格式化/解析日期。我想"SimpleDateFormat 创建有开销,定义成静态共享的复用一下,省得每次 new",于是写了:

// 静态共享 SimpleDateFormat(有问题的版本)
public class DateUtil {
    // ★ 静态共享一个实例, 图复用、省创建开销
    private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static String format(Date date) {
        return SDF.format(date);      // 多个线程并发调用同一个SDF
    }
    public static Date parse(String s) throws ParseException {
        return SDF.parse(s);          // 多个线程并发调用同一个SDF
    }
}

// 现象(高并发下):
//   - 大多数时候正常
//   - 偶尔: format/parse 出莫名其妙的日期(比如年份/月份错乱)
//   - 偶尔: 抛 NumberFormatException 或其他诡异异常
//   - 低并发/单元测试时几乎不复现, 高并发生产才偶现

我盯着监控里那些偶发的、日期错乱的记录,和零星的 NumberFormatException,起初完全摸不着头脑——代码看起来人畜无害,日期格式字符串也对,大多数时候结果都正确;可就是会偶尔、随机地解析出错误的日期、或抛个莫名的异常。而且这种问题单元测试根本测不出来(单线程跑一万次都没事),只在高并发的生产环境偶现——这种"时灵时不灵、和并发强相关"的特征,正是它的真面目。

第一件事:看清真相——SimpleDateFormat 内部有可变状态,不是线程安全的

我去深入研究了 SimpleDateFormat 的实现,才彻底明白这个"偶发日期错乱"的根源——SimpleDateFormat 不是线程安全的:它在 format/parse 的过程中,会用到一个内部共享的、可变的 Calendar 对象来暂存中间的日期计算状态;当多个线程并发调用同一个 SimpleDateFormat 实例时,它们会同时读写这个共享的 Calendar,导致状态互相覆盖、错乱——于是 A 线程算到一半的中间状态,被 B 线程冲掉了,A 就得出了错误的日期、甚至抛异常

SimpleDateFormat 线程不安全的真相

# 1. SimpleDateFormat 在 format/parse 时, 会用到一个【内部的 Calendar 对象】
#    来暂存"正在计算的日期"的中间状态(年、月、日等)。
#    - 这个 Calendar 是 SimpleDateFormat 实例的【成员变量】(共享的、可变的状态)。

# 2. 关键: 它【不是线程安全的】!
#    - 它在计算过程中, 会【读写这个共享的 Calendar】;
#    - 它没有任何加锁/同步保护这个共享状态。

# 3. 多线程并发调用同一个SimpleDateFormat实例时:
#    - 线程A 正在用那个Calendar算日期(写了一半中间状态);
#    - 线程B 同时也来用【同一个Calendar】, 把A的中间状态【覆盖】了;
#    - → A继续用被污染的状态算下去, 得出【错误的日期】, 或【抛异常】;
#    - 这是典型的【数据竞争】: 多线程无同步地读写共享可变状态。

# 4. 为什么"偶发、难复现":
#    - 要"恰好两个线程同时操作那个Calendar"才出错;
#    - 低并发时几乎碰不上(概率极低), 单元测试单线程更测不出;
#    - 高并发时频繁发生 → 偶尔错乱; 这是数据竞争的典型特征。

# 5. 把"创建有开销、想复用"当成了"可以静态共享"——这是错的:
#    - "想复用以省开销"是合理诉求, 但前提是"这个对象线程安全";
#    - SimpleDateFormat 不线程安全, 就【不能】多线程共享一个实例!

# 6. java.date里好几个老类都有线程安全问题(SimpleDateFormat、Calendar等);
#    这也是 Java 8 推出 java.time(新日期时间API, 不可变、线程安全)的原因之一。

# 核心: SimpleDateFormat 内部用共享可变的Calendar暂存状态, 不是线程安全的; 多线程共享一个实例
#   并发format/parse会数据竞争、得出错误日期或抛异常; 偶发难复现是数据竞争的典型特征。

真相大白,我恍然大悟。原来 SimpleDateFormat 在 format/parse 时,会用到一个内部的、共享的、可变的 Calendar 对象(它的成员变量)来暂存"正在计算的日期"的中间状态;而它不是线程安全的——它读写这个共享 Calendar 时没有任何加锁保护于是多线程并发调用同一个实例时:线程 A 正用那个 Calendar 算日期(写了一半中间状态),线程 B 同时也来用同一个 Calendar、把 A 的中间状态覆盖了;A 继续用被污染的状态算下去,得出错误的日期抛异常——这是典型的数据竞争(多线程无同步地读写共享可变状态)。而"偶发、难复现"是因为:要"恰好两个线程同时操作那个 Calendar"才出错,低并发碰不上、单线程测不出,高并发才偶现——这是数据竞争的典型特征。我犯的错,是把"创建有开销、想复用"当成了"可以静态共享"——"想复用省开销"是合理诉求,但前提是这个对象线程安全;SimpleDateFormat 不线程安全,就不能多线程共享一个实例!(java.util.date 里好几个老类都有线程安全问题,这也是 Java 8 推出 java.time 的原因之一。)

第二件事:正解——用 java.time 的 DateTimeFormatter(不可变线程安全),或 ThreadLocal/每次新建

搞懂了原理,正解就清晰了:优先用 Java 8+ 的 DateTimeFormatter(不可变、线程安全,可放心静态共享);如果还在用 SimpleDateFormat,就每次新建、或用 ThreadLocal 每线程一个实例

// ====== 正解一(推荐, Java 8+): 用 DateTimeFormatter, 不可变线程安全 ======
import java.time.format.DateTimeFormatter;
import java.time.LocalDateTime;

public class DateUtil {
    // ★ DateTimeFormatter 是【不可变、线程安全】的, 可以放心静态共享!
    private static final DateTimeFormatter FMT =
        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    public static String format(LocalDateTime dt) {
        return dt.format(FMT);        // ✓ 多线程并发调用安全
    }
    public static LocalDateTime parse(String s) {
        return LocalDateTime.parse(s, FMT);   // ✓ 安全
    }
}
// → java.time 整套(LocalDateTime/DateTimeFormatter等)都是不可变、线程安全的;
//   这是现代Java处理日期时间的首选, 从根上没有SimpleDateFormat的线程安全问题。

// ====== 正解二: 如果必须用 SimpleDateFormat, 用 ThreadLocal(每线程一个) ======
public class DateUtil2 {
    // 每个线程有自己独立的SimpleDateFormat实例, 互不干扰, 又复用(不用每次new)
    private static final ThreadLocal SDF =
        ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

    public static String format(Date date) {
        return SDF.get().format(date);   // ✓ get()拿到本线程独有的实例, 安全
    }
}
// → ThreadLocal: 每个线程一个独立实例, 既避免并发共享、又避免每次new的开销。

// ====== 正解三: 每次用时新建(简单, 但有创建开销) ======
public static String format3(Date date) {
    return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date);  // 每次new一个局部的
}
// → 局部变量, 不共享, 线程安全; 简单但高频调用有创建开销。

// ====== ✗ 绝对错误: 多线程共享一个SimpleDateFormat实例 ======
// private static final SimpleDateFormat SDF = new SimpleDateFormat(...);  // ✗ 并发会错乱!
// (除非你给每次调用都加锁synchronized, 但那样并发性能差, 不如用上面的方案)

// 核心: 优先用java.time的DateTimeFormatter(不可变线程安全, 可静态共享); 还用SimpleDateFormat
//   就用ThreadLocal(每线程一个)或每次新建; 绝不多线程共享一个SimpleDateFormat实例。

修复的核心,是"用线程安全的 DateTimeFormatter,或让 SimpleDateFormat 不被共享"正解一(推荐,Java 8+):用 DateTimeFormatter——它是不可变、线程安全的,可以放心静态共享;java.time 整套(LocalDateTime/DateTimeFormatter)都是不可变线程安全的,是现代 Java 处理日期时间的首选,从根上没有 SimpleDateFormat 的线程安全问题正解二:必须用 SimpleDateFormat 就用 ThreadLocal——ThreadLocal.withInitial(() -> new SimpleDateFormat(...)),每个线程一个独立实例、互不干扰,既避免并发共享、又避免每次 new 的开销正解三:每次用时新建——局部变量不共享、线程安全,简单但高频调用有创建开销绝对避免:多线程共享一个 SimpleDateFormat 实例(并发会错乱;除非每次调用都 synchronized,但那样并发性能差)归根结底:优先用 java.time 的 DateTimeFormatter(不可变线程安全、可静态共享);还用 SimpleDateFormat 就用 ThreadLocal 或每次新建;绝不多线程共享一个 SimpleDateFormat 实例。

第三件事:线程安全相关的其他常见坑

排查后我把 Java 线程安全相关的其他常见坑也系统梳理了一遍。

线程安全的其他常见坑

# 1. SimpleDateFormat共享(本文): 非线程安全, 并发错乱。→ DateTimeFormatter/ThreadLocal。

# 2. 把非线程安全的类当线程安全用: HashMap/ArrayList/StringBuilder等都非线程安全;
#    多线程共享并发改会出错(见Go map并发那类问题)。→ 用并发容器或加锁。

# 3. 单例里的可变共享状态: 单例对象被所有线程共享, 它的可变字段要线程安全。

# 4. 误以为final就线程安全: final只保证引用不变, 引用指向的对象内部仍可变、仍可能不安全。

# 5. 检查再操作的竞态(check-then-act): if(map没有key) map.put(...) 之间有竞态。
#    → 用原子方法 putIfAbsent/computeIfAbsent。

# 6. 共享计数器没用原子类: count++ 不是原子的, 并发会丢更新。→ 用AtomicInteger。

# 7. 可见性问题: 一个线程改了变量, 另一个线程看不到(没用volatile/同步)。

# 8. 把"无状态"和"有状态"搞混: 无状态的对象天然线程安全, 有状态的要小心。

# 共同根源: 多线程并发访问"共享的可变状态"而没有正确同步, 就是数据竞争/线程不安全;
#   而很多常用类(SimpleDateFormat/HashMap/StringBuilder)恰恰是"有可变状态且非线程安全"的。

# 核心: 共享一个对象给多线程前, 先问"它线程安全吗"; 非线程安全的(SimpleDateFormat/HashMap等)
#   别共享, 用线程安全替代(DateTimeFormatter/ConcurrentHashMap)、ThreadLocal、加锁或不共享。

排查让我把线程安全的其他坑也梳理清了。一、SimpleDateFormat 共享(本文)。二、把非线程安全的类当线程安全用(HashMap/ArrayList/StringBuilder 都非线程安全,并发改出错)。三、单例里的可变共享状态四、误以为 final 就线程安全(final 只保证引用不变,对象内部仍可变)。五、check-then-act 竞态(用 putIfAbsent/computeIfAbsent)。六、共享计数器没用原子类(count++ 非原子,用 AtomicInteger)。七、可见性问题(没 volatile 看不到别人的修改)。八、把无状态和有状态搞混它们的共同根源是:多线程并发访问"共享的可变状态"而没有正确同步,就是数据竞争/线程不安全;而很多常用类(SimpleDateFormat/HashMap/StringBuilder)恰恰是"有可变状态且非线程安全"的核心是:共享一个对象给多线程前,先问"它线程安全吗";非线程安全的别共享,用线程安全替代、ThreadLocal、加锁或不共享下面这张图,是这次 SimpleDateFormat 错乱的成因与解法:

第四件事:常见类的线程安全性速查表

这次踩坑后,我把常见 Java 类的线程安全性整理成一张表,共享前对照。

线程安全? 多线程怎么用
SimpleDateFormat ✗ 否 DateTimeFormatter/ThreadLocal/每次new
DateTimeFormatter(java.time) ✓ 是(不可变) 可放心静态共享
HashMap ✗ 否 ConcurrentHashMap/加锁
ArrayList ✗ 否 CopyOnWriteArrayList/Collections.synchronizedList/加锁
StringBuilder ✗ 否 StringBuffer/局部使用
String/Integer等不可变类 ✓ 是(不可变) 放心共享
AtomicInteger等原子类 ✓ 是 放心用做并发计数

这张表把常见类的线程安全性钉清了。核心规律是:"不可变(immutable)"的类(String、Integer、DateTimeFormatter、java.time 整套)天然线程安全、可放心共享;"有可变状态又没做同步"的类(SimpleDateFormat、HashMap、ArrayList、StringBuilder)非线程安全、不能裸共享;而专门设计的并发类(ConcurrentHashMap、原子类)是线程安全的它给我的最大启发是:判断一个类"线程安不安全",有一个非常有力的线索——看它"有没有可变的共享状态":不可变的对象(创建后状态不再变)天然线程安全(没有可变状态可被并发破坏);有可变状态的对象,除非它内部做了同步,否则就非线程安全这其实再次印证了"不可变性(immutability)"的巨大价值:"不可变"不仅能避免"意外修改"类的 bug,它还天然地解决了线程安全问题——因为一个"创建后就不再改变"的对象,无论多少线程同时读它,都不会有任何冲突(没有"",就没有"读写冲突");这正是为什么 Java 8 的 java.time、以及函数式编程,都如此推崇不可变——不可变是并发安全的一条捷径用"有没有可变共享状态"判断线程安全、并优先选用不可变的类(它天然线程安全)——是写并发代码的一个核心心法。

第五件事:并发 bug 的"幽灵"特性

这次让我深刻领教了并发 bug 那种"幽灵般"的特性。

特性 说明
偶发随机 要恰好并发冲突才出, 概率低、不稳定
难复现 单线程/低并发测不出, 只高并发偶现
和时序强相关 取决于线程调度的微妙时序
现象诡异 结果错乱/偶尔异常, 离根因远
测试难覆盖 常规测试(单线程)根本测不到

这张表道出了并发 bug 的"幽灵"本质。核心是:并发 bug(数据竞争)偶发、随机、难复现、和线程调度时序强相关——它要"恰好两个线程在恰好的时刻冲突"才发作,概率低、不稳定;单线程和低并发(包括绝大多数单元测试)根本测不出,只在高并发生产环境偶现;且现象诡异(结果错乱/偶尔异常)、离根因很远它给我的深刻启发是:并发 bug 是最难发现、最难复现、也最难调试的一类 bug——因为它不确定、不稳定、不可靠地重现,你没法像普通 bug 那样"稳定复现 → 单步调试 → 找到原因";等它在生产偶现时,往往已经造成了影响,且现场难以保留这让我对并发编程有了更深的敬畏:对待并发,最好的策略是"预防"而非"事后调试"——因为它一旦发生就极难调试;预防的关键,是在写代码时就对"共享可变状态"保持高度警觉,主动地避免数据竞争(用不可变对象、线程安全的类、不共享、或正确加锁),而不是寄希望于"测出来再改"(很可能测不出);"并发安全靠设计,而非靠测试"——把正确性建立在"设计上就不可能有数据竞争",远比"靠测试去发现数据竞争"可靠敬畏并发 bug 的幽灵特性、靠"预防性的设计(避免共享可变状态)"而非"事后调试"来保证并发安全——这,是这个 SimpleDateFormat 坑,带给我的关于"如何对待并发"的清醒认知。

第六件事:共享一个对象给多线程前,我现在的判断习惯

现在每当我要把一个对象设成静态共享、或共享给多个线程,我都会按这张图先问一遍:

这张图的精髓,是"共享前先问它有没有可变状态、是不是线程安全类"无状态/不可变的对象放心共享(天然线程安全);有可变状态的,是专门的线程安全类(ConcurrentHashMap/原子类)才放心共享;否则(SimpleDateFormat/HashMap)不能裸共享——换不可变替代、ThreadLocal 每线程一个、每次新建、或加锁这套习惯,让我从"随手把对象设成 static 共享"变成了"共享前先评估它的线程安全性"——核心始终是:共享给多线程前,先问"它有可变状态吗、它线程安全吗";非线程安全的别裸共享。

我立下的几条规矩

这场"静态共享 SimpleDateFormat 高并发日期错乱"的事故,换来了我写 Java 并发时,刻进骨子里的几条铁律:

  1. SimpleDateFormat 非线程安全。内部有共享可变的 Calendar,绝不多线程共享一个实例。
  2. 优先用 java.time 的 DateTimeFormatter。不可变、线程安全,可放心静态共享。
  3. 必须用 SimpleDateFormat 就 ThreadLocal 或每次新建。每线程一个,或局部变量。
  4. 共享对象给多线程前先问"它线程安全吗"。非线程安全的别裸共享。
  5. 不可变的类天然线程安全。优先选不可变,是并发安全的捷径。
  6. HashMap/ArrayList/StringBuilder 都非线程安全。并发场景用并发容器或加锁。
  7. 并发 bug 靠预防(设计),而非靠测试。对共享可变状态保持高度警觉。

附:一个能稳定复现 SimpleDateFormat 错乱的小程序

为了让团队直观看到"共享 SimpleDateFormat 真的会错乱",我写了一个能大概率复现的小程序——多线程并发用同一个 SimpleDateFormat 来回 format/parse,跑一会就会看到错误的日期、甚至 NumberFormatException。

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.*;

public class SdfRaceDemo {
    // ✗ 故意做错: 多线程共享一个 SimpleDateFormat
    private static final SimpleDateFormat SDF =
        new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(20);
        String sample = "2026-06-02 12:00:00";
        for (int i = 0; i < 20; i++) {
            pool.submit(() -> {
                for (int j = 0; j < 100000; j++) {
                    try {
                        Date d = SDF.parse(sample);     // 并发parse
                        String s = SDF.format(d);        // 并发format
                        // 正常应该 s 等于 sample, 错乱时 s 会不对、或这里直接抛异常
                        if (!sample.equals(s)) {
                            System.out.println("错乱! got=" + s);
                        }
                    } catch (Exception e) {
                        // 并发下常见: NumberFormatException, ArrayIndexOutOfBounds 等
                        System.out.println("抛异常: " + e);
                    }
                }
            });
        }
        pool.shutdown();
        pool.awaitTermination(1, TimeUnit.MINUTES);
        // → 跑起来很快就会刷出一堆"错乱"和"抛异常";
        //   把 SDF 换成 ThreadLocal 或 DateTimeFormatter, 就一条都不会出。
    }
}
// 这个demo的价值: 把"偶发难复现"的并发bug, 用"高并发+海量循环"放大成"几乎必现",
//   让团队亲眼看到共享SimpleDateFormat的危害, 比讲十遍道理都管用。

这个能复现的小 demo,价值在于"把偶发变必现、把抽象变直观"。并发 bug 平时"偶发难复现",但你可以用"高并发(20 线程)+ 海量循环(每线程十万次)"去放大冲突概率,把"偶发"逼成"几乎必现"——跑起来很快就刷出一堆"错乱"和"抛异常"(NumberFormatException 等);而把共享的 SDF 换成 ThreadLocal 或 DateTimeFormatter,就一条都不会出。它给我的启发是:对付"偶发的并发 bug",一个有力的手段是写一个"放大并发压力"的复现程序(高并发 + 大循环 + 断言),主动地、人为地把那个偶发的竞争"逼出来";"能稳定复现",是搞定一个 bug 的一半——尤其对并发 bug,能复现本身就是巨大的胜利而且,这种"错误演示 + 正确对照"的小 demo,是团队里传播并发知识最有效的方式——让人亲眼看到"共享 SimpleDateFormat 真的会错乱",远比口头讲十遍"它不安全"都管用、都印象深刻用"放大压力"主动复现偶发并发 bug、用"错误对照正确"的 demo 传播并发安全意识——是这个坑教我的、对付并发问题的两个实用招法。

写在最后

回头看,这场由"静态共享一个 SimpleDateFormat"引发的、高并发下偶发日期错乱的事故,真正教给我的,远不止"SimpleDateFormat 非线程安全、要用 DateTimeFormatter"这一个知识点。它让我对"共享可变状态,是并发问题的总根源",有了一次刻骨的体会。我栽跟头,是因为我把一个"有可变状态、且非线程安全"的对象(SimpleDateFormat 内部藏着一个可变的 Calendar),当成"无状态、可放心共享"的工具,设成了 static final 给所有线程共享。我看它"名字像个工具类、用法像个纯函数(传日期进去、出字符串)",就下意识以为它是无状态的、线程安全的——却没意识到它内部偷偷地用一个可变的成员变量(Calendar)暂存中间状态;于是多个线程并发调用时,这个共享的可变状态被你写我也写,互相覆盖错乱。问题的根,不是 SimpleDateFormat 这一个类,而是"多个线程,并发地访问一份共享的可变状态,且没有同步"——这,就是数据竞争(data race),是几乎一切并发 bug 的总根源。这让我领悟到一个理解并发的核心认知:并发编程的绝大多数问题,都可以归结到一句话——"共享的、可变的状态,被并发访问,而没有正确同步";这三个要素(共享可变并发无同步)缺一不可,凑齐就出数据竞争;反过来,要写并发安全的代码,就是要打破这三者中的至少一个:要么不共享(ThreadLocal/局部变量)、要么不可变(用 immutable 的 DateTimeFormatter)、要么加同步(锁/原子类/并发容器)这给了我一个分析任何并发问题的统一框架:遇到并发 bug,就去找那个"被并发访问的、共享的、可变的状态"(本文是那个共享的 SimpleDateFormat 及其内部 Calendar);找到它,然后从"不共享 / 不可变 / 加同步"里挑一个去打破——这,几乎是解决一切并发安全问题的通用思路;而其中"不可变"往往是最优雅的一条(没有可变状态,就根本没有竞争)。认清"共享可变状态被并发无同步访问 = 数据竞争"这个总根源、用"不共享/不可变/加同步"三选一去破解——这,是我用一次 SimpleDateFormat 错乱的事故,换来的、关于 Java 并发、也关于如何系统地思考一切并发安全问题的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次想把一个对象设成 static 共享时,先问一句"它有可变状态吗、它线程安全吗",那我对着那串偶发错乱的日期排查的这大半天,就值了。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我在循环里用 defer 关闭打开的文件,以为每次循环结束就关了,结果它们全堆到函数返回才一起关,跑到一半就 too many open files,我对着 defer 在函数返回时才执行这个坑排查大半天的复盘

2026-6-2 13:33:30

技术教程

一个 varchar 手机号字段被用数字去查,MySQL 偷偷做隐式类型转换让索引彻底失效:一次慢查询拖垮数据库的深度排查与类型对齐正解

2026-6-2 13:48:12

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索