來講講你對ThreadLocal的理解

紀莫發表於2020-09-10

前言

面試的時候被問到ThreadLocal的相關知識,沒有回答好(奶奶的,現在感覺問啥都能被問倒),所以我決定先解決這幾次面試中都遇到的高頻問題,把這幾個硬骨頭都能理解的透徹的說出來了,感覺最起碼不能總是一輪遊。

ThreadLocal介紹

ThreadLocal是JDK1.2開始就提供的一個用來儲存執行緒本地變數的類。ThreadLocal中的變數是在每個執行緒中獨立存在的,當多個執行緒訪問ThreadLocal中的變數的時候,其實都是訪問的自己當前執行緒的記憶體中的變數,從而保證的變數的執行緒安全。

我們一般在使用ThreadLocal的時候都是為了解決執行緒中存在的變數競爭問題。其實解決這類問題,通常大家也會想到使用synchronized來加鎖解決。

例如在解決SimpleDateFormat的執行緒安全的時候。SimpleDateFormat是非執行緒安全的,它裡面無論的是format()方法還是parse()方法,都有使用它自己內部的一個Calendar類的物件,format方法是設定時間,parse()方法裡面是先呼叫Calendar的clear()方法,然後又呼叫了Calendar的set()方法(賦值),如果一個執行緒剛呼叫了set()進行賦值,這個時候又來了一個執行緒直接呼叫了clear()方法,那麼這個parse()方法執行的結果就會有問題的。
解決辦法一
將使用SimpleDateformat的方法加上synchronized,這樣雖然保證了執行緒安全,但卻降低了效率,同一時間只有一個執行緒能使用格式化時間的方法。

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

public static synchronized String formatDate(Date date){
    return simpleDateFormat.format(date);
}

解決辦法二
將SimpleDateFormat的物件,放到ThreadLocal裡面,這樣每個執行緒中都有一個自己的格式物件的副本了。互不干擾,從而保證了執行緒安全。

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

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

ThreadLocal的原理

我們先看一下ThreadLocal是怎麼使用的。

ThreadLocal<Integer> threadLocal99 = new ThreadLocal<Integer>();
threadLocal99.set(3);
int num = threadLocal99.get();
System.out.println("數字:"+num);
threadLocal99.remove();
System.out.println("數字Empty:"+threadLocal99.get());

執行結果:

數字:3
數字Empty:null

使用起來很簡單,主要是將變數放到ThreadLocal裡面,線上程執行過程中就可以取到,當執行完成後在remove掉就可以了,只要沒有呼叫remove()當前執行緒在執行過程中都是可以拿到變數資料的。
因為是放到了當前執行的執行緒中,所以ThreadLocal中的變數值只能當前執行緒來使用,從而保證的了執行緒安全(當前執行緒的子執行緒其實也是可以獲取到的)。

來看一下ThreadLocal的set()方法原始碼

public void set(T value) {
   // 獲取當前執行緒
   Thread t = Thread.currentThread();
   // 獲取ThreadLocalMap
   ThreadLocal.ThreadLocalMap map = getMap(t);
   // ThreadLocalMap 物件是否為空,不為空則直接將資料放入到ThreadLocalMap中
   if (map != null)
       map.set(this, value);
   else
       createMap(t, value); // ThreadLocalMap物件為空,則先建立物件,再賦值。
}

我們看到變數都是存放在了ThreadLocalMap這個變數中的。那麼ThreadLocalMap又是怎麼來的呢?

ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}
public class Thread implements Runnable {
	... ...
	/* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;
    ... ...
}

通過上面的原始碼,我們發現ThreadLocalMap變數是當前執行執行緒中的一個變數,所以說,ThreadLocal中存放的資料其實都是放到了當前執行執行緒中的一個變數裡面了。也就是儲存在了當前的執行緒物件裡了,別的執行緒裡面是另一個執行緒物件了,拿不到其他執行緒物件中的資料,所以資料自然就隔離開了。

那麼ThreadLocalMap是怎麼儲存資料的呢?
ThreadLocalMap 是ThreadLocal類裡的一個內部類,雖然類的名字上帶著Map但卻沒有實現Map介面,只是結構和Map類似而已。
在這裡插入圖片描述
ThreadLocalMap內部其實是一個Entry陣列,Entry是ThreadLocalMap中的一個內部類,繼承自WeakReference,並將ThreadLocal型別的物件設定為了Entry的Key,以及對Key設定成弱引用。
ThreadLocalMap的內部資料結構,就大概是這樣的key,value組成的Entry的陣列集合。
在這裡插入圖片描述
和真正的Map還是有區別的,沒有連結串列了,這樣在解決key的hash衝突的時候措施肯定就和HashMap不一樣了。
一個執行緒中是可以建立多個ThreadLocal物件的,多個ThreadLocal物件就會存放多個資料,那麼在ThreadLocalMap中就會以陣列的形式存放這些資料。
我們來看一下具體的ThreadLocalMap的set()方法的原始碼

/**
 * Set the value associated with key.
 * @param key the thread local object
 * @param value the value to be set
 */
private void set(ThreadLocal<?> key, Object value) {

    // We don't use a fast path as with get() because it is at
    // least as common to use set() to create new entries as
    // it is to replace existing ones, in which case, a fast
    // path would fail more often than not.

    Entry[] tab = table;
    int len = tab.length;
    // 定位在陣列中的位置
    int i = key.threadLocalHashCode & (len-1);

    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();
        // 如果當前位置不為空,並且當前位置的key和傳過來的key相等,那麼就會覆蓋當前位置的資料
        if (k == key) {
            e.value = value;
            return;
        }
        // 如果當前位置為空,則初始化一個Entry物件,放到當前位置。
        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }
    // 如果當前位置不為空,並且當前位置的key也不等於要賦值的key ,那麼將去找下一個空位置,直接將資料放到下一個空位置處。
    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

我們從set()方法中可以看到,處理邏輯有四步。

  • 第一步先根據Threadlocal物件的hashcode和陣列長度做與運算獲取資料應該放在當前陣列中的位置。

  • 第二步就是判斷當前位置是否為空,為空的話就直接初始化一個Entry物件,放到當前位置。

  • 第三步如果當前位置不為空,而當前位置的Entry中的key和傳過來的key一樣,那麼直接覆蓋掉當前位置的資料。

  • 第四步如果當前位置不為空,並且當前位置的Entry中的key和傳過來的key
    也不一樣,那麼就會去找下一個空位置,然後將資料存放到空位置(陣列超過長度後,會執行擴容的);

在get的時候也是類似的邏輯,先通過傳入的ThreadLocal的hashcode獲取在Entry陣列中的位置,然後拿當前位置的Entry的Key和傳入的ThreadLocal對比,相等的話,直接把資料返回,如果不相等就去判斷和陣列中的下一個值的key是否相等。。。

private Entry getEntry(ThreadLocal<?> key) {
    int i = key.threadLocalHashCode & (table.length - 1);
    Entry e = table[i];
    if (e != null && e.get() == key)
        return e;
    else
        return getEntryAfterMiss(key, i, e);
}
/**
 * Version of getEntry method for use when key is not found in
 * its direct hash slot.
 *
 * @param  key the thread local object
 * @param  i the table index for key's hash code
 * @param  e the entry at table[i]
 * @return the entry associated with key, or null if no such
 */
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
    Entry[] tab = table;
    int len = tab.length;

    while (e != null) {
        ThreadLocal<?> k = e.get();
        if (k == key)
            return e;
        if (k == null)
            expungeStaleEntry(i);
        else
            i = nextIndex(i, len);
        e = tab[i];
    }
    return null;
}

我們上文一直說,ThreadLocal是儲存在單個執行緒中的資料,每個執行緒都有自己的資料,但是實際ThreadLocal裡面的真正的物件資料,其實是儲存在堆裡面的,而執行緒裡面只是儲存了物件的引用而已。
並且我們在使用的時候通常需要在上一個執行緒執行的方法的上下文共享ThreadLocal中的變數。
例如我的主執行緒是在某個方法中執行程式碼呢,但是這個方法中有一段程式碼時新建立了一個執行緒,在這個執行緒裡面還使用了我這個正在執行的方法裡面的定義的ThreadLocal裡面的變數。這個時候,就是需要從新執行緒裡面呼叫外面執行緒的資料,這個就需要執行緒間共享了。這種子父執行緒共享資料的情況,ThreadLocal也是支援的。
例如:

 ThreadLocal threadLocalMain = new InheritableThreadLocal();
 threadLocalMain.set("主執行緒變數");
 Thread t = new Thread() {
     @Override
     public void run() {
         super.run();
         System.out.println( "現在獲取的變數是 =" + threadLocalMain.get());
     }
 };
 t.start();

執行結果:

現在獲取的變數是 =主執行緒變數

上面這樣的程式碼就能實現子父執行緒共享資料的情況,重點是使用InheritableThreadLocal來實現的共享。
那麼它是怎麼實現資料共享的呢?
在Thread類的init()方法中有這麼一段程式碼:

if (inheritThreadLocals && parent.inheritableThreadLocals != null)
            this.inheritableThreadLocals =ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);

這段程式碼的意思是,在建立執行緒的時候,如果當前執行緒的inheritThreadLocals變數和父執行緒的inheritThreadLocals變數都不為空的時候,會將父執行緒的inheritThreadLocals變數中的資料,賦給當前執行緒中的inheritThreadLocals變數。

ThreadLocal的記憶體洩漏問題

上文我們也提到過,ThreadLocal中的ThreadLocalMap裡面的Entry物件是繼承自WeakReference類的,說明Entry的key是一個弱引用。
在這裡插入圖片描述

弱引用是用來描述那些非必須的物件,弱引用的物件,只能生存到下一次垃圾收集發生為止。當垃圾收集器開始工作,無論當前記憶體是否足夠,都會回收掉只被弱引用關聯的物件。

這個弱引用還是ThreadLocal物件本身,所以一般線上程執行完成後,ThreadLocal物件就會變成null了,而為null的弱引用物件,在下一次GC的時候就會被清除掉,這樣Entry的Key的記憶體空間就被釋放出來了,但是Entry的value還在佔用的記憶體,如果執行緒是被複用的(例如執行緒池中的執行緒),那麼這裡面的value值就會越來越多,最終就導致了記憶體洩漏。

防止記憶體洩漏的辦法就是在每次使用完ThreadLocal的時候都去執行以下remove()方法,就可以把key和value的空間都釋放了。

那既然容易產生記憶體洩漏,為什麼還要設定成弱引用的呢?
如果正常情況下應該是強引用,但是強引用只要引用關係還在就一直不會被回收,所以如果執行緒被複用了,那麼Entry中的Key和Value都不會被回收,這樣就造成了Key和Value都會發生記憶體洩漏了。

相關文章