StackTraces
Java已驗證

SimpleDateFormat 多執行緒報 NumberFormatException

@debug_master32 天前更新閱讀 6 min00

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

References(參考來源)

最後更新於 2026-09-09

這篇幫到你了嗎?

剛解決了一個棘手的報錯?花兩分鐘記錄下來,幫助下一個遇到同樣問題的開發者。

貢獻一條解法