研二學妹面試位元組,竟倒在了ThreadLocal上,這是不要應屆生還是不要女生啊?

JavaBuild發表於2024-05-27

一、寫在開頭

    今天和一個之前研二的學妹聊天,聊及她上週面試位元組的情況,著實感受到了Java後端現在找工作的壓力啊,記得在18,19年的時候,研究生計算機專業的學生,背背八股文找個Java開發工作毫無問題,但現在即便你是應屆生,問的考題也非常的深入和細節了,只會背八股,沒有一定的程式碼量和專案積累,根本找不到像樣的工作,具體聊天內容如下:

image

既然大廠的面試都拷問到ThreadLocal了,那今天build哥就花點時間也來溫習一下這個知識點吧,儘可能整理的細緻一點!🤓🤓

二、ThreadLocal簡介

2.1 ThreadLocal的作用

處理併發程式設計的時候,其核心問題是當多個執行緒去訪問共享變數時,因為順序、資源分配等原因帶來了資料的不準確,我們叫這種情況為執行緒不安全,為了解決執行緒安全問題,在Java中可以採用Lock、 synchronzed關鍵字等方式,但這種方式對於沒有持有鎖的執行緒來說會阻塞,這樣以來在時間效能上就有所損失。

為了解決這個問題,Java的lang包中誕生出了一個類,名為 ThreadLocal,見名知意,它被視為執行緒的“本地變數”,主要用來儲存各執行緒的私有資料,當多個執行緒訪問同一個ThreadLocal變數時,實際上它們訪問的是各自執行緒本地儲存的副本,而不是共享變數本身。因此,每個執行緒都可以獨立地修改自己的副本,而不會影響到其他執行緒。這種以空間換時間的方式,可以大大的提升處理時間。

2.2 ThreadLocal的使用案例

上面瞭解了它的特性後,我們來寫一個小demo感受一下ThreadLocal的使用。

public class TestService implements Runnable{
        // SimpleDateFormat 不是執行緒安全的,所以每個執行緒都要有自己獨立的副本
        //共享變數
        private static final ThreadLocal<SimpleDateFormat> formatter = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyyMMdd"));

        public static void main(String[] args) throws InterruptedException {
            TestService obj = new TestService();
            //迴圈建立5個執行緒
            for(int i=0 ; i<5; i++){
                Thread t = new Thread(obj, ""+i);
                Thread.sleep(new Random().nextInt(1000));
                t.start();
            }
        }

        @Override
        public void run() {
            System.out.println("Thread:"+Thread.currentThread().getName()+" default Formatter = "+formatter.get().toPattern());
            try {
                Thread.sleep(new Random().nextInt(1000));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //formatter pattern is changed here by thread, but it won't reflect to other threads
            //設定副本的值
            formatter.set(new SimpleDateFormat());
            System.out.println("Thread:"+Thread.currentThread().getName()+" formatter = "+formatter.get().toPattern());
        }
}

輸出:

Thread:0 default Formatter = yyyyMMdd
Thread:1 default Formatter = yyyyMMdd
Thread:2 default Formatter = yyyyMMdd
Thread:1 formatter = yy-M-d ah:mm
Thread:0 formatter = yy-M-d ah:mm
Thread:3 default Formatter = yyyyMMdd
Thread:2 formatter = yy-M-d ah:mm
Thread:3 formatter = yy-M-d ah:mm
Thread:4 default Formatter = yyyyMMdd
Thread:4 formatter = yy-M-d ah:mm

從輸出中可以看出,雖然 Thread-0 已經改變了 formatter 的值,但 Thread-1 預設格式化值與初始化值相同並沒有被修改,其他執行緒也一樣,這說明每個執行緒獲取ThreadLocal變數值的時候,確訪問的時執行緒本地的副本值。

三、ThreadLocal的實現原理

我們從Thread原始碼入手,一步步的跟進,去探索ThreadLocal的實現原理。首先,在Thread的原始碼中,我們看到了這樣的兩句定義語句:

public class Thread implements Runnable {
    //......
    //與此執行緒有關的ThreadLocal值。由ThreadLocal類維護
    ThreadLocal.ThreadLocalMap threadLocals = null;

    //與此執行緒有關的InheritableThreadLocal值。由InheritableThreadLocal類維護
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
    //......
}

threadLocals 、inheritableThreadLocals 都是ThreadLocalMap變數,而這個Map我們可以看作是ThreadLocal的定製化HashMap,用來儲存執行緒本地變數的容器,是一個靜態內部類,而這兩個變數的值初始為null,只有當前執行緒呼叫 ThreadLocal 類的 set或get方法時才建立它們,那我們繼續去看set/get方法。

【set方法解析】

public void set(T value) {
	//1. 獲取當前執行緒例項物件
    Thread t = Thread.currentThread();

	//2. 透過當前執行緒例項獲取到ThreadLocalMap物件
    ThreadLocalMap map = getMap(t);

    if (map != null)
	   //3. 如果Map不為null,則以當前ThreadLocal例項為key,值為value進行存入
       map.set(this, value);
    else
	  //4.map為null,則新建ThreadLocalMap並存入value
      createMap(t, value);
}

在ThreadLocal的set方法中透過getMap()方法去獲取當前執行緒的ThreadLocalMap物件,並對獲取到的map進行判斷,我們跟如到getMap方法中去,發現其實裡面返回的是初始化定義的threadLocals變數。

ThreadLocalMap getMap(Thread t) {
   return t.threadLocals;
}

在threadLocals沒有被呼叫初始化方法重新賦值的時候,它為null(不為null時,直接set進行賦值,當前ThreadLocal例項為key,值為value),set方法中會去呼叫createMap(t,value)進行處理,我們繼續跟入這個方法的原始碼去看看:

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

我們可以看到,在這個方法內部,會去新構造一個ThreadLocalMap的例項,並將value值初始化進去,並賦給threadLocals。

看完了set方法的底層實現我們知道:

  1. 最終變數儲存的位置在ThreadLocalMap裡,ThreadLocal可以視為這個Map的封裝;
  2. 無論如何最終threadLocals儲存的資料都是以執行緒為key,對應的區域性變數為值得對映表;
  3. 因為對映表的原因,確保了每個執行緒的區域性變數都時獨立的。

【get方法解析】

看完了set的原始碼,我們繼續來看看get方法的底層實現吧,既然有存(set)就有取(get),get 方法提供的就是獲取當前執行緒中 ThreadLocal 的變數值的功能!

public T get() {
  //1. 獲取當前執行緒的例項物件
  Thread t = Thread.currentThread();

  //2. 獲取當前執行緒的ThreadLocalMap
  ThreadLocalMap map = getMap(t);
  if (map != null) {
	//3. 獲取map中當前ThreadLocal例項為key的值的entry
    ThreadLocalMap.Entry e = map.getEntry(this);

    if (e != null) {
      @SuppressWarnings("unchecked")
	  //4. 當前entitiy不為null的話,就返回相應的值value
      T result = (T)e.value;
      return result;
    }
  }
  //5. 若map為null或者entry為null的話透過該方法初始化,並返回該方法返回的value
  return setInitialValue();
}

我們上面提到了執行緒的變數值是和執行緒的ThreadLocal有對映關係的,所以這裡將當前執行緒的ThreadLocal作為key去map中獲取值,若map為null或者entry為null的話透過該方法初始化,並返回該方法返回的value,我們去看看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;
}
protected T initialValue() {
    return null;
}

這個方法裡的實現和set幾乎一模一樣,這裡呼叫了一個protected訪問修飾符的方法initialValue(),這個方法可以被子類重寫。

我們在2.2使用案例中寫道的ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyyMMdd"));這是在Java8中的寫法,等價於:

private static final ThreadLocal<SimpleDateFormat> formatter = new ThreadLocal<SimpleDateFormat>(){
    @Override
    protected SimpleDateFormat initialValue(){
        return new SimpleDateFormat("yyyyMMdd");
    }
};

setInitialValue 方法的目的是確保每個執行緒在第一次嘗試訪問其 ThreadLocal 變數時都有一個合適的值。

3.1 ThreadLocalMap

上面我們也說了,ThreadLocalMap是ThreadLocal的靜態內部類,而每個執行緒獨立的變數副本儲存也是在這個Map中,它是一個定製的雜湊表,底層維護了一個Entry 型別的陣列型別的陣列 table,它的內部提供了set、remove、getEntry等方法。

Entry靜態內部類
這個Entry又是ThreadLocalMap的一個靜態內部類,我們看一下它的原始碼:

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

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

Entry 繼承了弱引用 WeakReference<ThreadLocal<?>>,它的 value 欄位用於儲存與特定 ThreadLocal 物件關聯的值,key 為弱引用,意味著當 ThreadLocal 外部強引用被置為 null(ThreadLocalInstance=null)時,根據可達性分析,ThreadLocal 例項此時沒有任何一條鏈路引用它,所以系統 GC 的時候 ThreadLocal 會被回收。這種操作看似利用垃圾回收器節省了記憶體空間,實則存在一個風險,也就是我們下面要說的記憶體洩露問題!

只具有弱引用的物件,擁有更為短暫的生命週期,在GC執行緒掃描到它所在的記憶體區域的時候,一旦發現了只有弱引用的物件的時候,不管記憶體夠不夠用都會將其回收掉

四、ThreadLocal記憶體洩漏問題

4.1 記憶體洩漏的原因

如果非要問ThreadLocal有什麼缺點的話,那就是使用不當的時候,會帶來記憶體洩漏問題。

記憶體洩漏 是指程式中已動態分配的堆記憶體由於某種原因程式未釋放或無法釋放,造成系統記憶體的浪費,導致程式執行速度減慢甚至系統崩潰等嚴重後果。

根據3.1中的分析,我們知道ThreadLocalMap中的使用的key是ThreadLocal的弱引用,Value為強引用,如果ThreadLocal沒有被強引用的話,key會被GC掉,而value依舊存在,若我們採用任何措施的前提下,執行緒一直執行,那這些value值就會一直存在,過多的佔用記憶體,導致記憶體洩漏!

4.2 如何解決記憶體洩漏

如何解決記憶體洩漏呢,只需要記得在使用完 ThreadLocal 中儲存的內容後將它 remove 掉就可以了。

//ThreadLocal提供的清理方法
public void remove() {
	//1. 獲取當前執行緒的ThreadLocalMap
	ThreadLocalMap m = getMap(Thread.currentThread());
 	if (m != null)
		//2. 從map中刪除以當前ThreadLocal例項為key的鍵值對
		m.remove(this);
}
/**
 * ThreadLocalMap中的remove方法
 */
private void remove(ThreadLocal<?> key) {
    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)]) {
        if (e.get() == key) {
			//將entry的key置為null
            e.clear();
			//將該entry的value也置為null
            expungeStaleEntry(i);
            return;
        }
    }
}

除此之外,我們還可以使用Java 8引入的InheritableThreadLocal來替代ThreadLocal,它可以在子執行緒中自動繼承父執行緒的執行緒區域性變數值,從而避免在建立新執行緒時重複設定值的問題。但是同樣需要注意及時清理資源以避免記憶體洩漏。

五、執行緒間區域性變數傳值問題

上面我們提到的Java8中引入的InheritableThreadLocal類,這是實現父子執行緒間區域性變數傳值的關鍵!
InheritableThreadLocal存在於java.lang包中是ThreadLocal的擴充套件,它有一個特性,那就是當建立一個新的執行緒時,如果父執行緒中有一個 InheritableThreadLocal 變數,那麼子執行緒將會繼承這個變數的值。這意味著子執行緒可以訪問其父執行緒為此類變數設定的值。我們寫一個小demo感受一下!

public class TestService{
    // 建立一個 InheritableThreadLocal 變數
    private static final InheritableThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>();

    public static void main(String[] args) {
        // 在主執行緒中設定值
        inheritableThreadLocal.set("這是父執行緒的值");

        System.out.println("父執行緒中的值: " + inheritableThreadLocal.get());

        // 建立一個子執行緒
        Thread childThread = new Thread(() -> {
            // 在子執行緒中嘗試獲取值,由於使用了 InheritableThreadLocal,這裡會獲取到父執行緒中設定的值
            System.out.println("子執行緒中的值: " + inheritableThreadLocal.get());
        });

        // 啟動子執行緒
        childThread.start();

        // 等待子執行緒執行完成
        try {
            childThread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 主執行緒結束時清除值,防止潛在的記憶體洩漏
        inheritableThreadLocal.remove();
    }
}

輸出:

父執行緒中的值: 這是父執行緒的值
子執行緒中的值: 這是父執行緒的值

輸出不出所料,在子執行緒中獲取的其實是父執行緒設定的inheritableThreadLocal值。

5.1 父子執行緒區域性變數傳值的實現原理

我們看到上面的輸出後,應該思考這樣的一個問題:子執行緒是怎麼拿到父執行緒的inheritableThreadLocal值得呢?其實要從子執行緒的初始化開始說起,線上程Thread的內部,有著這樣的一個初始化方法:

private void init(ThreadGroup g, Runnable target, String name,
                  long stackSize, AccessControlContext acc,
                  // 該引數一般預設是 true
                  boolean inheritThreadLocals) {
  // 省略大部分程式碼
  Thread parent = currentThread();
  
  // 複製父執行緒的 inheritableThreadLocals 屬性,實現父子執行緒區域性變數共享
  if (inheritThreadLocals && parent.inheritableThreadLocals != null) {
       this.inheritableThreadLocals =
    ThreadLocal.createInheritedMap(parent.inheritableThreadLocals); 
  }
    // 省略部分程式碼
}

在這裡將父執行緒的inheritableThreadLocals賦值了進來,我們跟入createInheritedMap方法中繼續解析:

// 返回一個ThreadLocalMap,傳值為父執行緒的
static ThreadLocalMap createInheritedMap(ThreadLocalMap parentMap) {
  return new ThreadLocalMap(parentMap);
}
//ThreadLoaclMap構建的過程中會呼叫該構造方法
private ThreadLocalMap(ThreadLocalMap parentMap) {
  Entry[] parentTable = parentMap.table;
  int len = parentTable.length;
  setThreshold(len);
  table = new Entry[len];
    // 一個個複製父執行緒 ThreadLocalMap 中的資料
  for (int j = 0; j < len; j++) {
    Entry e = parentTable[j];
    if (e != null) {
      @SuppressWarnings("unchecked")
      ThreadLocal<Object> key = (ThreadLocal<Object>) e.get();
      if (key != null) {
        // childValue 方法呼叫的是 InheritableThreadLocal#childValue(T parentValue)
        Object value = key.childValue(e.value);
        Entry c = new Entry(key, value);
        int h = key.threadLocalHashCode & (len - 1);
        while (table[h] != null)
          h = nextIndex(h, len);
        table[h] = c;
        size++;
      }
    }
  }
}

在這個構造方法中,我們終於看到了InheritableThreadLocal的身影,childValue()方法就是其中的一個方法,用來給子執行緒賦父執行緒的inheritableThreadLocals值;其實InheritableThreadLocal的原始碼非常非常的簡單,大部分的實現都取自父類ThreadLocal。

public class InheritableThreadLocal<T> extends ThreadLocal<T> {

    protected T childValue(T parentValue) {
        return parentValue;
    }

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

六、總結

OK,基於學妹在位元組面試的考點,我們又梳理了一遍ThreadLocal,這個類大家還是要好好學一學的,畢竟在日後的工作中,我們肯定會使用到,譬如用它來儲存使用者登入資訊,這樣在同一個執行緒中的任何地方都可以獲取到登入資訊;用於儲存事務上下文,這樣在同一個執行緒中的任何地方都可以獲取到事務上下文等等。

七、結尾彩蛋

如果本篇部落格對您有一定的幫助,大家記得留言+點贊+收藏呀。原創不易,轉載請聯絡Build哥!
image

如果您想與Build哥的關係更近一步,還可以關注“JavaBuild888”,在這裡除了看到《Java成長計劃》系列博文,還有提升工作效率的小筆記、讀書心得、大廠面經、人生感悟等等,歡迎您的加入!
image

相關文章