併發——深入分析ThreadLocal的實現原理

特務依昂發表於2020-04-16

一、前言

  這篇部落格來分析一下ThreadLocal的實現原理以及常見問題,由於現在時間比較晚了,我就不廢話了,直接進入正題。


二、正文

2.1 ThreadLocal是什麼

  在講實現原理之前,我先來簡單的說一說ThreadLocal是什麼。ThreadLocal被稱作執行緒區域性變數,當我們定義了一個ThreadLocal變數,所有的執行緒共同使用這個變數,但是對於每一個執行緒來說,實際操作的值是互相獨立的。簡單來說就是,ThreadLocal能讓執行緒擁有自己內部獨享的變數。舉一個簡單的例子:

// 定義一個執行緒共享的ThreadLocal變數
static ThreadLocal<Integer> tl = new ThreadLocal<>();

public static void main(String[] args) {
    
    // 建立第一個執行緒
    Thread t1 = new Thread(() -> {
        // 設定ThreadLocal變數的初始值,為1
        tl.set(1);
        // 迴圈列印ThreadLocal變數的值
        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() + "----" + tl.get());
            // 每次列印完讓值 + 1
            tl.set(tl.get() + 1);
        }
    }, "thread1");
    
    // 建立第二個執行緒
    Thread t2 = new Thread(() -> {
         // 設定ThreadLocal變數的初始值,為100,與上一個執行緒區別開
        tl.set(100);
        // 迴圈列印ThreadLocal變數的值
        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() + "----" + tl.get());
            // 每次列印完讓值 - 1
            tl.set(tl.get() - 1);
        }
    }, "thread2");
	// 開啟兩個執行緒
    t1.start();
    t2.start();
    
    tl.remove();
}

  上面的程式碼,執行結果如下(注:每次執行的結果可能不同):

thread1----1
thread2----100
thread1----2
thread2----99
thread1----3
thread2----98
thread1----4
thread2----97
thread1----5
thread2----96
thread1----6
thread2----95
thread1----7
thread2----94
thread1----8
thread2----93
thread1----9
thread2----92
thread1----10
thread2----91

  通過上面的輸出結果我們可以發現,執行緒1執行緒2雖然使用的是同一個ThreadLocal變數儲存值,但是輸出結果中,兩個執行緒的值卻互不影響,執行緒11輸出到10,而執行緒2100輸出到91。這就是ThreadLocal的功能,即讓每一個執行緒擁有自己獨立的變數,多個執行緒之間互不影響。


2.2 ThreadLocal的實現原理

  下面我就就來說一說ThreadLocal是如何做到執行緒之間相互獨立的,也就是它的實現原理。這裡我直接放出結論,後面再根據原始碼分析:每一個執行緒都有一個對應的Thread物件,而Thread類有一個成員變數,它是一個Map集合,這個Map集合的key就是ThreadLocal的引用,而value就是當前執行緒在key所對應的ThreadLocal中儲存的值。當某個執行緒需要獲取儲存在ThreadLocal變數中的值時,ThreadLocal底層會獲取當前執行緒的Thread物件中的Map集合,然後以ThreadLocal作為key,從Map集合中查詢value值。這就是ThreadLocal實現執行緒獨立的原理。也就是說,ThreadLocal能夠做到執行緒獨立,是因為值並不存在ThreadLocal中,而是儲存線上程物件中。下面我們根據ThreadLocal中兩個最重要的方法來確認這一點。


2.3 ThreadLocal中的get方法

  get方法的作用非常簡單,就是執行緒向ThreadLocal中取值,下面我們來看看它的原始碼:

public T get() {
    // 獲取當前執行緒的Thread物件
    Thread t = Thread.currentThread();
    // getMap方法傳入Thread物件,此方法將返回Thread物件中儲存的一個Map集合
    // 這個Map集合的型別為ThreadLocalMap,這是ThreadLoacl的一個內部類
    // 當前執行緒存放在ThreadLocal中的值,實際上存放在這個Map集合中
    ThreadLocalMap map = getMap(t);
    // 如果當前Map集合已經初始化,則直接從Map集合中查詢
    if (map != null) {
        // ThreadLocalMap的key其實就是ThreadLoacl物件的引用
        // 所以要找到執行緒在當前ThreadLoacl中存放的值,就需要以當前ThreadLoacl作為key
        // getEntry方法就是通過key獲取map中的一個key-value,而這裡使用的key就是this
        ThreadLocalMap.Entry e = map.getEntry(this);
        // 如果返回值不為空,表示查詢成功
        if (e != null) {
            @SuppressWarnings("unchecked")
            // 於是獲取對應的value並返回
            T result = (T)e.value;
            return result;
        }
    }
    // 若當前執行緒的ThreadLocalMap還未初始化,或者查詢失敗,則呼叫以下方法
    return setInitialValue();
}

private T setInitialValue() {
    // 此方法預設返回null,但是可以由子類進行重新,根據需求返回需要的值
    T value = initialValue();
    // 獲取當前執行緒的Thread物件
    Thread t = Thread.currentThread();
    // 獲取對應的ThreadLocalMap
    ThreadLocalMap map = getMap(t);
    // 如果Map已經初始化了,就直接往map中加入一個key-value
    // key就是當前ThreadLocal物件的引用,而value就是上面獲取到的value,預設為null
    if (map != null)
        map.set(this, value);
    // 若還沒有初始化,則呼叫createMap建立ThreadLocalMap物件
    else
        createMap(t, value);
    // 返回initialValue方法返回的值,預設為null
    return value;
}

void createMap(Thread t, T firstValue) {
    // 建立ThreadLocalMap物件,構造方法傳入的是第一對放入其中的key-value
    // 這個key也就是當前執行緒第一次呼叫get方法的ThreadLocal物件,也就是當前ThreadLocal物件
    // 而firstValue則是initialValue方法的返回值,預設為null
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

  上面的程式碼非常直觀的驗證了我之前說過的ThreadLocal的實現原理。通過上面的程式碼,我們可以非常直觀的看到,執行緒向ThreadLocal中存放的值,最後都放入了執行緒自己的ThreadLocalMap中,而這個mapkey就是當前ThreadLocal的引用。而ThreadLocal中,獲取執行緒的ThreadLocalMap的方法getMap的程式碼如下:

ThreadLocalMap getMap(Thread t) {
    // 直接返回Thread物件的threadLocals成員變數
    return t.threadLocals;
}

  我們再看看Thread類中的threadLocals變數:

/** 可以看到,ThreadLocalMap是ThreadLocal的內部類 */
ThreadLocal.ThreadLocalMap threadLocals = null;

2.4 ThreadLocal中的set方法

  下面再來看一看ThreadLocalset方法的實現,set方法用來使執行緒向ThreadLocal中存放值(實際上是存放線上程自己的Map中):

public void set(T value) {
    // 獲取當前執行緒的Thread物件
    Thread t = Thread.currentThread();
    // 獲取當前執行緒的ThreadLocalMap
    ThreadLocalMap map = getMap(t);
    // 若map已經初始化,則之際將value放入Map中,對應的key就是當前ThreadLocal的引用
    if (map != null)
        map.set(this, value);
    // 若沒有初始化,則呼叫createMap方法,為當前執行緒t建立ThreadLocalMap,
    // 然後將key-value放入(此方法已經在上面講解get方法是看過)
    else
        createMap(t, value);
}

  這就是set方法的實現,比較簡單。看完上面兩個關鍵方法的實現,相信大家對ThreadLocal的實現已經有了一個比較清晰的認識,下面我們來更加深入的分析ThreadLocal,看看ThreadLocalMap的一些實現細節。


2.5 ThreadLocalMap的中的弱引用

  ThreadLocalMap的實現其實就是一個比較普通的Map集合,它的實現和HashMap類似,所以具體的實現細節我們就不一一講解了,這裡我們只關注它最特別的一個地方,即它內部的節點Entry。我們先來看看Entry的程式碼:

// Entry是ThreadLocalMap的內部類,表示Map的節點
// 這裡繼承了WeakReference,這是java實現的弱引用類,泛型為ThreadLocal
// 表示在這個Map中,作為key的ThreadLocal是弱引用
// (這裡value是強引用,因為沒用WeakReference)
static class Entry extends WeakReference<ThreadLocal<?>> {
    /** 儲存value */
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
        // 將key的值傳入父類WeakReference的構造方法,用弱引用來引用key
        super(k);
        // value則直接使用上面的強引用
        value = v;
    }
}

  可以看到,上面的Entry比較特殊,它繼承自WeakReference型別,這是Java實現的弱引用。在具體講解前,我們先來介紹一下不同型別的引用:

強引用:這是Java中最常見的引用,在沒有使用特殊引用的情況下,都是強引用,比如Object o = new Object()就是典型的強引用。能讓程式設計師通過強引用訪問到的物件,不會被JVM垃圾回收,即使記憶體空間不夠,JVM也不會回收這些物件,而是丟擲記憶體溢位異常;

軟引用:軟引用描述的是一些還有用,但不是必須的物件。被軟引用所引用的物件,也不會被垃圾回收,直到JVM將要發生記憶體溢位異常時,才會將這些物件列為回收物件,進行回收。在JDK1.2之後,提供了SoftReference類實現軟引用;

弱引用:弱引用描述的是非必須的物件,被弱引用所引用的物件,只能生存到下一次垃圾回收前,下一次垃圾回收來臨,此物件就會被回收。在JDK1.2之後,提供了WeakReference類實現弱引用(也就是上面Entry繼承的類);

虛引用:這是最弱的一種引用關係,一個物件是否有虛引用,完全不會對其生存時間產生影響,我們也不能通過一個虛引用訪問物件,使用虛引用的唯一目的就是,能在這個物件被回收時,受到一個系統的通知。JDK1.2之後,提供了PhantomReference實現虛引用;

  介紹完各類引用的概念,我們就可以來分析一下Entry為什麼需要繼承WeakReference類了。從程式碼中,我們可以看到,Entrykey值,也就是ThreadLocal的引用傳入到了WeakReference的構造方法中,也就是說在ThreadLocalMap中,key的引用是弱引用。這表明,當沒有其他強引用指向key時,這個key將會在下一次垃圾回收時被JVM回收。

  為什麼需要這麼做呢?這麼做的目的自然是為了有利於垃圾回收了。如果瞭解過JVM的垃圾回收演算法的應該知道,JVM判斷一個物件是否需要被回收,判斷的依據是這個物件還能否被我們所使用,舉個簡單的例子:

public static void main(String[] args) {
    Object o = new Object();
    o = null;
}

  上面的程式碼中,我們建立了一個物件,並使用強引用o指向它,然後我們將o置為空,這個時候剛剛建立的物件就丟失了,因為我們無法通過任何引用找到這個物件,從而使用它,於是這個物件就需要被回收,這種判斷依據被稱為可達性分析。關於JVM的垃圾回收演算法,可以參考這篇部落格:Java中的垃圾回收演算法詳解

  好,迴歸正題,我們開始分析為什麼ThreadLocalMap需要讓key使用弱引用。假設我們建立了一個ThreadLocal,使用完之後沒有用了,我們希望能夠讓它被JVM回收,於是有了下面這個過程:

// 建立ThreadLocal物件
ThreadLocal tl = new ThreadLocal();

// .....省略使用的過程...

// 使用完成,希望被JVM回收,於是執行以下操作,解除強引用
tl = null;

  我們在使用完ThreadLocal之後,解除對它的強引用,希望它被JVM回收。但是JVM無法回收它,因為我們雖然在此處釋放了對它的強引用,但是它還有其它強引用,那就是Thread物件的ThreadLocalMapkey。我們之前反覆說過,ThreadLocalMapkey就是ThreadLocal物件的引用,若這個引用是一個強引用,那麼在當前執行緒執行完畢,被回收前,ThreadLocalMap不會被回收,而ThreadLocalMap不會被回收,它的key引用的ThreadLocal也就不會回收,這就是問題的所在。而使用弱引用就可以保證,在其他對ThreadLocal的強引用解除後,ThreadLocalMap對它的引用不會影響JVM對它進行垃圾回收。這就是使用弱引用的原因。


2.6 ThreadLocal造成的記憶體溢位問題

  上面描述了對ThreadLocalMapkey使用弱引用,來避免JVM無法回收ThreadLocal的問題,但是這裡卻還有另外一個問題。我們看上面Entry的程式碼發現,key值雖然使用的弱引用,但是value使用的卻是強引用。這會造成一個什麼問題?這會造成key被JVM回收,但是value卻無法被收,key對應的ThreadLocal被回收後,key變為了null,但是value卻還是原來的value,因為被ThreadLocalMap所引用,將無法被JVM回收。若value所佔記憶體較大,執行緒較多的情況下,將持續佔用大量記憶體,甚至造成記憶體溢位。我們通過一段程式碼演示這個問題:

public class Main {

    public static void main(String[] args) {
        // 迴圈建立多個TestClass
        for (int i = 0; i < 100; i++) {
            // 建立TestClass物件
            TestClass t = new TestClass(i);
            // 呼叫反覆
            t.printId();
            // *************注意此處,非常關鍵:為了幫助回收,將t置為null
            t = null;
        }
    }

    static class TestClass {
        int id;
        // 每個TestClass物件對應一個很大的陣列
        int[] arr = new int[100000000];
        // 每個TestClass物件對應一個ThreadLocal物件
        ThreadLocal<int[]> threadLocal = new ThreadLocal<>();

        TestClass(int id) {
            this.id = id;
            // threadLocal存放的就是這個很大的陣列
            threadLocal.set(arr);
        }

        public void printId() {
            System.out.println(id);
        }
    }
}

  上面的程式碼多次建立所佔記憶體非常大的物件,並在建立後,立即解除物件的強引用,讓物件可以被JVM回收。按道理來說,上面的程式碼執行應該不會發生記憶體溢位,因為我們雖然建立了多個大物件,佔用了大量空間,但是這些物件立即就用不到了,可以被垃圾回收,而這個物件被垃圾回收後,物件的id,陣列,和threadLocal成員都會被回收,所以所佔記憶體不會持續升高,但是實際執行結果如下:

0
1
2
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at Main$TestClass.<init>(Main.java:23)
	at Main.main(Main.java:10)

  可以看到,很快就發生了記憶體溢位異常。為什麼呢?需要注意到,在TestClass的構造方法中,我們將陣列arr放入了ThreadLocal物件中,也就是被放進了當前執行緒的ThreadLocalMap中,作為value存在。我們前面說過,ThreadLocalMapvalue是強引用,這也就意味著雖然ThreadLocal可以被正常回收,但是作為value的大陣列無法被回收,因為它仍然被ThreadLocalMap的強引用所指向。於是TestClass物件的超大陣列就一種在記憶體中,佔據大量空間,我們連續建立了多個TestClass,記憶體很快就被佔滿了,於是發生了記憶體溢位。而JDK的開發人員自然發現了這個問題,於是有了下面這個解決方案:

public class Main {

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            TestClass t = new TestClass(i);
            t.printId();
            // **********注意,與上面的程式碼只有此處不同************
            // 此處呼叫了ThreadLocal物件的remove方法
            t.threadLocal.remove();
            t = null;
        }
    }

    static class TestClass {
        int id;
        int[] arr;
        ThreadLocal<int[]> threadLocal;

        TestClass(int id) {
            this.id = id;
            arr = new int[100000000];
            threadLocal = new ThreadLocal<>();
            threadLocal.set(arr);
        }

        public void printId() {
            System.out.println(id);
        }
    }
}

  上面的程式碼中,我們在將t置為空時,先呼叫了ThreadLocal物件的remove方法,這樣做了之後,再看看執行結果:

0
1
2
// ....神略中間部分
98
99

  做了上面的修改後,沒有再發生記憶體溢位異常,程式正常執行完畢。這是為什麼呢?ThreadLocalremove方法究竟有什麼作用。其實remove方法的作用非常簡單,執行remove方法時,會從當前執行緒的ThreadLocalMap中刪除key為當前ThreadLocal的那一個記錄,keyvalue都會被置為null,這樣一來,就解除了ThreadLocalMapvalue的強引用,使得value可以正常地被JVM回收了。所以,今後如果我們確認不再使用的ThreadLocal物件,一定要記得呼叫它的remove方法。

  我們之前說過,如果我們沒有呼叫remove方法,那就會導致ThreadLocal在使用完畢後,被正常回收,但是ThreadLocalMap中存放的value無法被回收,此時將會在ThreadLocalMap中出現keynull,而value不為null的元素。為了減少已經無用的物件依舊佔用記憶體的現象,ThreadLocal底層實現中,在操作ThreadLocalMap的過程中,執行緒若檢測到keynull的元素,會將此元素的value置為null,然後將這個元素從ThreadLocalMap中刪除,佔用的記憶體就可以讓JVM將其回收。比如說在getEntry方法中,或者是Map擴容的方法中等。


三、總結

  ThreadLocal實現執行緒獨立的方式是直接將值存放在Thread物件的ThreadLocalMap中,Mapkey就是ThreadLocal的引用,且為了有助於JVM進行垃圾回收,key使用的是弱引用。在使用ThreadLocal後,一定要記得呼叫remove方法,有助於JVMvalue的回收。


四、參考

相關文章