这个坑最阴险的地方在于:报错是间歇性的,日志要么抛一个
NumberFormatException: multiple points,要么不报错、静默返回一个错得离谱的日期(比如把 2016 解析成 2200 或 0002 年)。既不像异常那样好发现,又不像 bug 那样好复现,只在并发压力下才露头。
Issue & Env(案发现场与环境)
- 现象:
SimpleDateFormat作为static final共享实例,多线程下偶发解析失败或日期串台,错误断断续续。 - 环境:Java 8 之前(尤其常用老
SimpleDateFormat的场景),或仍沿用SimpleDateFormat的存量代码。
StackTrace(堆栈日志)
标准报错是数字解析失败:
java.lang.NumberFormatException: multiple points
at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1110)
at java.lang.Double.parseDouble(Double.java:540)
at java.text.DigitList.getDouble(DigitList.java:168)
at java.text.DecimalFormat.parse(DecimalFormat.java:1321)
at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:2088)
at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1455)
也有 For input string: ""、For input string: ".11E.111E" 这类,偶尔还会静默返回错误的年份。
Reproduce(复现代码)
共享一个静态实例,多线程同时 parse:
public class Broken {
private static final SimpleDateFormat FORMAT =
new SimpleDateFormat("yyyy-MM-dd");
public static void main(String[] args) {
try (ExecutorService pool = Executors.newFixedThreadPool(16)) {
for (int i = 0; i < 1000; i++) {
pool.submit(() -> {
Date d = FORMAT.parse("2026-03-14");
if (!"2026-03-14".equals(FORMAT.format(d))) {
System.out.println("wrong: " + FORMAT.format(d));
}
return null;
});
}
}
}
}
典型输出里会冒出 wrong: 0002-01-14、wrong: 2226-03-14 和散落的 NumberFormatException。单线程跑永远正常,多核机器上却时好时坏。
Root Cause(根因分析)
SimpleDateFormat 继承了 DateFormat,内部持有一个 Calendar 字段,作为格式化和解析时的「草稿纸」。解析时它按位把年、月、日写进这个 Calendar,最后再读出来组装结果。
关键在于这份草稿是共享的。线程 A 刚把年份设成 2026,线程 B 插进来把年份覆盖成自己输入里的 2226,线程 A 再回头读日字段,拼出来的就是一个「A 的年 + B 的日」的荒谬组合。没有任何单次写入是坏的,坏的是交叉写入,所以错误既静默又随机。
NumberFormatException: multiple points 同源:解析过程中维护了一个内部游标,两个线程交错推进让游标和缓冲区错位,数字解析就炸了。format 用的是同一个 Calendar,也一样不安全;parse 和 format 混在一个实例上更糟。
The Fix(解决方案)
新代码和能改的代码,直接用 java.time:DateTimeFormatter 不可变、线程安全,一个 static final 实例全应用共用,既无同步也无每次分配的额外开销:
private static final DateTimeFormatter FORMAT =
DateTimeFormatter.ofPattern("uuuu-MM-dd");
LocalDate parsed = LocalDate.parse("2026-03-14", FORMAT);
String text = FORMAT.format(parsed);
存量代码不能换类型时,三种降级方式按成本递进:
每次调用新建实例,最简单也最正确:
private static String format(Date date) {
return new SimpleDateFormat("yyyy-MM-dd").format(date);
}
或者用 ThreadLocal 每个线程一份,但要注意它在容器线程池里「实例和线程同寿命」,容易间接造成 classloader 泄漏,用完记得 remove();在 Java 21 虚拟线程下每个任务一个实例,反而不划算。
总之,SimpleDateFormat 唯一「错」的用法就是让它 static 共享,绕开共享这一步,所有诡异现象都消失。
