高并发下日期解析错乱:SimpleDateFormat 避坑复盘

这是一个典型的单机测试一切正常一上高并发就抽风的诡异 bug。我们一个接口会把字符串形式的日期解析成日期对象,平时跑得好好的,可一到流量高峰就开始偶发性地报错——有时抛 NumberFormatException,有时解析出一个莫名其妙根本不对的日期。最折磨人的是这个错是偶发的,绝大多数请求都正常只在并发高的时候零星出现几次,而我在本地单线程一遍遍测试同样的日期字符串怎么都复现不出来。排查到最后真凶让我对一个用了无数次的老朋友彻底改观:罪魁祸首是一个我们为了复用省得每次都 new 而定义成静态共享变量的 SimpleDateFormat。原来 SimpleDateFormat 这个 Java 里最常用的日期格式化类根本不是线程安全的,它内部维护着一个可变的 Calendar 对象作为中间状态,当多个线程并发用同一个实例 parse 或 format 时它们会同时读写这个共享的可变状态互相踩踏,于是出现解析出错抛异常或得到完全错误的日期。这篇文章从这次共享 SimpleDateFormat 导致并发解析错乱的事故出发,讲透 Java 并发共享避坑:理解线程安全与共享的前提、首选不可变线程安全的 DateTimeFormatter、必须用 SimpleDateFormat 时用 ThreadLocal 或每次新建、HashMap 等其它老朋友也不安全、如何系统揪出并发共享隐患,以及一个根本认知——并发的一切麻烦都源于多线程共享了可变状态,无共享和不可变是化解它的两大法宝。

这是一个典型的"单机测试一切正常,一上高并发就抽风"的诡异 bug。我们一个接口,会把字符串形式的日期解析成日期对象,平时跑得好好的;可一到流量高峰,就开始偶发性地报错——有时抛 NumberFormatException,有时解析出一个莫名其妙、根本不对的日期(比如把 "2024-01-15" 解析成了某个八竿子打不着的时间)。最折磨人的是,这个错是"偶发"的:绝大多数请求都正常,只在并发高的时候零星出现几次;而我在本地单线程一遍遍地测试同样的日期字符串,怎么都复现不出来。

排查到最后,真凶让我对一个用了无数次的"老朋友"彻底改观:罪魁祸首,是一个我们为了"复用、省得每次都 new"而定义成静态共享变量SimpleDateFormat。原来,SimpleDateFormat 这个 Java 里最常用的日期格式化类,根本不是线程安全的!它内部维护着一个可变的 Calendar 对象作为解析/格式化时的中间状态;当多个线程并发地用同一个 SimpleDateFormat 实例去 parse 或 format 时,它们会同时读写这个共享的、可变的内部状态,互相踩踏——于是就出现了解析出错、抛异常、或得到完全错误的日期。这篇文章,就从这次"共享 SimpleDateFormat 导致并发解析错乱"的事故讲起,聊聊 Java 并发里一个被无数人忽视、却极其高发的坑:把一个"非线程安全"的对象,当成"可以共享"的对象用了。

故障现场:一个被多线程"踩踏"的格式化器

先把那段闯祸的代码还原一下:

public class DateUtil {
    // 祸根: 定义成 static 静态共享, 想着"复用一个实例, 省得每次 new"
    private static final SimpleDateFormat SDF =
        new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    // 高并发下, 多个线程同时调用这个方法, 共用上面那一个 SDF 实例
    public static Date parse(String s) throws ParseException {
        return SDF.parse(s);   // ← 多线程并发 parse 同一个实例, 内部状态被踩踏!
    }
}
// 单线程测: 永远正常(没人和它抢)
// 高并发: 偶发 NumberFormatException / 解析出错误的日期

看出问题了吗?我们把 SimpleDateFormat 定义成了 static final 的静态共享实例——出发点是好的:"创建 SimpleDateFormat 有点开销,定义一个共享的、大家复用,不是更高效吗?"这个想法对于线程安全的对象是成立的,可惜 SimpleDateFormat 偏偏不是。它在 parse/format 的过程中,会把中间结果暂存在它内部的一个 Calendar 成员变量里;当线程 A 正解析到一半、把中间状态写进了那个共享的 Calendar,线程 B 也冲进来开始解析、覆盖了 Calendar 的状态——A 回过头来再用 Calendar 时,拿到的已经是被 B 污染过的脏数据了。两个线程的解析过程交织在一起、互相踩踏,结果自然就是抛异常、或解析出张冠李戴的错误日期。

这完美解释了那两个诡异现象:为什么"偶发"?因为只有当两个线程"恰好同时"在用这个共享实例时才会踩踏,并发越高、撞上的概率越大,所以表现为高峰期零星出现。为什么"单线程复现不了"?因为单线程下,从来只有一个线程在用它,没有任何人来踩踏它的内部状态,自然永远正常。这种"低并发隐身、高并发暴雷""测试环境正常、生产环境抽风"的特征,正是几乎所有"线程安全"类 bug 的共同指纹。

第一件事:理解"线程安全"——共享的前提是无状态或同步

要避开这类坑,核心是建立一个意识:一个对象能不能被多个线程"共享"使用,取决于它是不是"线程安全"的;而很多你以为可以随便共享的常用对象,其实并不线程安全。什么样的对象不线程安全?简单说,那些内部持有"可变状态"、又没有做同步保护的对象。SimpleDateFormat 正是如此——它内部那个可变的 Calendar,就是它的"软肋"。

// 为什么 SimpleDateFormat 不安全? 因为它 parse 时会改内部的可变状态:
// SimpleDateFormat.parse() 内部大致:
//   calendar.clear();              // 操作内部共享的 Calendar
//   ... 一通解析, 不断改 calendar ...
//   return calendar.getTime();     // 从被改了一路的 calendar 取结果
// 多线程同时进来, 都在改同一个 calendar → 状态错乱

// 判断一个对象能否安全共享, 核心问一句:
// 它有没有"在方法执行过程中被修改的、且被多线程共享的"可变状态?
//   有 → 不能裸共享 (要么别共享, 要么加同步)
//   无(无状态 / 不可变) → 可以安全共享

关键认知是:"能否被多线程安全共享",是一个对象的重要属性,而它取决于这个对象有没有"会在使用过程中被修改的、共享的可变状态"。无状态的对象(每次调用不依赖、也不修改任何成员变量)、或不可变的对象(创建后状态再也不变),天生就是线程安全的,可以放心共享;而像 SimpleDateFormat 这种"使用过程中会修改自己内部可变状态"的对象,就是非线程安全的,绝不能在多线程间裸共享。所以,在把任何一个对象定义成"静态共享"或"在多线程间传递"之前,都要先问一句:它线程安全吗?它内部有没有可变状态会被并发踩踏?——这个意识,是写并发代码的一道基本防线。我那次,恰恰就是缺了这道防线,想当然地共享了一个不该共享的东西。

第二件事:正解——首选 java.time 的 DateTimeFormatter

知道了 SimpleDateFormat 不安全,怎么修?有几种办法,但我最推荐的是釜底抽薪——换掉它,改用 Java 8 引入的 java.time 包里的 DateTimeFormatter。后者是不可变的、天生线程安全的,可以放心地定义成静态共享实例。

// 正解(首选): 用 java.time 的 DateTimeFormatter, 它不可变、线程安全
public class DateUtil {
    // 可以放心 static 共享! DateTimeFormatter 是不可变、线程安全的
    private static final DateTimeFormatter FMT =
        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    public static LocalDateTime parse(String s) {
        return LocalDateTime.parse(s, FMT);   // 多线程并发调用, 完全安全
    }
    public static String format(LocalDateTime t) {
        return t.format(FMT);
    }
}

为什么 DateTimeFormatter 就安全?因为它的设计是不可变(immutable)的——它在解析/格式化时,不会把中间状态存到自己身上,而是用方法的局部变量来处理,处理完就完了,自己的内部状态从头到尾都不变。一个状态永不改变的对象,无论多少线程同时用它,都不可能产生"互相踩踏状态"的问题,所以它天然就是线程安全的、可以放心共享。这其实揭示了一个并发编程里极其重要的设计思想:不可变(immutability)是天然的线程安全。只要一个对象创建后状态就再也不变,它就能在任意多个线程间被自由共享,完全不用加锁——这是应对并发最优雅、最省心的一招。java.time 整个包(LocalDateTimeDateTimeFormatter 等)都是按"不可变"设计的,所以它不仅 API 更现代好用,在并发安全上也比老的 Date/SimpleDateFormat 高出一个段位。所以,新代码处理日期时间,首选 java.time 包,别再用老的 DateSimpleDateFormat 了。

第三件事:必须用 SimpleDateFormat 时,怎么安全用

如果你在维护老代码、一时换不掉 SimpleDateFormat,那也有几种让它"安全使用"的办法。核心思路是:要么不共享(每次用都新建一个),要么虽共享但加同步,要么用 ThreadLocal 给每个线程一份独享的。

// 方案1: 每次用都新建局部变量 —— 简单, 但有创建开销
public static Date parse(String s) throws ParseException {
    return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(s);  // 不共享, 安全
}

// 方案2: 共享实例 + synchronized 加锁 —— 安全, 但并发高时锁会成瓶颈
private static final SimpleDateFormat SDF = new SimpleDateFormat("...");
public static synchronized Date parse(String s) throws ParseException {
    return SDF.parse(s);   // 同一时刻只有一个线程能进, 串行化, 安全但慢
}

// 方案3(推荐, 若必须用 SDF): ThreadLocal, 每个线程一份独享, 互不干扰
private static final ThreadLocal SDF_TL =
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
public static Date parse(String s) throws ParseException {
    return SDF_TL.get().parse(s);   // 每个线程拿到的是自己专属的实例, 无竞争
}

这三种方案各有取舍:方案一(每次 new)最简单、绝对安全,缺点是每次都创建对象有一点开销(但现代 JVM 下其实很轻,绝大多数场景这点开销完全可以忽略,所以这往往是最务实的选择);方案二(共享 + synchronized)保留了单实例,但用锁把并发访问串行化了——安全,可一旦并发高,这把锁就会成为性能瓶颈,大家都堵在这儿排队;方案三(ThreadLocal)是兼顾"复用"和"安全"的好办法:它给每个线程都准备一份自己专属的 SimpleDateFormat 实例,线程之间用的是不同的实例,自然不会互相踩踏,既避免了频繁创建、又没有锁竞争。如果实在要用 SimpleDateFormat,ThreadLocal 是兼顾安全与性能的推荐方案;但若能换,还是首选不可变的 DateTimeFormatter,从根上免除这些麻烦。我把这些方案画成一张选择图:

这张图的首选路径,始终是最上面那条:能用 DateTimeFormatter 就用它,不可变带来的线程安全是最省心的。实在要用 SimpleDateFormat,则根据并发情况在"每次新建""ThreadLocal""加锁"之间选一个。但无论哪条,前提都是你意识到了 SimpleDateFormat 非线程安全——这个意识,才是避开这一切的起点。

第四件事:不止 SimpleDateFormat——这些"老朋友"也不安全

这次事故后,我警觉起来,把代码库里那些"被当成共享实例"的常用类都排查了一遍,发现 SimpleDateFormat 绝不是孤例——Java 里有好几个我们天天用的类,都不是线程安全的,稍不留神就会踩同样的坑。

// Java 里几个常见的"非线程安全"类(别当共享实例多线程裸用):
// 1. SimpleDateFormat —— 本次主角
// 2. HashMap —— 并发 put 可能丢数据, 极端情况(旧版)还可能死循环
//    多线程要用: ConcurrentHashMap
Map safeMap = new ConcurrentHashMap<>();
// 3. ArrayList / LinkedList —— 并发 add 会丢数据或抛异常
//    多线程要用: CopyOnWriteArrayList 或 Collections.synchronizedList
// 4. StringBuilder —— 非线程安全(性能好); 多线程拼接用 StringBuffer(带锁)
// 5. SimpleDateFormat 的近亲: 老的 Calendar 也不安全

// 而这些是线程安全的, 可放心共享:
// ConcurrentHashMap, CopyOnWriteArrayList, StringBuffer,
// java.time.* (LocalDateTime/DateTimeFormatter), AtomicInteger, 以及一切不可变对象

这里有个特别值得一提的对比:HashMapConcurrentHashMapArrayListCopyOnWriteArrayListStringBuilderStringBuffer——每一对里,前者是"非线程安全但性能更好"的版本,后者是"线程安全"的版本。Java 这么设计,是把"要不要线程安全"的选择权交给了你:单线程下,用前者(无锁,更快);多线程共享时,用后者(有同步保护,安全)。很多人的坑,就在于不假思索地在多线程环境里用了那个"性能版"(HashMapArrayList),却忘了它不安全。把常用类的线程安全性整理成一张对照表,你心里要有这张表:

用途 非线程安全(单线程用) 线程安全(多线程共享用)
日期格式化 SimpleDateFormat DateTimeFormatter(不可变)
哈希表 HashMap ConcurrentHashMap
列表 ArrayList CopyOnWriteArrayList
字符串拼接 StringBuilder StringBuffer
计数器 普通 int/long(++ 非原子) AtomicInteger / AtomicLong
日期时间对象 Date / Calendar(可变) LocalDateTime 等(不可变)

第五件事:怎么系统性地揪出"并发共享"隐患

知道了有这些坑,光靠"记住"是不够的——还得有系统的办法去揪出代码里"把非线程安全对象当共享用"的隐患。我总结了几条排查的着眼点:

排查信号 为什么可疑 怎么处理
static 的可变对象字段 静态=全局共享, 多线程必然并发访问 确认它线程安全, 否则改造
Web 单例 Bean 里的可变成员 Spring 单例被多请求线程共享 可变状态要么移除要么同步
SimpleDateFormat/HashMap 当字段 典型的非安全类被共享 换安全版 / ThreadLocal / 局部变量
"偶发、高峰期、测试不复现"的bug 并发问题的典型指纹 重点查共享可变状态
i++ / 复合操作在并发下 看似一步实则非原子, 会丢更新 用 Atomic 类或加锁

这张表里,我想重点强调前两条,它们是"非安全对象被共享"最高发的两个藏身处:一是 static 静态字段——静态意味着全局唯一、被所有线程共享,所以任何一个可变的静态字段,都天然处在多线程并发访问之下,必须是线程安全的(我那次的坑正是 static 的 SimpleDateFormat)。二是 Web 框架里的单例 Bean——像 Spring 的 @Service@Controller 默认都是单例的,一个实例会被所有请求线程共享;所以如果你在这些单例 Bean 里定义了"可变的成员变量"(比如一个成员 SimpleDateFormat、或一个成员 List 用来暂存数据),那它就会被多个请求线程并发读写,极易出问题。一条铁律:单例(无论是 static 还是框架的单例 Bean)+ 可变成员状态 = 并发隐患。要么让它无状态(别在单例里放可变成员),要么确保那个成员是线程安全的。把这两个高发处盯紧,能帮你揪出绝大多数这类隐患。

一张"这个对象能不能多线程共享"的决策图

把这次踩坑沉淀成一张图。每当你要把一个对象定义成 static、放进单例 Bean、或在多线程间共享时,照着它问一遍:

这张图的判断主线是两层:第一问"有没有可变状态"——无状态或不可变的,天然安全,放心共享(这是最理想的);第二问"它自身线程安全吗"——有可变状态但内部做了同步(如 ConcurrentHashMap)的,也能共享;而既有可变状态、又没做同步的(如 SimpleDateFormat、HashMap),就绝不能裸共享,得换安全类、或用 ThreadLocal、或干脆每次新建。把这张图过一遍成为习惯,"共享了不该共享的东西"这类并发坑,就能在编码时被你拦下。

我立下的几条并发安全规矩

这次"SimpleDateFormat 并发错乱"的事故后,团队的规范里加了这么几条:

  1. 共享前先问线程安全:任何对象在定义成 static、放进单例 Bean、或跨线程共享前,先确认它是否线程安全。
  2. 日期格式化首选 DateTimeFormatter:新代码用 java.time(不可变、线程安全),别用 SimpleDateFormat;非用不可时用 ThreadLocal 或每次新建。
  3. 多线程容器用并发版:多线程共享的集合用 ConcurrentHashMap、CopyOnWriteArrayList,别裸用 HashMap、ArrayList。
  4. 优先无状态/不可变设计:能设计成无状态或不可变的就尽量这么做,它是最省心的线程安全。
  5. 单例里别放可变成员:static 单例和框架单例 Bean 里,避免放可变的成员状态;要放就确保它线程安全。
  6. 偶发并发bug先查共享状态:遇到"偶发、高峰期才有、测试不复现"的 bug,重点排查有没有被并发踩踏的共享可变状态。
  7. 关键路径做并发测试:对会被高并发访问的代码,写多线程并发测试,把这类隐患在上线前逼出来。

这几条里,第四条"优先无状态/不可变设计"是我觉得境界最高、也最值得追求的一条。回头看这次的坑,根源在于 SimpleDateFormat "有可变状态";而正解 DateTimeFormatter 之所以能轻松线程安全,正因为它"不可变、无可变状态"。这揭示了应对并发的一条根本智慧:并发问题的本质,是"多个线程对共享的可变状态的争抢";而釜底抽薪的办法,不是费劲去给这个争抢加锁、协调,而是从源头消灭"可变的共享状态"——要么让状态不共享(每个线程一份),要么让状态不可变(谁也改不了,自然无从争抢)。"无共享"和"不可变",是化解并发复杂性的两大法宝。一个尽量用无状态、不可变设计的系统,天然就回避了大量并发的麻烦——这远比在到处都是可变共享状态的代码里,辛辛苦苦地加锁、调试死锁,要高明、也省心得多。

写在最后:并发,是对"想当然"最严厉的审判

这次被 SimpleDateFormat 坑到的经历,让我对"并发编程"这件事,生出了一种深深的敬畏。在写并发代码之前,我们脑子里默认的模型,几乎都是"单线程"的——我们想当然地以为,代码会像我们阅读它那样,一行一行、从上到下、有条不紊地执行;我们想当然地以为,我创建的一个对象、我复用的一个实例,在被使用时是"独占"的、不会被别人打扰的。这些在单线程世界里天经地义的假设,到了多线程的世界里,几乎全部失效了——而并发 bug,正是这些失效的假设,一个个暴露出来的恶果。我那次"复用一个 SimpleDateFormat 实例"的想当然,在单线程里完全成立,可一旦多个线程同时涌进来,这个"独占"的假设就碎了,bug 就来了。

想通这一点,我意识到:并发编程之所以难,难就难在它彻底颠覆了我们根深蒂固的"单线程直觉";它逼着我们从"只有我一个执行流"的舒适假设,切换到"随时可能有好多个执行流,在我意想不到的时刻,同时闯进同一段代码、同一个对象"的警觉之中。这是一种思维方式的根本转变,而转变的核心,就是要时刻对"共享"和"可变"这两个词保持高度敏感——因为并发的一切麻烦,几乎都源于"多个线程,共享了某个可变的东西"。每当代码里出现"共享 + 可变"这个组合,你就该亮起红灯,问一句:这里安全吗?会不会被并发踩踏?

所以,如果你也在写会跑在多线程下的代码(在今天,几乎所有后端服务都是),我想把这次踩坑最想说的话送给你:请彻底放下"单线程"的想当然,带着"随时有人和我抢"的警觉去写每一行可能并发的代码。尤其要对那些"被共享的可变状态"——static 字段、单例 Bean 的成员、跨线程传递的对象——保持最高的敏感:它线程安全吗?不安全的话,我是该换个安全的、还是该让它不共享、还是该让它不可变?把"共享了不该共享的、可变了不该可变的"这根弦时刻绷紧,你就避开了并发世界里最大的一类陷阱。那个在高峰期偶发抽风的日期解析,最终用一连串让人摸不着头脑的诡异错误,教会了我对并发最朴素也最重要的敬畏:在多线程的世界里,凡是"想当然"的地方,迟早都会被它无情地审判;唯有时刻警觉于"共享"与"可变",你才能在这片暗流涌动的水域里,行得安稳。愿你我的并发代码,都经得起高峰期那千军万马的考验。

把对"共享"与"可变"的这份警觉,变成写每一行并发代码时的本能,你离一个成熟的后端工程师,就又近了一步。

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

err 是 nil 却不等于 nil:Go 接口 nil 避坑复盘

2026-6-1 13:42:56

技术教程

翻页越翻越慢直到超时:深分页避坑复盘

2026-6-1 13:52:51

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