【併發編程】ThreadLocal:如何優雅的解決SimpleDateFormat多線程安全問題

文章開始之前先做個找工作的介紹吧,由於這次疫情影響,我一個UI朋友的公司破產之後他現在處於找工作階段,一直沒有找到自己合適的,三年工作經驗左右,座標深圳,如果有招UI的朋友可以聯繫我。

作品: http://yiming.zcool.com.cn/

又是風和日麗的一天,我正在快樂的寫着bug,突然感覺到背後一陣涼風吹過,我感覺肯定有大事發生,我轉頭一看,果然,小明笑嘻嘻的站在我身後,邊笑邊說:哥,忙嗎?不忙的話幫我看個問題唄!每次他的問題都十分詭異,不過身爲同事,還是應該相互幫助的,我決定與他一起看看他的問題。

SimpleDateFormat詭異bug

SimpleDateFormat應該是我們開發中使用比較多的工具類了吧,小明也在項目中使用到了,但就是這個工具類讓小明痛苦了好長一段時間,爲什麼呢?那是因爲測試工程師的小哥哥們在辛勞的做着接口性能測試,但是發現有些接口返回的日期時間是錯亂的,不符合實際結果,這個時候,禪道中就多出了一道美麗的風景線,那就是bug:在併發接口測試中,日期返回的數據偶爾錯誤。

就是這麼一個bug,讓小明鬱悶了好長一段時間,實在找不到解決方案,這才找到了我,我來看了他的業務代碼之後,發現他使用了SimpleDateFormat這個工具類來格式化時間, 並且還是靜態的,這個時候我就知道爲什麼平時做功能測試的時候沒有問題,在做性能測試的時候bug就出來了,小明也是剛畢業不久,對多線程這一塊不是怎麼的熟悉,所以這個也不能怪他,我們現在使用demo來複現一下SimpleDateFormat的詭異bug吧。

復現SimpleDateFormat詭異bug

我這裏將使用demo的形式來複現一下SimpleDateFormat存在的bug。

字符串日期轉Date日期(parse)

package com.ymy.test;

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

public class SimpleDateFormatBugTest {

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

    private static Date parse(String date){
        Date parse = null;
         try {
            return sdf.parse(date);
        } catch (ParseException e) {
            e.printStackTrace();
        }
        return null;
    }
    
    public static void main(String[] args) {

        Thread t1 = new Thread(() -> {
            Date parse = parse("2020-12-12 12:12:12");
            System.out.println("當前日期:" + parse);
        });

        Thread t2 = new Thread(() -> {
            Date parse = parse("2020-12-12 12:12:12");
            System.out.println("當前日期:" + parse);
        });


        Thread t3 = new Thread(() -> {
            Date parse = parse("2018-10-10 10:10:10");
            System.out.println("當前日期:" + parse);
        });

        t1.start();
        t2.start();
        t3.start();

        try {
            t1.join();
            t2.join();
            t3.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("線程執行完畢");

    }
}

執行結果

Exception in thread "Thread-2" Exception in thread "Thread-0" java.lang.NumberFormatException: For input string: ".1102E.1102E22"
	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 com.ymy.test.SimpleDateFormatBugTest.parse(SimpleDateFormatBugTest.java:33)
	at com.ymy.test.SimpleDateFormatBugTest.lambda$main$2(SimpleDateFormatBugTest.java:56)
	at java.lang.Thread.run(Thread.java:748)
java.lang.NumberFormatException: For input string: ".1102E.1102E22"
	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 com.ymy.test.SimpleDateFormatBugTest.parse(SimpleDateFormatBugTest.java:33)
	at com.ymy.test.SimpleDateFormatBugTest.lambda$main$0(SimpleDateFormatBugTest.java:45)
	at java.lang.Thread.run(Thread.java:748)
當前日期:Sat Dec 12 12:12:12 CST 2020
線程執行完畢

Process finished with exit code 0

Date日期轉String類型(format)

package com.ymy.test;

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

public class SimpleDateFormatTest {


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

   


    private static Date d1 = null;

    private static Date d2 = null;

    private static Date d3 = null;

    static {
        try {
            d1 = sdf.parse("2020-12-12 12:12:12");
            d2 =sdf.parse("2019-11-11 11:11:11");
            d3 =sdf.parse("2018-10-10 10:10:10");
        } catch (ParseException e) {
            e.printStackTrace();
        }
    }




    public static void main(String[] args) {

        Thread t1 = new Thread(() -> {
            String parse = sdf.format(d1);
            System.out.println("當前日期:" + parse);
        });

        Thread t2 = new Thread(() -> {
            String parse = sdf.format(d2);
            System.out.println("當前日期:" + parse);
        });


        Thread t3 = new Thread(() -> {
            String parse = sdf.format(d3);
            System.out.println("當前日期:" + parse);
        });

        t1.start();
        t2.start();
        t3.start();

        try {
            t1.join();
            t2.join();
            t3.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }


    }
}

使用三個線程分別對2020-12-12 12:12:12、2019-11-11 11:11:11、2018-10-10 10:10:10的Date格式轉字符串格式的操作,我們在靜態代碼塊中初始化了Date格式的數據,然後使用三個線程分別對他們進行格式轉換。

第一次:

當前日期:2020-12-12 12:12:12
當前日期:2018-11-11 11:11:11
當前日期:2019-11-11 11:11:11

第二次:

當前日期:2018-10-10 10:10:10
當前日期:2018-10-10 10:10:10
當前日期:2019-11-11 11:11:11

Process finished with exit code 0

第三次:

當前日期:2020-10-10 10:10:10
當前日期:2018-10-10 10:10:10
當前日期:2018-10-10 10:10:10

Process finished with exit code 0

我們發現String轉Date的時候有兩個線程直接報錯,Date轉String雖然不會報錯,但是日期格式全部錯亂,爲什麼平時使用的時候不會出現問題,一到性能測試的時候就會發生這種問題,小明表示快要崩潰了。
在這裏插入圖片描述

SimpleDateFormat出現bug的原因

其實瞭解過多線程的人都知道SimpleDateFormat是線程不安全的,但是爲什麼是線程不安全的,大家都知道嗎?我們一起來看一下。

首先parse源碼分析:

public Date parse(String source) throws ParseException
    {
        ParsePosition pos = new ParsePosition(0);
        Date result = parse(source, pos);
        if (pos.index == 0)
            throw new ParseException("Unparseable date: \"" + source + "\"" ,
                pos.errorIndex);
        return result;
    }

進入:parse(source, pos);,然後注意到下面這段代碼

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();
                }
            }
        }


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;
    }

我們深入到**calb.establish(calendar).getTime();**中,發現 establish() 中存在着一行比較秀的代碼:

cal.clear();
public final void clear()
    {
        for (int i = 0; i < fields.length; ) {
            stamp[i] = fields[i] = 0; // UNSET == 0
            isSet[i++] = false;
        }
        areAllFieldsSet = areFieldsSet = false;
        isTimeSet = false;
    }

由於calendar這個參數是由調用方傳遞進來的,而調用方parse()拿的是當前類的成員變量。

 protected Calendar calendar;

然而我們都知道,成員變量在多線程情況下如果沒有鎖的加持,是很容易出現線程安全問題的,他這裏是先執行的clear,在重新獲取時間

public final Date getTime() {
        return new Date(getTimeInMillis());
    }

public long getTimeInMillis() {
        if (!isTimeSet) {
            updateTime();
        }
        return time;
    }
ressWarnings("ProtectedField")
    protected long          time;

getTime最終返回的是成員變量,這個是之前已經設置了值的屬性,然而當開啓多線程的時候,很有可能導致第一個線程執行了getTime之後第二個線程有執行了clear,由於這些變量都是成員變量,所以他們是共享的,bug就發生了。

format日期錯亂的原因和這個類似,這裏就不做過多說明了,感興趣的小哥哥小姐姐可以翻開源碼擼一下。

如何解決SimpleDateFormat多線程安全問題

局部變量

既然成員變量會發生線程安全問題,那將SimpleDateFormat設置成爲局部變量那不就沒問題了嗎,卻是是這樣,但如果需要修改格式化的模式,改動量是非常大,因爲你需要將所有涉及到的局部變量都修改一遍,而單例只需要修改一次,這個看情況而定

使用SimpleDateFormat方法時加鎖

這也是一種解決的思路,比如:synchronized,大家都知道,加鎖會降低程序的效率,除非必要情況,否者時不建議直接使用鎖來解決的。

使用ThreadLocal

ThreadLocal不知道大家瞭解過沒有,他是一種解決多線程併發問題簡單有效的方式,通過線程和變量綁定的方式,讓線程與線程之間不存在變量共享的問題,自然而然就解決了多線程併發的問題。

ThreadLocal介紹

JDK 1.2的版本中就提供java.lang.ThreadLocal,ThreadLocal爲解決多線程程序的併發問題提供了一種新的思路。使用這個工具類可以很簡潔地編寫出優美的多線程程序,ThreadLocal並不是一個Thread,而是Thread的局部變量。

舉個生活中的例子,人有三急,而廁所只有一坑位,所以公司的上百人都會爭先恐後的爭奪那一個坑位的使用權,並且在爭奪過程中還有可能出現事故,公司老總有一次也想蹲坑,但是發現已經有很多人正在爲了那一個坑位搶的死去活來,導致他不能及時排泄而。。。。。。

公司老總整理了情緒之後,於時吩咐祕書:你趕緊去安排一下,廁所的坑位增加到一百個,每人一個,看還有沒有人搶。

這個例子就有點類似與我們程序中的多線程,很多線程都在搶同一個資源,在搶來搶去的時候難免發生意外情況,也就是程序中的線程安全問題,所以有沒有一種給每個線程都分配對應的變量,讓他們不用搶來搶去?這個時候ThreadLocal站了出來,我是土豪,我給你們每個線程都分配一個只屬於你們自己的資源,省的你們搶來搶去,打擾我泡妞。

ThreadLocal使用demo

package com.ymy.test;

import java.util.Random;

public class MyLocalThread {


    private static Random random = new Random();

    private static ThreadLocal<Integer> t = ThreadLocal.withInitial(
            ()->random.nextInt(10)+1
    );



    private static Integer get(){
        return t.get();
    }


    public static void main(String[] args) {

        Thread t1 = new Thread(() -> {
            Integer num = get();
            System.out.println("線程名:"+Thread.currentThread().getName()+" "+ num);
            num = get();
            System.out.println("線程名:"+Thread.currentThread().getName()+" 第二次獲取 "+ num);
        });

        Thread t2 = new Thread(() -> {
            Integer num = get();
            System.out.println("線程名:"+Thread.currentThread().getName()+" "+ num);
            num = get();
            System.out.println("線程名:"+Thread.currentThread().getName()+" 第二次獲取 "+ num);
        });

        Thread t3 = new Thread(() -> {
            Integer num = get();
            System.out.println("線程名:"+Thread.currentThread().getName()+" "+ num);
            num = get();
            System.out.println("線程名:"+Thread.currentThread().getName()+" 第二次獲取 "+ num);
        });


        t1.start();
        t2.start();
        t3.start();
        try {
            t1.join();
            t2.join();
            t3.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("執行完畢");
    }


}

上面的代碼很簡單,ThreadLocal爲三個線程生成三個隨機數,然後三個線程分別去獲取生成的隨機數,看看會發生什麼結果?

線程名:Thread-0 3
線程名:Thread-0 第二次獲取 3
線程名:Thread-1 1
線程名:Thread-2 10
線程名:Thread-1 第二次獲取 1
線程名:Thread-2 第二次獲取 10
執行完畢

Process finished with exit code 0

我們來看一下三個線程分別生成的隨機數:

第一個線程(Thread-0):3
第二個線程(Thread-1):1
第三個線程(Thread-2):10

我們看到三個線程生成的數據:3、1、10,都是隨機的,但是這並不能說明他就是線程安全的,所以我這裏還特意在這三個線程中重複獲取了一次隨機數,我們我們發現:

第一個線程第二次獲取(Thread-0):3
第二個線程第二次獲取(Thread-1):1
第三個線程第二次獲取(Thread-2):10

你們是不是發現什麼了?沒錯,那就是每個線程的第二次獲取的數據和第一次是相同的,而且不會和其他線程發生任何錯亂,這就是ThreadLocal的神奇之處。

ThreadLocal源碼探索

我們先看看ThreadLocal是如何被創建的

private static ThreadLocal<Integer> t = ThreadLocal.withInitial(()->random.nextInt(10)+1);

這行代碼是上面demo中ThreadLocal創建方式,通過ThreadLocal.withInitial來創建,我們一起來看一下withInitial方法。

 /**
     * Creates a thread local variable. The initial value of the variable is
     * determined by invoking the {@code get} method on the {@code Supplier}.
     *
     * @param <S> the type of the thread local's value
     * @param supplier the supplier to be used to determine the initial value
     * @return a new thread local variable
     * @throws NullPointerException if the specified supplier is null
     * @since 1.8
     */
    public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) {
        return new SuppliedThreadLocal<>(supplier);
    }

這是jdk1.8才支持的創建方式,以前的版本請不要這麼使用,這個方法表示:創建線程局部變量。變量的初始值是通過調用get()方法確定的。

那我們在一起來看看get()方法

/**
     * Returns the value in the current thread's copy of this
     * thread-local variable.  If the variable has no value for the
     * current thread, it is first initialized to the value returned
     * by an invocation of the {@link #initialValue} method.
     *
     * @return the current thread's value of this thread-local
     */
    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }

大致意思:返回當前線程中該線程局部變量的副本中的值。如果變量沒有當前線程的值,則首先將其初始化爲調用{@link #initialValue}方法返回的值。

/**
     * Variant of set() to establish initialValue. Used instead
     * of set() in case user has overridden the set() method.
     *
     * @return the initial value
     */
    private T setInitialValue() {
        T value = initialValue();
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
        return value;
    }

大致意思:將此線程局部變量的當前線程副本設置爲指定的值。大多數子類將不需要重寫這個方法,僅僅依靠{@link #initialValue}方法來設置線程局部變量的值,其中看到ThreadLocalMap 了沒有?之前是不是一直有一個疑惑,那就是ThreadLocal到底是怎麼存儲我們線程變量的,ThreadLocalMap 就是ThreadLocal給每一個線程都分配一個獨立資源的王牌,我們一起看看ThreadLocalMap的內部結構

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

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

/**
         * The table, resized as necessary.
         * table.length MUST always be a power of two.
         */
        private Entry[] table;

裏面也有很多的方法,這裏就不全貼出來了,佔用太多空間,Entry 存放的就是線程對應的存儲對象,我們來梳理一下整個過程,當線程調用get()的時候,首先會判斷Thread類中的ThreadLocal.ThreadLocalMap threadLocals變量,通過線程名獲取
在這裏插入圖片描述
如果存在,接着獲取當前線程的存儲的對象T,如果沒有找到,那麼將會執行初始化過程,也就是setInitialValue()方法,在setInitialValue()方法方法中又會判斷ThreadLocal.ThreadLocalMap是否爲空,如果不爲空,賦值,爲空,創建ThreadLocal.ThreadLocalMap

void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }

然後返回當前線程的變量對象,大致流程就是這樣,Thread類中維護着一個ThreadLocalMap,它負責存儲線程的與線程對應的資源對象,當線程調用get()的時候會判斷ThreadLocalMap中是否存在當前線程,如果沒有,創建在返回,否者直接返回。

ThreadLocal注意事項

不知道你們注意到沒有,存儲線程變量的ThreadLocalMap屬於Thread,並不屬於ThreadLocal,你知道爲什麼嗎?

1:我們知道,ThreadLocal只是一個簡單的工具類,而ThreadLocalMap裏面的數據都是和線程相關,所以存放在Thread中。

2:ThreadLocalMap存放在Thread中不容易發生內存泄漏,爲什麼這麼說呢?那是因爲ThreadLocalMap中對線程Thread有着引用關係,由於ThreadLocal的生命週期可能是和程序共存亡的,如果將ThreadLocalMap存放到ThreadLocal中,就算線程的生命週期結束了,ThreadLocalMap也不會被回收,因爲ThreadLocal一直存在,如果存放在Thread中就不一樣了,我們來看一下之前看過的源碼

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

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

看到WeakReference沒有,它表示這ThreadLocalMap對ThreadLocal是一種弱引用,只要Thread生命週期結束,ThreadLocalMap也會跟着一起消失,所以考慮內存泄漏方面,存在Thread類中更合理。

那ThreadLocal會發生內存泄漏問題嗎?答案是肯定的,剛剛我們說到,線程被回收,ThreadLocalMap也會一起被回收,但是有一種情況線程是同程序共生死的,那就是線程池,所這個時候就可能會發生內存泄露問題了,然而我們我們在項目開發中線程池是使用比較多的,那我們如何解決這個問題呢?

其實很簡單,java的gc機制做不到,不代表我們自己做不到,我們在自己的邏輯處理中自己釋放,想必很多人都想到了,try/finally,沒錯就是它,只需要在finally中假如這行代碼即可。

t.remove();//t:ThreadLocal實例

ThreadLocal介紹的差不多了,下面我們來使用ThreadLocal解決一下SimpleDateFormat多線程的安全問題吧。

使用ThreadLocal解決SimpleDateFormat線程安全問題

上修改之後的代碼

package com.ymy.test;

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

public class SimpleDateFormatBugTest {



    private static ThreadLocal<SimpleDateFormat> t = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));


    private  static SimpleDateFormat getSimpleDateFormat() {
        return t.get();
    }



    private static Date parse(String date){
        try {
            return getSimpleDateFormat().parse(date);
        } catch (ParseException e) {
            e.printStackTrace();
        }
        return null;
    }

    public static void main(String[] args) {

         Thread t1 = new Thread(() -> {
            Date parse = parse("2020-12-12 12:12:12");
            System.out.println("當前日期:" + parse);
        });

        Thread t2 = new Thread(() -> {
            Date parse = parse("2019-11-11 11:11:11");
            System.out.println("當前日期:" + parse);
        });


        Thread t3 = new Thread(() -> {
            Date parse = parse("2018-10-10 10:10:10");
            System.out.println("當前日期:" + parse);
        });

        t1.start();
        t2.start();
        t3.start();

        try {
            t1.join();
            t2.join();
            t3.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("線程執行完畢");

    }
}

第一次運行

當前日期:Wed Oct 10 10:10:10 CST 2018
當前日期:Mon Nov 11 11:11:11 CST 2019
當前日期:Sat Dec 12 12:12:12 CST 2020
線程執行完畢

Process finished with exit code 0

第二次運行

當前日期:Wed Oct 10 10:10:10 CST 2018
當前日期:Sat Dec 12 12:12:12 CST 2020
當前日期:Mon Nov 11 11:11:11 CST 2019
線程執行完畢

Process finished with exit code 0

第三次運行

當前日期:Wed Oct 10 10:10:10 CST 2018
當前日期:Sat Dec 12 12:12:12 CST 2020
當前日期:Mon Nov 11 11:11:11 CST 2019
線程執行完畢

Process finished with exit code 0

完美的解決了SimpleDateFormat在多線程中併發問題。

總結

問題終於解決了,小明也可以繼續快樂的寫bug了,充實的一天就這麼過去了,小明收穫了知識,而我,帶着小明走了一遍ThreadLocal的源碼,也對ThreadLocal加深了一遍印象,小明因爲解決了這個bug,也不用加班了,臉上流露了激動的神情,也正好到下班時間了,然後小明就帶着我去擼串了。。。。。。。。。。。。。。。。。。。。
在這裏插入圖片描述

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