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

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類看起來只是對日期進行格式化,很難和在併發場景下線程不安全會導致數據錯亂關聯起來

作者:京東科技 宋慧超

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

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章