這個坑最陰險的地方在於:報錯是間歇性的,日誌要麼拋一個
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 共享,繞開共享這一步,所有詭異現象都消失。
