SimpleDateFormat線程不安全問題與ThreadLocal原理

SimpleDateFormat是JDK中長久以來自帶的日期時間格式化類,但是它有線程安全性方面的問題泳唠,使用時要避免它帶來的影響。

SimpleDateFormat是線程不安全的

寫一個SimpleDateFormat在并發(fā)環(huán)境下簡單的例子先。

public class SimpleDateFormatExample {
    private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static void main(String[] args) {
        // 創(chuàng)建線程池溪椎,最好直接new ThreadPoolExecutor,而不是用Executors工具類
        // ExecutorService threadPool = Executors.newCachedThreadPool();
        ExecutorService threadPool = new ThreadPoolExecutor(
            5, 50, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(100)
        );

        List<String> dates = Arrays.asList(
            "2019-02-21 15:47:01",
            "2018-03-22 16:46:02",
            "2017-04-23 17:45:03",
            "2016-05-24 18:44:04",
            "2015-06-25 19:43:05",
            "2014-07-26 20:42:06",
            "2013-08-27 21:41:07",
            "2012-09-28 22:40:08",
            "2011-10-29 23:39:09"
        );

        for (String date : dates) {
            threadPool.execute(() -> {
                try {
                    System.out.println(simpleDateFormat.parse(date));
                } catch (ParseException e) {
                    e.printStackTrace();
                }
            });
        }
    }
}

輸出非常混亂校读,拋出大量NumberFormatException沼侣,以及得出錯誤的結(jié)果。

Exception in thread "pool-1-thread-2" java.lang.NumberFormatException: For input string: ""
    at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
    at java.lang.Long.parseLong(Long.java:601)
    at java.lang.Long.parseLong(Long.java:631)
    at java.text.DigitList.getLong(DigitList.java:195)
    at java.text.DecimalFormat.parse(DecimalFormat.java:2084)
    at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
    at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
    at java.text.DateFormat.parse(DateFormat.java:364)
    at me.lmagics.SimpleDateFormatExample.lambda$main$0(SimpleDateFormatExample.java:42)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
    at java.lang.Thread.run(Thread.java:748)
Exception in thread "pool-1-thread-4" Exception in thread "pool-1-thread-3" java.lang.NumberFormatException: For input string: ".809E.809E22"
    at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:2043)
    at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
    at java.lang.Double.parseDouble(Double.java:538)
    at java.text.DigitList.getDouble(DigitList.java:169)
    at java.text.DecimalFormat.parse(DecimalFormat.java:2089)
    at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
    at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
    at java.text.DateFormat.parse(DateFormat.java:364)
    at me.lmagics.SimpleDateFormatExample.lambda$main$0(SimpleDateFormatExample.java:42)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
    at java.lang.Thread.run(Thread.java:748)
java.lang.NumberFormatException: For input string: ".809E.809E22"
    at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:2043)
    at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
    at java.lang.Double.parseDouble(Double.java:538)
    at java.text.DigitList.getDouble(DigitList.java:169)
    at java.text.DecimalFormat.parse(DecimalFormat.java:2089)
    at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
    at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
Wed Mar 23 16:46:02 CST 1
Tue May 24 18:44:04 CST 2016
    at java.text.DateFormat.parse(DateFormat.java:364)
Wed Mar 23 16:46:02 CST 1
    at me.lmagics.SimpleDateFormatExample.lambda$main$0(SimpleDateFormatExample.java:42)
Thu Feb 21 15:47:01 CST 2019
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
Thu Jun 25 19:43:05 CST 2015
Thu Oct 29 23:39:09 CST 2011
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
    at java.lang.Thread.run(Thread.java:748)

然后通過SimpleDateFormat的源碼來分析它線程不安全的根本原因歉秫。
SimpleDateFormat是繼承自DateFormat類蛾洛,DateFormat類中維護了一個全局的Calendar變量。

    /**
     * The {@link Calendar} instance used for calculating the date-time fields
     * and the instant of time. This field is used for both formatting and
     * parsing.
     *
     * <p>Subclasses should initialize this field to a {@link Calendar}
     * appropriate for the {@link Locale} associated with this
     * <code>DateFormat</code>.
     * @serial
     */
    protected Calendar calendar;

從注釋可以看出端考,這個Calendar對象既用于格式化也用于解析日期時間雅潭。再查看parse()方法接近最后的部分。

        Date parsedDate;
        try {
            parsedDate = calb.establish(calendar).getTime();
            // If the year value is ambiguous,
            // then the two-digit year == the default start year
            if (ambiguousYear[0]) {
                if (parsedDate.before(defaultCenturyStart)) {
                    parsedDate = calb.addYear(100).establish(calendar).getTime();
                }
            }
        }
        // An IllegalArgumentException will be thrown by Calendar.getTime()
        // if any fields are out of range, e.g., MONTH == 17.
        catch (IllegalArgumentException e) {
            ...
        }

        return parsedDate;

可見却特,最后的返回值是通過調(diào)用CalendarBuilder.establish()方法獲得的扶供,而它的入?yún)⒄镁褪乔懊娴腃alendar對象。

    Calendar establish(Calendar cal) {
        boolean weekDate = isSet(WEEK_YEAR)
                            && field[WEEK_YEAR] > field[YEAR];
        if (weekDate && !cal.isWeekDateSupported()) {
            // Use YEAR instead
            if (!isSet(YEAR)) {
                set(YEAR, field[MAX_FIELD + WEEK_YEAR]);
            }
            weekDate = false;
        }

        cal.clear();
        // Set the fields from the min stamp to the max stamp so that
        // the field resolution works in the Calendar.
        for (int stamp = MINIMUM_USER_STAMP; stamp < nextStamp; stamp++) {
            for (int index = 0; index <= maxFieldIndex; index++) {
                if (field[index] == stamp) {
                    cal.set(index, field[MAX_FIELD + index]);
                    break;
                }
            }
        }

        if (weekDate) {
            int weekOfYear = isSet(WEEK_OF_YEAR) ? field[MAX_FIELD + WEEK_OF_YEAR] : 1;
            int dayOfWeek = isSet(DAY_OF_WEEK) ?
                                field[MAX_FIELD + DAY_OF_WEEK] : cal.getFirstDayOfWeek();
            if (!isValidDayOfWeek(dayOfWeek) && cal.isLenient()) {
                if (dayOfWeek >= 8) {
                    dayOfWeek--;
                    weekOfYear += dayOfWeek / 7;
                    dayOfWeek = (dayOfWeek % 7) + 1;
                } else {
                    while (dayOfWeek <= 0) {
                        dayOfWeek += 7;
                        weekOfYear--;
                    }
                }
                dayOfWeek = toCalendarDayOfWeek(dayOfWeek);
            }
            cal.setWeekDate(field[MAX_FIELD + WEEK_YEAR], weekOfYear, dayOfWeek);
        }
        return cal;
    }

該方法中先后調(diào)用了cal.clear()與cal.set()裂明,也就是先清除cal對象中設(shè)置的值椿浓,再重新設(shè)置新的值。由于Calendar內(nèi)部并沒有線程安全機制闽晦,并且這兩個操作也都不是原子性的扳碍,所以當(dāng)多個線程同時操作一個SimpleDateFormat時就會引起cal的值混亂。類似地仙蛉,format()方法也存在同樣的問題笋敞。

為什么用ThreadLocal能解決問題

ThreadLocal即線程本地變量。它用來為每個線程維護一個專屬的變量副本荠瘪,線程對自己的變量副本進行操作時夯巷,對其他線程的變量副本沒有任何影響。由此可見哀墓,它特別適合解決并發(fā)情況下變量共享造成的線程安全性問題趁餐,前提是各個副本隔離后不影響業(yè)務(wù)運行。
以上面的SimpleDateFormat問題為例篮绰,ThreadLocal可以這樣使用后雷。

// 可以直接設(shè)置初始值
private static ThreadLocal<SimpleDateFormat> simpleDateFormat = ThreadLocal.withInitial(() ->
    new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
);
// 也可以調(diào)用set()方法
private static ThreadLocal<SimpleDateFormat> simpleDateFormat = new ThreadLocal<>();
simpleDateFormat.set(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
...
// 調(diào)用get()方法取得值
System.out.println(simpleDateFormat.get().parse(date));
// 移除
simpleDateFormat.remove();

那么ThreadLocal是采用了什么機制來實現(xiàn)變量副本隔離的呢?在Thread類內(nèi)部吠各,有如下的定義臀突。

    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;

可見每個線程都維護了一個叫ThreadLocalMap的東西,它是ThreadLocal中定義的一個靜態(tài)內(nèi)部類贾漏。其實現(xiàn)類似于HashMap惧辈,但沒實現(xiàn)Map接口,數(shù)據(jù)結(jié)構(gòu)和內(nèi)部邏輯也有不同磕瓷。ThreadLocalMap.Entry是這樣定義的盒齿。

        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }

該Entry的鍵值類型都是確定的念逞。值就是變量的副本,鍵是對ThreadLocal對象的一個弱引用边翁。由于線程并不能直接訪問和存取ThreadLocalMap翎承,只能藉由ThreadLocal進行,因此不同的線程之間的變量副本就實現(xiàn)了隔離符匾。


上面的圖來自阿里Java開發(fā)手冊叨咖,清晰地示出了線程、ThreadLocal啊胶、ThreadLocalMap三者的引用關(guān)系甸各,鼓掌。
另外焰坪,ThreadLocal還可能存在內(nèi)存泄漏的問題趣倾,前人已經(jīng)寫過很好的分析文章,如http://www.reibang.com/p/a1cd61fa22da某饰,下面稍作總結(jié)儒恋。

  • 回憶Java中的4種引用類型:強、軟黔漂、弱诫尽、虛引用,其引用強度依次遞減炬守。其中弱引用只能存活到下一次Young GC發(fā)生之前牧嫉。


  • ThreadLocalMap.Entry中的鍵就是弱引用,如果它被回收减途,會出現(xiàn)key為null但value仍然存在的情況(value是強引用酣藻,當(dāng)然Entry也沒有被回收),有內(nèi)存泄漏風(fēng)險观蜗。ThreadLocal的設(shè)計者已經(jīng)考慮到了這種情況,調(diào)用get()/set()/remove()方法時衣洁,都會調(diào)用expungeStaleEntry()方法來刪除這種key已經(jīng)被回收了的Entry墓捻。這段代碼很有意思,關(guān)鍵點添加了一點注釋坊夫。
        private int expungeStaleEntry(int staleSlot) {
            Entry[] tab = table;
            int len = tab.length;

            // expunge entry at staleSlot
            // 將值和Entry都設(shè)成null砖第,這樣在下一次GC根搜索時均不可達,就被回收了
            tab[staleSlot].value = null;
            tab[staleSlot] = null;
            size--;

            // Rehash until we encounter null
            // 還沒完环凿,接下來會繼續(xù)找key為null的其他Entry梧兼,一起刪掉
            Entry e;
            int i;
            for (i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                ThreadLocal<?> k = e.get();
                if (k == null) {
                    // 找到了,將值和Entry都設(shè)成null
                    e.value = null;
                    tab[i] = null;
                    size--;
                } else {
                    int h = k.threadLocalHashCode & (len - 1);
                    if (h != i) {
                        tab[i] = null;

                        // Unlike Knuth 6.4 Algorithm R, we must scan until
                        // null because multiple entries could have been stale.
                        // nextIndex()方法就是(i + 1)%len智听,ThreadLocalMap是采用線性探測開放定址解決hash沖突的
                        // 這比HashMap的鏈地址法(數(shù)組+鏈表)簡單得多羽杰,當(dāng)然ThreadLocal變量多了之后渡紫,解決沖突的時間會邊長
                        while (tab[h] != null)
                            h = nextIndex(h, len);
                        tab[h] = e;
                    }
                }
            }
            return i;
        }
  • 但是就算這樣設(shè)計了,也不能完全防止ThreadLocal內(nèi)存泄漏考赛,因為它可以是static的惕澎,也有可能在分配了變量副本之后沒調(diào)用任何方法。另外颜骤,由于ThreadLocalMap的生命周期和線程一樣長唧喉,因此不管Entry的鍵是對ThreadLocal的強引用還是弱引用,都有可能出現(xiàn)ThreadLocal被回收變成null的情況忍抽。
  • 所以八孝,完全避免內(nèi)存泄漏的唯一手段就是在ThreadLocal用完后,調(diào)用remove()方法鸠项。
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末干跛,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子锈锤,更是在濱河造成了極大的恐慌驯鳖,老刑警劉巖,帶你破解...
    沈念sama閱讀 211,743評論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件久免,死亡現(xiàn)場離奇詭異浅辙,居然都是意外死亡,警方通過查閱死者的電腦和手機阎姥,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,296評論 3 385
  • 文/潘曉璐 我一進店門记舆,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人呼巴,你說我怎么就攤上這事泽腮。” “怎么了衣赶?”我有些...
    開封第一講書人閱讀 157,285評論 0 348
  • 文/不壞的土叔 我叫張陵诊赊,是天一觀的道長。 經(jīng)常有香客問我府瞄,道長碧磅,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 56,485評論 1 283
  • 正文 為了忘掉前任遵馆,我火速辦了婚禮鲸郊,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘货邓。我一直安慰自己秆撮,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 65,581評論 6 386
  • 文/花漫 我一把揭開白布换况。 她就那樣靜靜地躺著职辨,像睡著了一般盗蟆。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上拨匆,一...
    開封第一講書人閱讀 49,821評論 1 290
  • 那天姆涩,我揣著相機與錄音,去河邊找鬼惭每。 笑死骨饿,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的台腥。 我是一名探鬼主播宏赘,決...
    沈念sama閱讀 38,960評論 3 408
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼黎侈!你這毒婦竟也來了察署?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,719評論 0 266
  • 序言:老撾萬榮一對情侶失蹤峻汉,失蹤者是張志新(化名)和其女友劉穎贴汪,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體休吠,經(jīng)...
    沈念sama閱讀 44,186評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡扳埂,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,516評論 2 327
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了瘤礁。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片阳懂。...
    茶點故事閱讀 38,650評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖柜思,靈堂內(nèi)的尸體忽然破棺而出岩调,到底是詐尸還是另有隱情,我是刑警寧澤赡盘,帶...
    沈念sama閱讀 34,329評論 4 330
  • 正文 年R本政府宣布号枕,位于F島的核電站,受9級特大地震影響陨享,放射性物質(zhì)發(fā)生泄漏葱淳。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,936評論 3 313
  • 文/蒙蒙 一霉咨、第九天 我趴在偏房一處隱蔽的房頂上張望湃望。 院中可真熱鬧筝尾,春花似錦、人聲如沸件豌。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,757評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽唁毒。三九已至,卻和暖如春星爪,著一層夾襖步出監(jiān)牢的瞬間浆西,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,991評論 1 266
  • 我被黑心中介騙來泰國打工顽腾, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留近零,地道東北人。 一個月前我還...
    沈念sama閱讀 46,370評論 2 360
  • 正文 我出身青樓抄肖,卻偏偏與公主長得像久信,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子漓摩,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 43,527評論 2 349

推薦閱讀更多精彩內(nèi)容