1. 問題背景
問題的背景是這樣的,在最近需求開發(fā)中遇到需要將給定目標(biāo)數(shù)據(jù)通過某一固定的計量規(guī)則進(jìn)行過濾并打標(biāo)生成明細(xì)數(shù)據(jù),其中發(fā)現(xiàn)存在一筆目標(biāo)數(shù)據(jù)的時間在不符合現(xiàn)有日期規(guī)則的條件下,還是通過了規(guī)則引擎的匹配打標(biāo)操作。故而需要對該錯誤匹配場景進(jìn)行排查,定位其根本原因所在。
2. 排查思路
2.1 數(shù)據(jù)定位
在開始排查問題之初,先假定現(xiàn)有的Aviator規(guī)則引擎能夠?qū)ΜF(xiàn)有的數(shù)據(jù)進(jìn)行正常的匹配打標(biāo),查詢在存在問題數(shù)據(jù)(圖中紅框所示)同一時刻進(jìn)行規(guī)則匹配時的數(shù)據(jù)都有哪些。發(fā)現(xiàn)存在五筆數(shù)據(jù)在同一時刻進(jìn)行規(guī)則匹配落庫。
繼續(xù)查詢具體的匹配規(guī)則表達(dá)式,發(fā)現(xiàn)針對loanPayTime時間區(qū)間在[2022-07-16 00:00:00, 2023-05-11 23:59:59]的范圍內(nèi)進(jìn)行匹配,目標(biāo)數(shù)據(jù)的時間為2023-09-19 11:27:29,理論上應(yīng)該不會被匹配到。
但是觀測匹配打標(biāo)的明細(xì)數(shù)據(jù)發(fā)現(xiàn)確實打標(biāo)成功了(如紅框所示)。
所以重新回到最初的和目標(biāo)數(shù)據(jù)同時落庫的五筆數(shù)據(jù)發(fā)現(xiàn),這五筆數(shù)據(jù)的loanPayTime時間確實在規(guī)則[2022-07-16 00:00:00, 2023-05-11 23:59:59]之內(nèi),所以在想有沒有可能是在目標(biāo)數(shù)據(jù)匹配規(guī)則引擎前,其它的五筆數(shù)據(jù)中的其中一筆對該數(shù)據(jù)進(jìn)行了修改導(dǎo)致誤匹配到了這個規(guī)則。順著這個思路,首先需要確認(rèn)下Aviator規(guī)則引擎在并發(fā)場景下是否線程安全的。
2.2 規(guī)則引擎
由于在需求中使用到用于給數(shù)據(jù)匹配打標(biāo)的是Aviator規(guī)則引擎,所以第一直覺是懷疑Aviator規(guī)則引擎在并發(fā)的場景中可能會存在線程不安全的情況。
首先簡單介紹下Aviator規(guī)則引擎是什么,Aviator是一個高性能的、輕量級的java語言實現(xiàn)的表達(dá)式求值引擎,主要用于各種表達(dá)式的動態(tài)求值,相較于其它的開源可用的規(guī)則引擎而言,Aviator的設(shè)計目標(biāo)是輕量級和高性能 ,相比于Groovy、JRuby的笨重,Aviator非常小,加上依賴包也才450K,不算依賴包的話只有70K;
當(dāng)然,Aviator的語法是受限的,它不是一門完整的語言,而只是語言的一小部分集合。其次,Aviator的實現(xiàn)思路與其他輕量級的求值器很不相同,其他求值器一般都是通過解釋的方式運行,而Aviator則是直接將表達(dá)式編譯成Java字節(jié)碼,交給JVM去執(zhí)行。簡單來說,Aviator的定位是介于Groovy這樣的重量級腳本語言和IKExpression這樣的輕量級表達(dá)式引擎之間。(具體Aviator的相關(guān)介紹不是本文的重點,具體可參見)
通過查閱相關(guān)資料發(fā)現(xiàn),Aviator中的AviatorEvaluator.execute() 方法本身是線程安全的,也就是說只要表達(dá)式執(zhí)行邏輯和傳入的env是線程安全的,理論上是不會出現(xiàn)并發(fā)場景下線程不安全問題的。(詳見)
2.3 匹配規(guī)則引擎的env
通過前面Aviator的相關(guān)資料發(fā)現(xiàn)傳入的env如果在多線程場景下不安全也會導(dǎo)致最終的結(jié)果是錯誤的,故而定位使用的env發(fā)現(xiàn)使用的是HashMap,該集合類確實是線程不安全的(具體可詳見),但是線程不安全的前提是多個線程同時對其進(jìn)行修改,定位代碼發(fā)現(xiàn)在每次調(diào)用方式時都會重新生成一個HashMap,故而應(yīng)該不會是由于這個線程不安全類導(dǎo)致的。
繼續(xù)定位發(fā)現(xiàn),loanPayTime這個字段在進(jìn)行Aviator規(guī)則引擎匹配前使用SimpleDateFormat進(jìn)行了格式化,所以有可能是由于該類的線程不安全導(dǎo)致的數(shù)據(jù)錯亂問題,但是這個類應(yīng)該只是對日期進(jìn)行格式化處理,難不成還能影響最終的數(shù)據(jù)。帶著這個疑問查詢資料發(fā)現(xiàn),emm確實是線程不安全的。
好家伙,嫌疑對象目前已經(jīng)有了,現(xiàn)在就是尋找相關(guān)證據(jù)來佐證了。
3. SimpleDateFormat 還能線程不安全?
3.1 先寫個demo試試
話不多說,直接去測試一下在并發(fā)場景下,SimpleDateFormat類會不會對需要格式化的日期進(jìn)行錯亂格式化。先模擬一個場景,對多線程并發(fā)場景下格式化日期,即在[0,9]的數(shù)據(jù)范圍內(nèi),在偶數(shù)情況下對2024年1月23日進(jìn)行格式化,在奇數(shù)情況下對2024年1月22日進(jìn)行格式化,然后觀測日志打印效果。
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 + " 偶數(shù)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 + " 奇數(shù)i:" + finalI); } } catch (Exception e) { System.err.println("線程 " + Thread.currentThread().getName() + " 出現(xiàn)了異常: " + 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("所有任務(wù)執(zhí)行完畢,總耗時: " + duration.toMillis() + " 毫秒"); } }
具體demo代碼如上所示,執(zhí)行結(jié)果如下,理論上來說應(yīng)該是2024年1月23日和2024年1月22日打印日志的次數(shù)各5次。實際結(jié)果發(fā)現(xiàn)在偶數(shù)的場景下仍然會出現(xiàn)打印格式化2024年1月22日的場景。明顯出現(xiàn)了數(shù)據(jù)錯亂賦值的問題,所以到這里大概可以基本確定就是SimpleDateFormat類在并發(fā)場景下線程不安全導(dǎo)致的。
3.2 SimpleDateFormat為什么線程不安全?
查詢相關(guān)資料發(fā)現(xiàn),從SimpleDateFormat類提供的接口來看,實在讓人看不出它與線程安全有什么關(guān)系,進(jìn)入SimpleDateFormat源碼發(fā)現(xiàn)類上面確實存在注釋提醒:意思就是, SimpleDateFormat中的日期格式不是同步的。推薦(建議)為每個線程創(chuàng)建獨立的格式實例。如果多個線程同時訪問一個格式,則它必須保持外部同步。
繼續(xù)分析源碼發(fā)現(xiàn),SimpleDateFormat線程不安全的真正原因是繼承了DateFormat,在DateFormat中定義了一個protected屬性的 Calendar類的對象:calendar。由于Calendar類的概念復(fù)雜,牽扯到時區(qū)與本地化等等,jdk的實現(xiàn)中使用了成員變量來傳遞參數(shù),這就造成在多線程的時候會出現(xiàn)錯誤。
注意到在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方法里),而這就是引發(fā)問題的根源。
想象一下,在一個多線程環(huán)境下,有兩個線程持有了同一個SimpleDateFormat的實例,分別調(diào)用format方法: 線程1調(diào)用format方法,改變了calendar這個字段。 中斷來了。 線程2開始執(zhí)行,它也改變了calendar。 又中斷了。 線程1回來了,此時,calendar已然不是它所設(shè)的值,而是走上了線程2設(shè)計的道路。
如果多個線程同時爭搶calendar對象,則會出現(xiàn)各種問題,時間不對,線程掛死等等。 分析一下format的實現(xiàn),我們不難發(fā)現(xiàn),用到成員變量calendar,唯一的好處,就是在調(diào)用subFormat時,少了一個參數(shù),卻帶來了這許多的問題。
其實,只要在這里用一個局部變量,一路傳遞下去,所有問題都將迎刃而解。 這個問題背后隱藏著一個更為重要的問題–無狀態(tài):無狀態(tài)方法的好處之一,就是它在各種環(huán)境下,都可以安全的調(diào)用。衡量一個方法是否是有狀態(tài)的,就看它是否改動了其它的東西,比如全局變量,比如實例的字段。format方法在運行過程中改動了SimpleDateFormat的calendar字段,所以,它是有狀態(tài)的。
4. 如何解決?
4.1 每次在需要時新創(chuàng)建實例
在需要進(jìn)行格式化日期的地方新建一個實例,不管什么時候,將有線程安全問題的對象由共享變?yōu)榫植克接卸寄鼙苊舛嗑€程問題,不過也加重了創(chuàng)建對象的負(fù)擔(dān)。在一般情況下,這樣其實對性能影響比不是很明顯的。代碼示例如下。
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); } } }
說明:當(dāng)線程較多時,當(dāng)一個線程調(diào)用該方法時,其他想要調(diào)用此方法的線程就要block,多線程并發(fā)量大的時候會對性能有一定的影響。
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 threadLocal = new ThreadLocal() { @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 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, 也是將共享變量變?yōu)楠毾恚€程獨享肯定能比方法獨享在并發(fā)環(huán)境中能減少不少創(chuàng)建對象的開銷。如果對性能要求比較高的情況下,一般推薦使用這種方法
4.4 拋棄JDK,使用其他類庫中的時間格式化類
?使用
Apache commons
里的
FastDateFormat
,宣稱是既快又線程安全的SimpleDateFormat, 可惜它
只能
對日期進(jìn)行format,
不能
對日期串進(jìn)行解析。?使用
Joda-Time
類庫來處理時間相關(guān)問題。
5. 性能比較
通過追加時間監(jiān)控,將原有數(shù)據(jù)范圍擴充到[0,999],線程池保留10個線程不變,觀察三種情況下性能情況。
?第一種:耗時40ms
?第二種:耗時33ms
?第三種:耗時30ms
通過性能壓測發(fā)現(xiàn)4.3中的ThreadLocal性能最優(yōu),耗時30ms,4.1每次新創(chuàng)建實例性能最差,需要耗時40ms,當(dāng)然了在極致的高并發(fā)場景下提升效果應(yīng)該會更加明顯。性能問題不是本文探討的重點,在此不多做贅述。
6. 總結(jié)
Ok,以上就是針對本次問題排查的主要思路及流程,這個我剛開始的排查思路也一直局限于規(guī)則引擎的線程不安全或者是傳入的env(由于使用的是HashMap)線程不安全,還是受到組內(nèi)大佬的啟發(fā)和幫助才進(jìn)一步去分析SimpleDateFormat類可能會存在線程不安全。本次問題排查確實提供一個經(jīng)驗,打破常規(guī)思路,比如SimpleDateFormat類看起來只是對日期進(jìn)行格式化,很難和在并發(fā)場景下線程不安全會導(dǎo)致數(shù)據(jù)錯亂關(guān)聯(lián)起來。以上。
審核編輯 黃宇
-
JAVA
+關(guān)注
關(guān)注
19文章
2967瀏覽量
104751 -
hashmap
+關(guān)注
關(guān)注
0文章
14瀏覽量
2288
發(fā)布評論請先 登錄
相關(guān)推薦
評論