Java 多線程(7): ThreadLocal 的應用及原理

在涉及到多線程須要共享變量的時候,通常有兩種方法:其一就是使用互斥鎖,使得在每一個時刻只能有一個線程訪問該變量,好處就是便於編碼(直接使用 synchronized 關鍵字進行同步訪問),缺點在於這增長了線程間的競爭,下降了效率;其二就是使用本文要講的 ThreadLocal。若是說 synchronized 是以「時間換空間」,那麼 ThreadLocal 就是 「以空間換時間」 —— 由於 ThreadLocal 的原理就是爲每一個線程都提供一個這樣的變量,使得這些變量是線程級別的變量,不一樣線程之間互不影響,從而達到能夠併發訪問而不出現併發問題的目的。數據庫


首先咱們來看一個客觀的事實:當一個可變對象被多個線程訪問時,可能會獲得非預期的結果 —— 因此先讓咱們來看一個例子。在講到併發訪問的問題的時候,SimpleDateFormat 老是會被拿來當成一個絕好的例子(從這點看感謝 JDK 提供了這麼一個有設計缺陷的類方便咱們當成反面教材 :) )。由於 SimpleDateFormatformatparse 方法共享從父類 DateFormat 繼承而來的 Calendar 對象:
DateFormat 的 Calendar 對象數組

而且在 formatparse 方法中都會改變這個 Calendar 對象:安全

  • format 方法片斷:

format 方法片斷

  • parse 方法片斷:

parse 方法片斷

就拿 format 方法來講,考慮以下的併發情景:多線程

  • 線程A 此時調用 calendar.setTime(date1),而後 線程A 被中斷;
  • 接着 線程B 執行,而後調用 calendar.setTime(date2),而後 線程B 被中斷;
  • 接着又是 線程A 執行,可是此時的 calendar 已經和以前的不一致了,因此便致使了併發問題。

因此由於這個共享的 calendar 對象,SimpleDateFormat 並非一個線程安全的類,咱們寫一段代碼來測試下。併發

(1)定義 DateFormatWrapper 類,來包裝對 SimpleDateFormat 的調用:app

public class DateFormatWrapper {

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

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

    public static Date parse(String str) throws ParseException {
        return SDF.parse(str);
    }
    
}

(2)而後寫一個 DateFormatTest,開啓多個線程來使用 DateFormatWrapperide

public class DateFormatTest {

    public static void main(String[] args) throws Exception {
        ExecutorService threadPool = Executors.newCachedThreadPool(); // 建立無大小限制的線程池

        List<Future<?>> futures = new ArrayList<>();

        for (int i = 0; i < 9; i++) {
            DateFormatTask task = new DateFormatTask();
            Future<?> future = threadPool.submit(task); // 將任務提交到線程池

            futures.add(future);
        }

        for (Future<?> future : futures) {
            try {
                future.get();
            } catch (ExecutionException ex) { // 運行時若是出現異常則進入 catch 塊
                System.err.println("執行時出現異常:" + ex.getMessage());
            }
        }

        threadPool.shutdown();
    }

    static class DateFormatTask implements Callable<Void> {

        @Override
        public Void call() throws Exception {
            String str = DateFormatWrapper.format(
                    DateFormatWrapper.parse("2017-07-17 16:54:54"));
            System.out.printf("Thread(%s) -> %s\n", Thread.currentThread().getName(), str);

            return null;
        }

    }
}

某次運行的結果:
某次運行的結果測試

能夠發現,SimpleDateFormat 在多線程共享的狀況下,不只可能會出現結果錯誤的狀況,還可能會因爲併發訪問致使運行異常。固然,咱們確定有解決的辦法:this

  1. DateFormatWrapperformatparse 方法加上 synchronized 關鍵字,壞處就是前面提到的這會加大線程間的競爭和切換而下降效率;
  2. 不使用全局的 SimpleDateFormat 對象,而是每次使用 formatparse 方法都新建一個 SimpleDateFormat 對象,壞處也很明顯,每次調用 format 或者 parse 方法都要新建一個 SimpleDateFormat,這會加大 GC 的負擔;
  3. 使用 ThreadLocalThreadLocal<SimpleDateFormat> 能夠爲每一個線程提供一個獨立的 SimpleDateFormat 對象,建立的 SimpleDateFormat 對象個數最多和線程個數相同,相比於 (1),使用ThreadLocal不存在線程間的競爭;相比於 (2),使用ThreadLocal建立的 SimpleDateFormat 對象個數也更加合理(不會超過線程的數量)。

咱們使用 ThreadLocal 來對 DateFormatWrapper 進行修改,使得每一個線程使用單獨的 SimpleDateFormat編碼

public class DateFormatWrapper {

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

    };

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

    public static Date parse(String str) throws ParseException {
        return SDF.get().parse(str);
    }

}

若是使用 Java8,則初始化 ThreadLocal 對象的代碼能夠改成:

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

而後再運行 DateFormatTest,便始終是預期的結果:
正確的結果


咱們已經看到了 ThreadLocal 的功能,那 ThreadLocal 是如何實現爲每一個線程提供一份共享變量的拷貝呢?

在使用 ThreadLocal 時,當前線程訪問 ThreadLocal 中包含的變量是經過 get() 方法,因此首先來看這個方法的實現:

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

經過代碼能夠猜想:

  • 在某個地方(其實就是在 ThreadLocal 的內部),JDK 實現了一個相似於 HashMap 的類,叫 ThreadLocalMap,該 「Map」 的鍵類型爲 ThreadLocal<T>,值類型爲 T
  • 而後每一個線程都關聯着一個 ThreadLocalMap 對象,而且能夠經過 getMap(Thread t) 方法來得到 線程t 關聯的 ThreadLocalMap 對象;
  • ThreadLocalMap 類有個以 ThreadLocal 對象爲參數的 getEntry(ThreadLocal) 的方法,用來得到當前 ThreadLocal 對象關聯的 Entry 對象。一個 Entry 對象就是一個鍵值對,鍵(key)是 ThreadLocal 對象,值(value)是該 ThreadLocal 對象包含的變量(即 T)。

查看 getMap(Thread) 方法:
getMap(Thread)

直接返回的就是 t.threadLocals,原來在 Thread 類中有一個就叫 threadLocalsThreadLocalMap 的變量:
 Thread 的 threadLocals 變量

因此每一個 Thread 都會擁有一個 ThreadLocalMap 變量,來存放屬於該 Thread 的全部 ThreadLocal 變量。這樣來看的話,ThreadLocal就至關於一個調度器,每次調用 get 方法的時候,都會先找到當前線程的 ThreadLocalMap,而後再在這個 ThreadLocalMap 中找到對應的線程本地變量。

ThreadLocal 的 get() 方法的流程

而後咱們來看看當 mapnull(即第一次調用 get())時調用的 setInitialValue() 方法:

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

該方法首先會調用 initialValue() 方法來得到該 ThreadLocal 對象中須要包含的變量 —— 因此這就是爲何使用 ThreadLocal 是須要繼承 ThreadLocal 時並覆寫 initialValue() 方法,由於這樣才能讓 setInitialValue() 調用 initialValue() 從而獲得 ThreadLocal 包含的初始變量;而後就是當 map 不爲 null 的時候,將該變量(value)與當前ThreadLocal對象(this)在 map 中進行關聯;若是 mapnull,則調用 createMap 方法:

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

createMap 會調用 ThreadLocalMap 的構造方法來建立一個 ThreadLocalMap 對象:
ThreadLocalMap 的構造方法

能夠看到該方法經過一個 ThreadLocal 對象(firstKey)和該 ThreadLocal 包含的對象(firstValue)構造了一個 ThreadLocalMap 對象,使得該 map 在構造完畢時候就包含了這樣一個鍵值對(firstKey -> firstValue)。


爲啥須要使用 Map 呢?由於一個線程可能有多個 ThreadLocal 對象,多是包含 SimpleDateFormat,也多是包含一個數據庫鏈接 Connection,因此不一樣的變量須要經過對應的 ThreadLocal 對象來快速查找 —— 那麼 Map 固然是最好的方式。


ThreadLocal 還提供了修改和刪除當前包含對象的方法,修改的方法爲 set,刪除的方法爲 remove

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

很好理解,若是當前 ThredLocal 尚未包含值,那麼就調用 createMap 來初始化當前線程的 ThreadLocalMap 對象,不然直接在 map 中修改當前 ThreadLocalthis)包含的值。

public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        m.remove(this);
}

remove 方法就是得到當前線程的 ThreadLocalMap 對象,而後調用這個 mapremove(ThreadLocal) 方法。查看 ThreadLocalMapremove(ThreadLocal) 方法的實現:
remove(ThreadLocal)

邏輯就是先找到參數(ThreadLocal對象)對應的 Entry,而後調用 Entryclear() 方法,再調用 expungeStaleEntry(i)i 爲該 EntrymapEntry 數組中的索引。

(1)首先來看看 e.clear() 作了什麼。

查看 ThreadLocalMap 的源代碼,咱們能夠發現這個 「Map」 的 Entry 的實現以下:
Entry 的實現

能夠看到,該 Entry 類繼承自 WeakReference<ThreadLocal<?>>,因此 Entry 是一個 WeakReference(弱引用),並且該 WeakReference 包含的是一個 ThreadLocal 對象 —— 於是每一個 Entry 是一個弱引用的 ThreadLocal 對象(又由於 Entry 包括了一個 value 變量,因此該 Entry 構成了一個 ThreadLocal -> Object 的鍵值對),而 Entryclear() 方法,是繼承自 WeakReference,做用就是將 WeakReference 包含的對象的引用設置爲 null

clear() 方法

咱們知道對於一個弱引用的對象,一旦該對象再也不被其餘對象引用(好比像 clear() 方法那樣將對象引用直接設置爲 null),那麼在 GC 發生的時候,該對象便會被 GC 回收。因此讓 Entry 做爲一個 WeakReference,配合 ThreadLocalremove 方法,能夠及時清除某個 Entry 中的 ThreadLocalEntrykey)。

(2)expungeStaleEntry(i)的做用

先來看 expungeStaleEntry 的前一半代碼:

expungeStaleEntry 的前一半代碼

expungeStaleEntry 這部分代碼的做用就是將 i 位置上的 Entryvalue 設置爲 null,以及將 Entry 的引用設置爲 null。爲何要這作呢?由於前面調用 e.clear(),只是將 Entrykey 設置爲 null 而且可使其在 GC 是被快速回收,可是 Entryvalue 在調用 e.clear() 後並不會爲 null —— 因此若是不對 value 也進行清除,那麼就可能會致使內存泄漏了。所以expungeStaleEntry 方法的一個做用在於能夠把須要清除的 Entry 完全的從 ThreadLocalMap 中清除(keyvalueEntry 所有設置爲 null)。可是 expungeStaleEntry 還有另外的功能:看 expungeStaleEntry 的後一半代碼:

expungeStaleEntry 的後一半代碼

做用就是掃描位置 staleSlot 以後的 Entry 數組(直到某一個爲 null 的位置),清除每一個 keyThreadLocal) 爲 nullEntry,因此使用 expungeStaleEntry 能夠下降內存泄漏的機率。可是若是某些 ThreadLocal 變量不須要使用可是卻沒有調用到 expungeStaleEntry 方法,那麼就會致使這些 ThreadLocal 變量長期的貯存在內存中,引發內存浪費或者泄露 —— 因此,若是肯定某個 ThreadLocal 變量已經不須要使用,須要及時的使用 ThreadLocalremove() 方法(ThreadLocalgetset 方法也會調用到 expungeStaleEntry),將其從內存中清除。

相關文章
相關標籤/搜索