糟糕,被SimpleDateFormat坑到啦!| 京東雲技術團隊

發表於2024-02-18

1. 問題背景

問題的背景是這樣的,在最近需求開發中遇到需要將給定目標資料透過某一固定的計量規則進行過濾並打標生成明細資料,其中發現存在一筆目標資料的時間在不符合現有日期規則的條件下,還是透過了規則引擎的匹配打標操作。故而需要對該錯誤匹配場景進行排查,定位根本原因所在。

2. 排查思路

2.1 資料定位

在開始排查問題之初,先假定現有的Aviator規則引擎能夠對現有的資料進行正常的匹配打標,查詢在存在問題資料(圖中紅框所示)同一時刻進行規則匹配時的資料都有哪些。發現存在五筆資料在同一時刻進行規則匹配落庫。

繼續查詢具體的匹配規則表示式,發現針對loanPayTime時間區間在[2022-07-16 00:00:00, 2023-05-11 23:59:59]的範圍內進行匹配,目標資料的時間為2023-09-19 11:27:29,理論上應該不會被匹配到。

但是觀測匹配打標的明細資料發現確實打標成功了(如紅框所示)。

所以重新回到最初的和目標資料同時落庫的五筆資料發現,這五筆資料的loanPayTime時間確實在規則[2022-07-16 00:00:00, 2023-05-11 23:59:59]之內,所以在想有沒有可能是在目標資料匹配規則引擎,其它的五筆資料中的其中一筆對該資料進行了修改導致誤匹配到了這個規則。順著這個思路,首先需要確認下Aviator規則引擎在併發場景下是否執行緒安全的。

2.2 規則引擎

由於在需求中使用到用於給資料匹配打標的是Aviator規則引擎,所以第一直覺是懷疑Aviator規則引擎在併發的場景中可能會存在執行緒不安全的情況。

首先簡單介紹下Aviator規則引擎是什麼,Aviator是一個高效能的、輕量級的java語言實現的表示式求值引擎,主要用於各種表示式的動態求值,相較於其它的開源可用的規則引擎而言,Aviator的設計目標是輕量級高效能 ,相比於Groovy、JRuby的笨重,Aviator非常小,加上依賴包也才450K,不算依賴包的話只有70K;

當然,Aviator的語法是受限的,它不是一門完整的語言,而只是語言的一小部分集合。其次,Aviator的實現思路與其他輕量級的求值器很不相同,其他求值器一般都是透過解釋的方式執行,而Aviator則是直接將表示式編譯成Java位元組碼,交給JVM去執行。簡單來說,Aviator的定位是介於Groovy這樣的重量級指令碼語言和IKExpression這樣的輕量級表示式引擎之間。(具體Aviator的相關介紹不是本文的重點,具體可參見

透過查閱相關資料發現,Aviator中的AviatorEvaluator.execute() 方法本身是執行緒安全的,也就是說只要表示式執行邏輯和傳入的env是執行緒安全的,理論上是不會出現併發場景下執行緒不安全問題的。(詳見

2.3 匹配規則引擎的env

透過前面Aviator的相關資料發現傳入的env如果在多執行緒場景下不安全也會導致最終的結果是錯誤的,故而定位使用的env發現使用的是HashMap,該集合類確實是執行緒不安全的(具體可詳見),但是執行緒不安全的前提是多個執行緒同時對其進行修改,定位程式碼發現在每次呼叫方式時都會重新生成一個HashMap,故而應該不會是由於這個執行緒不安全類導致的。

繼續定位發現,loanPayTime這個欄位在進行Aviator規則引擎匹配前使用SimpleDateFormat進行了格式化,所以有可能是由於該類的執行緒不安全導致的資料錯亂問題,但是這個類應該只是對日期進行格式化處理,難不成還能影響最終的資料。帶著這個疑問查詢資料發現,emm確實是執行緒不安全的。

好傢伙,嫌疑物件目前已經有了,現在就是尋找相關證據來佐證了。

3. SimpleDateFormat 還能執行緒不安全?

3.1 先寫個demo試試

話不多說,直接去測試一下在併發場景下,SimpleDateFormat類會不會對需要格式化的日期進行錯亂格式化。先模擬一個場景,對多執行緒併發場景下格式化日期,即在[0,9]的資料範圍內,在偶數情況下對2024年1月23日進行格式化,在奇數情況下對2024年1月22日進行格式化,然後觀測日誌列印效果。

import java.text.SimpleDateFormat;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class ThreadSafeDateFormatDemo {
    static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");


    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(10);
        LocalDateTime startDateTime = LocalDateTime.now();
        Date date = new Date();
        for (int i = 0; i < 1000; i++) {
            int finalI = i;
            executor.submit(() -> {
                try {
                    if (finalI % 2 == 0) {

                        String formattedDate = dateFormat.format(date);
                        //第一種
//                        String formattedDate = DateUtil.formatDate(date);
                        //第二種
//                        String formattedDate = DateSyncUtil.formatDate(date);
                        //第三種
//                        String formattedDate = ThreadLocalDateUtil.formatDate(date);
                        System.out.println("執行緒 " + Thread.currentThread().getName() + " 時間為: " + formattedDate + " 偶數i:" + finalI);
                    } else {
                        Date now = new Date();
                        now.setTime(now.getTime() - TimeUnit.MILLISECONDS.convert(1, TimeUnit.DAYS));
                        String formattedDate = dateFormat.format(now);
                        //第一種
//                        String formattedDate = DateUtil.formatDate(now);
                        //第二種
//                        String formattedDate = DateSyncUtil.formatDate(now);
                        //第三種
//                        String formattedDate = ThreadLocalDateUtil.formatDate(now);
                        System.out.println("執行緒 " + Thread.currentThread().getName() + " 時間為: " + formattedDate + " 奇數i:" + finalI);
                    }

                } catch (Exception e) {
                    System.err.println("執行緒 " + Thread.currentThread().getName() + " 出現了異常: " + e.getMessage());
                }
            });
        }

        executor.shutdown();
        try {
            executor.awaitTermination(30, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 計算總耗時
        LocalDateTime endDateTime = LocalDateTime.now();
        Duration duration = Duration.between(startDateTime, endDateTime);
        System.out.println("所有任務執行完畢,總耗時: " + duration.toMillis() + " 毫秒");
    }
}

具體demo程式碼如上所示,執行結果如下,理論上來說應該是2024年1月23日2024年1月22日列印日誌的次數各5次。實際結果發現在偶數的場景下仍然會出現列印格式化2024年1月22日的場景。明顯出現了資料錯亂賦值的問題,所以到這裡大概可以基本確定就是SimpleDateFormat類在併發場景下執行緒不安全導致的

3.2 SimpleDateFormat為什麼執行緒不安全?

查詢相關資料發現,從SimpleDateFormat類提供的介面來看,實在讓人看不出它與執行緒安全有什麼關係,進入SimpleDateFormat原始碼發現類上面確實存在註釋提醒:意思就是, SimpleDateFormat中的日期格式不是同步的。推薦(建議)為每個執行緒建立獨立的格式例項。如果多個執行緒同時訪問一個格式,則它必須保持外部同步。

繼續分析原始碼發現,SimpleDateFormat執行緒不安全的真正原因是繼承了DateFormat,DateFormat中定義了一個protected屬性的 Calendar類的物件:calendar。由於Calendar類的概念複雜,牽扯到時區與本地化等等,jdk的實現中使用了成員變數來傳遞引數,這就造成在多執行緒的時候會出現錯誤。

注意到在format方法中有一段如下程式碼:

 public StringBuffer format(Date date, StringBuffer toAppendTo,
                               FieldPosition pos)
    {
        pos.beginIndex = pos.endIndex = 0;
        return format(date, toAppendTo, pos.getFieldDelegate());
    }

    // Called from Format after creating a FieldDelegate
    private StringBuffer format(Date date, StringBuffer toAppendTo,
                                FieldDelegate delegate) {
        // Convert input date to time field list
        calendar.setTime(date);

        boolean useDateFormatSymbols = useDateFormatSymbols();

        for (int i = 0; i < compiledPattern.length; ) {
            int tag = compiledPattern[i] >>> 8;
            int count = compiledPattern[i++] & 0xff;
            if (count == 255) {
                count = compiledPattern[i++] << 16;
                count |= compiledPattern[i++];
            }

            switch (tag) {
            case TAG_QUOTE_ASCII_CHAR:
                toAppendTo.append((char)count);
                break;

            case TAG_QUOTE_CHARS:
                toAppendTo.append(compiledPattern, i, count);
                i += count;
                break;

            default:
                subFormat(tag, count, delegate, toAppendTo, useDateFormatSymbols);
                break;
            }
        }
        return toAppendTo;
    }

calendar.setTime(date)這條語句改變了calendar,稍後,calendar還會用到(在subFormat方法裡),而這就是引發問題的根源。

想象一下,在一個多執行緒環境下,有兩個執行緒持有了同一個SimpleDateFormat的例項,分別呼叫format方法: 執行緒1呼叫format方法,改變了calendar這個欄位。 中斷來了。 執行緒2開始執行,它也改變了calendar。 又中斷了。 執行緒1回來了,此時,calendar已然不是它所設的值,而是走上了執行緒2設計的道路。

如果多個執行緒同時爭搶calendar物件,則會出現各種問題,時間不對執行緒掛死等等。 分析一下format的實現,我們不難發現,用到成員變數calendar,唯一的好處,就是在呼叫subFormat時,少了一個引數,卻帶來了這許多的問題。

其實,只要在這裡用一個區域性變數,一路傳遞下去,所有問題都將迎刃而解。 這個問題背後隱藏著一個更為重要的問題–無狀態:無狀態方法的好處之一,就是它在各種環境下,都可以安全的呼叫。衡量一個方法是否是有狀態的,就看它是否改動了其它的東西,比如全域性變數,比如例項的欄位。format方法在執行過程中改動了SimpleDateFormat的calendar欄位,所以,它是有狀態的。

4. 如何解決?

4.1 每次在需要時新建立例項

在需要進行格式化日期的地方新建一個例項,不管什麼時候,將有執行緒安全問題的物件由共享變為區域性私有都能避免多執行緒問題,不過也加重了建立物件的負擔。在一般情況下,這樣其實對效能影響比不是很明顯的。程式碼示例如下。

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

/**
 * @author 
 * @date 2024/1/23 20:04
 */


public class DateUtil {

    public static String formatDate(Date date) throws ParseException {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        return sdf.format(date);
    }

    public static Date parse(String strDate) throws ParseException {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        return sdf.parse(strDate);
    }
}​

4.2 同步SimpleDateFormat物件

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

/**
 * @author 
 * @date 2024/1/23 20:04
 */


public class DateSyncUtil {

    private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static String formatDate(Date date) throws ParseException {
        synchronized (sdf) {
            return sdf.format(date);
        }
    }

    public static Date parse(String strDate) throws ParseException {
        synchronized (sdf) {
            return sdf.parse(strDate);
        }
    }
}

說明:當執行緒較多時,當一個執行緒呼叫該方法時,其他想要呼叫此方法的執行緒就要block,多執行緒併發量大的時候會對效能有一定的影響。

4.3 ThreadLocal

import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

public class ConcurrentDateUtil {

    private static ThreadLocal<DateFormat> threadLocal = new ThreadLocal<DateFormat>() {
        @Override
        protected DateFormat initialValue() {
            return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        }
    };

    public static Date parse(String dateStr) throws ParseException {
        return threadLocal.get().parse(dateStr);
    }

    public static String format(Date date) {
        return threadLocal.get().format(date);
    }
}

另一種寫法

import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

/**
 * @author 
 * @date 2024/1/23 15:44
 * @description 執行緒安全的日期處理類
 */


public class ThreadLocalDateUtil {
    /**
     * 日期格式
     */
    private static final String date_format = "yyyy-MM-dd HH:mm:ss";
    /**
     * 執行緒安全處理
     */
    private static ThreadLocal<DateFormat> threadLocal = new ThreadLocal<>();

    /**
     * 執行緒安全處理
     */
    public static DateFormat getDateFormat() {
        DateFormat df = threadLocal.get();
        if (df == null) {
            df = new SimpleDateFormat(date_format);
            threadLocal.set(df);
        }
        return df;
    }

    /**
     * 執行緒安全處理日期格式化
     */
    public static String formatDate(Date date) {
        return getDateFormat().format(date);
    }

    /**
     * 執行緒安全處理日期解析
     */
    public static Date parse(String strDate) throws ParseException {
        return getDateFormat().parse(strDate);
    }
}

說明:使用ThreadLocal, 也是將共享變數變為獨享,執行緒獨享肯定能比方法獨享在併發環境中能減少不少建立物件的開銷。如果對效能要求比較高的情況下,一般推薦使用這種方法

4.4 拋棄JDK,使用其他類庫中的時間格式化類

•使用Apache commons 裡的FastDateFormat,宣稱是既快又執行緒安全的SimpleDateFormat, 可惜它只能對日期進行format, 不能對日期串進行解析。

•使用Joda-Time類庫來處理時間相關問題。

5. 效能比較

透過追加時間監控,將原有資料範圍擴充到[0,999],執行緒池保留10個執行緒不變,觀察三種情況下效能情況。

•第一種:耗時40ms

•第二種:耗時33ms

•第三種:耗時30ms

透過效能壓測發現4.3中的ThreadLocal效能最優,耗時30ms,4.1每次新建立例項效能最差,需要耗時40ms,當然了在極致的高併發場景下提升效果應該會更加明顯。效能問題不是本文探討的重點,在此不多做贅述。

6. 總結

以上就是針對本次問題排查的主要思路及流程,剛開始的排查思路也一直侷限於規則引擎的執行緒不安全或者是傳入的env(由於使用的是HashMap)執行緒不安全,還是受到組內大佬的啟發和幫助才進一步去分析SimpleDateFormat類可能會存線上程不安全。本次問題排查確實提供一個經驗打破常規思路,比如SimpleDateFormat類看起來只是對日期進行格式化,很難和在併發場景下執行緒不安全會導致資料錯亂關聯起來

作者:京東科技 宋慧超

來源:京東雲開發者社群 轉載請註明來源

相關文章