2026-01-31。
这个日期不是我要的。
我要的是 1 月 1 日,或者 12 月 31 日。跑出来的是 1 月 31 日。月和日各取了一半,拼在一起。
代码里就一行:
privatestaticfinalSimpleDateFormatSDF=newSimpleDateFormat("yyyy-MM-dd HH:mm:ss");8 个线程,各格式化 2 万次。
第一遍跑完,错 98781 次。
我盯着这个数看了一会儿。98781,挺整齐。心里有点不踏实,又跑了一遍。
87187 次。
同一个 class 文件,同一个 JVM,同一台机器,中间隔了不到一分钟。错误次数自己都不稳定,这事儿就有点玄了。
说白了,这不是某个边界条件没处理,是个概率问题。线程抢得越乱,错得多还是错得少,全看当时调度。
先怀疑时区
看到日期串了,第一反应是时区。
以前被时区坑过,yyyy-MM-dd和YYYY-MM-dd差一个字母,跨年那一周直接错一年。所以我先回头翻代码,看有没有TimeZone.setDefault。
没有。
再看 JVM 参数,没有-Duser.timezone。容器里的时区是Asia/Shanghai,代码里没动过。
同一台机器,同一个 JVM,同一个时区,时区解释不了"1 月 31 日"这种日期。时区错了会差 8 小时,会跨天,但不会把 1 月 1 日和 12 月 31 日拼成 1 月 31 日。
排除。
再怀疑 Date 被改了
第二个想法:是不是Date对象在传的时候被谁改了。
Date是可变的,setTime()一调就变。如果多个线程共用一个Date,确实会出各种怪事。
但我这个测试里,两个Date是提前 parse 好的常量,每个线程从头到尾只读,一次setTime都没有。
而且真要是Date被改,错出来的应该是另一个完整日期,不会出现"1 月 31 日"这种两头拼的。
排除。
剩下就是那个 static
代码里能被多个线程同时碰的,只剩SDF这一个对象。
去看SimpleDateFormat的源码。它继承DateFormat,DateFormat里有个成员变量:
protectedCalendarcalendar;format()干的事,是把传进来的Date写进这个calendar,再从calendar里把年月日时分秒一个一个读出来,拼成字符串。
写和读之间,隔着好几行代码。
两个线程同时进来,A 刚把calendar设成 1 月 1 日,还没读完,B 把它改成了 12 月 31 日。A 接着往下读,读到的月份是 12,日子还是 1。
拼出来就是 1 月 31 日。
这不是"数据被覆盖"那么简单,是一个对象的中间状态被另一个线程看见了。calendar这个字段,就是那个中间状态。
javadoc 里其实写了这句话:
Date formats are not synchronized. It is recommended to create separate format instances for each thread.
这段英文我很多年前就看过。当时扫一眼,觉得是"性能建议",没往心里去。
它不是性能建议。
四组对照,跑给你们看
光讲原理没意思,我索性写了四组,同一台机器连着跑:
A 单线程 + static SimpleDateFormat : 错误 0 B 8线程 + static SimpleDateFormat : 错误 87187 C 8线程 + ThreadLocal : 错误 0 D 8线程 + DateTimeFormatter : 错误 0 线程数=8 每线程=20000 单次总调用=160000 JDK=1.8.0_211A 和 B 只差一个"单线程/多线程",一个 0 错,一个 8 万多错。
这就是为什么本地测不出来。你写单测,一个线程跑一万遍,全是绿的。压测一上,开始出错。等线上发现,错的数据已经进库了。
C 和 D 都是 0。格式化这件事本身没问题,问题出在把一个可变对象交给多个线程共用。
最容易漏的一点
它不抛异常。
如果是NullPointerException,上线当天就炸了,日志一堆,谁都知道出事了。这个不炸。
它返回的字符串长度对、格式对、能存库、能展示、能进对账单。2026-01-31 00:00:00,看起来完全正常。
你只能靠"这个日期不该出现在这里"去发现它。而大部分系统,没有人会去核对每一条日期。
我把Date换成String存的时候还想过,格式化这种东西能出什么错。现在知道了,它出的错比抛异常难查十倍。
三行改掉
新代码,直接换DateTimeFormatter:
privatestaticfinalDateTimeFormatterFMT=DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");不可变,天生线程安全,不用 ThreadLocal,也不用每次 new。
老代码如果一时半会儿动不了,ThreadLocal兜一下:
privatestaticfinalThreadLocal<SimpleDateFormat>TL=ThreadLocal.withInitial(()->newSimpleDateFormat("yyyy-MM-dd HH:mm:ss"));注意remove()。线程池里线程是复用的,不 remove 就一直挂着,占着那个SimpleDateFormat不放。这个坑我踩过,反正内存就是涨,涨得莫名其妙,翻半天堆也看不出问题。
还有两个类
搜的时候别只搜SimpleDateFormat。
DecimalFormat一模一样,内部也是可变状态,也是线程不安全,也是不报错只算错。金额算错一分钱,对账的时候能查一天。
Calendar也是。它本身就是可变的,多线程共用照样出问题。
这三个类我在同一个项目里都见过,全是static final,全是"常量嘛,复用嘛"。
搜一下你们代码里有多少个static final SimpleDateFormat。搜出来先别急着改,数一下,再想想这些格式化每天跑多少次。