一次ThreadLocal原始碼解析之旅

cgrw發表於2019-02-16

本篇文章旨在將ThreadLocal的原理說清楚,講明白。全文主要完成了以下四個部分的工作:

  • 摸清了ThreadLocal是如何做到在不同執行緒set()、get()的值不被其它執行緒訪問的;
  • 介紹了弱引用在ThreadLocalMap中的應用;
  • 探尋了ThreadLocalMap如何實現hash map功能;
  • 列舉了一個使用ThreadLocal而出現的記憶體洩漏問題並加以分析;

首先,讓我們看看ThreadLocal能產生什麼樣的效果:

public class ThreadLocalDemo {
    public static void main(String[] args) {
        final ThreadLocal<Integer> local = new ThreadLocal<>();
        local.set(100);
        Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + " local: " + local.get());
            }
        });
        t.start();
        System.out.println("Main local: " + local.get());
    }
}
複製程式碼

列印結果如下:

Thread-0 local: null
Main local: 100
複製程式碼

local在主執行緒set的值,可以在主執行緒呼叫get方法得到,但線上程t內呼叫get方法,結果結果為null。

本文接下來以local呼叫的set方法為入口,探究產生這一結果的原因。

set()基礎

在ThreadLocal原始碼中set()是這樣實現的:

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

首先獲得當前執行local.set()語句所在的執行緒物件,也就是t,然後通過local的getMap()獲得t內部持有的ThreadLocalMap物件,進入Thread類的原始碼檢視,其中就包含名為threadLocals的欄位:

ThreadLocal.ThreadLocalMap threadLocals = null;
複製程式碼

而檢視getMap()的原始碼,返回的就是threadLocals:

ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}
複製程式碼

map != null

如果map != null,則執行map.set(this, value),這裡的this就是local。

ThreadLocalMap的具體實現後面再展開,在這裡姑且先簡單的理解為按鍵值對儲存資料的資料結構,那麼我們很容易發現,local還是那個local,並沒有在每個執行緒產生local副本,只不過呼叫set方法的時候,將它與傳入的值以鍵值對的形式,儲存於每個執行緒內部持有的ThreadLocalMap物件裡。

map == null

如果map == null,則執行createMap(t, value),原始碼如下:

void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}
複製程式碼

建立ThreadLocalMap物件賦給threadLocals。

至此,ThreadLocal的基本原理就已經很清晰了:各執行緒對共享的ThreadLocal例項進行操作,實際上是以該例項為鍵對內部持有的ThreadLocalMap物件進行操作

除了set(),ThreadLocal還提供了get()、remove()等操作,實現比較簡單,就不敷述了。

ThreadLocalMap結構

要想真正理解ThreadLocal,還需要知道ThreadLocalMap究竟是什麼。

註釋中是這樣介紹的:ThreadLocalMap is a customized hash map suitable only for maintaining thread local values.

ThreadLocalMap屬於自定義的map,是一個帶有hash功能的靜態內部類,和java.util包下提供的Map類並沒有關係。內部有一個靜態的Entry類,下面具體分析Entry。

Entry實現原理

首先,這個類程式碼如下:

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

    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}
複製程式碼

這裡引用程式碼中給出的註釋:The entries in this hash map extend WeakReference, using its main ref field as the key (which is always a ThreadLocal object). Note that null keys (i.e. entry.get() == null) mean that the key is no longer referenced。

第一句話實際上告訴了我們,entry繼承自WeakReference,用main方法引用的欄位作為entry中的key。

第二句的意思是,當entry.get() == null的時候,意味著鍵將不再被引用。

後續將解析這兩句註釋。

弱引用基礎知識

在開始這一小結之前,需要先掌握兩點:

  • 什麼是弱引用。《深入理解Java虛擬機器》中這樣寫道:“被弱引用關聯的物件只能生存到下一次垃圾收集發生之前,當垃圾收集器工作時,無論當前記憶體是否足夠,都會回收掉,只被弱引用關聯的物件。”
  • 什麼是引數的引用傳遞,這屬於Java SE基礎知識就不贅述了。

接下來,先閱讀原始碼,當構造器傳入引數後,代表鍵的k會傳入super()中,也就是它會首先執行父類的構造器:

public WeakReference(T referent) {
    super(referent);
}
複製程式碼

WeakReference的構造器繼續先呼叫父類的構造器:

Reference(T referent) {
    this(referent, null);
}

Reference(T referent, ReferenceQueue<? super T> queue) {
    this.referent = referent;
    this.queue = (queue == null) ? ReferenceQueue.NULL : queue;
}
複製程式碼

除此之外,我們在Reference類裡面看不到任何native方法,但能看到一些例項方法,比如get(),後續我們還將談到這個方法。

這個時候會疑惑弱引用的功能是怎麼實現的,在註釋中,有這樣的字眼:“special treatment by the garbage collector.” 可見WeakReference的功能實現交給了垃圾回收器處理,那麼這裡就不展開了,感興趣的可以參考文末的連結。在這裡我們只需要瞭解WeakReference的使用方法。

弱引用和強引用的使用方法並不相同,下面是一個弱引用的示例:

public class WeakReferenceDemo {
    public static void main(String[] args) {
        WeakReference<Fruit> fruitWeakReference = new WeakReference<>(new Fruit());
        // Fruit f = fruitWeakReference.get();

        if (fruitWeakReference.get() != null) {
            System.out.println("Before GC, this is the result");
        }

        System.gc();

        if (fruitWeakReference.get() != null) {
            System.out.println("After GC, fruitWeakReference.get() is not null");
        } else {
            System.out.println("After GC, fruitWeakReference.get() is null");
        }
    }
}

class Fruit {

}
複製程式碼

輸出結果如下:

Before GC, this is the result
After GC, fruitWeakReference.get() is null
複製程式碼

通過fruitWeakReference.get(),可以得到弱引用指向的物件,當執行System.gc()後,該物件被回收。

用一張圖表示強弱引用彼此間的關係:

圖1

要明確的是,類似“Object obj = new Object()”這般產生的引用屬於強引用,所以fruitWeakReference是強引用,此時它指向的是一個WeakReference物件,在new這個物件時,我們還傳入了一個new出來的Fruit物件,整行程式碼的目的,就是要創造一個弱引用,指向這個Fruit物件。而這個弱引用,就在fruitWeakReference指向的物件裡。

用個不嚴謹的比喻,弱引用就像一隻薛定諤的貓,我們想知道它的狀態,卻不能通過普通的Java程式碼呼叫出它本身來觀測它,如果將前文列出的WeakReferenceDemo內的雙斜槓註釋去掉,用一個變數f指向fruitWeakReference.get(),不過就是將一個強引用指向了原本由弱引用指向的物件而已,此時再執行程式,得到如下結果:

Before GC, this is the result
After GC, fruitWeakReference.get() is not null

Process finished with exit code 0
複製程式碼

由於物件被強引用,所以不會被垃圾回收。

弱引用Entry的鍵

有了前面的基礎,很容易就能理解Entry的構造原理。為了方便說明,不妨假設我們能建立一個Entry物件,程式碼如下:

Entry entry = new Entry(local, 100);
複製程式碼

此時強弱引用彼此間的關係圖如下:

圖2

到這裡,就能理解前面那兩句註釋了,entry繼承自WeakReference,內部維護一個弱引用,指向main方法中local指向的物件;entry.get()返回的是弱引用指向的物件,如果entry.get() == null,自然表示的就是鍵將不再被引用了。

所以,和普通Map的Entry類不同,ThreadLocalMap的Entry例項被建立是時,鍵是弱引用,至此ThreadLocal內部ThreadLocalMap的基本結構也就清楚了。

set()進階

再次貼出ThreadLocal中set()的原始碼:

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

注意第5行的語句,local呼叫set()時,一旦當前執行緒物件持有的ThreadLocalMap型別變數threadLocals不為null,則會執行map.set(this, value)這一行語句,上一節分析了ThreadLocalMap的結構,這一節將聚焦ThreadLocalMap的操作方法set()。

下面給出set()的原始碼:

private void set(ThreadLocal<?> key, Object value) {

    Entry[] tab = table;
    int len = tab.length;
    // 計算出hash表的位置i
    int i = key.threadLocalHashCode & (len-1);
    // 處理set方法關鍵邏輯
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();

        if (k == key) {
            e.value = value;
            return;
        }

        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }
    // 在hash表中儲存新生成的Entry物件
    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}
複製程式碼

程式碼中i是hash表(亦稱hash桶)的索引,也就是存放新設定的entry的位置,當然在存放之前還要進行一番比較操作。threadLocalHashCode是如下方式得到的:

private static AtomicInteger nextHashCode = new AtomicInteger();

private static final int HASH_INCREMENT = 0x61c88647;

private static int nextHashCode() {
    return nextHashCode.getAndAdd(HASH_INCREMENT);
}

private final int threadLocalHashCode = nextHashCode();
複製程式碼

採用0x61c88647是為了實現更好的雜湊,每當有新的ThreadLocal物件呼叫threadLocalHashCode的時候,後者自增一個0x61c88647大小的值。至於為什麼0x61c88647可以實現更好的雜湊,這涉及到Fibonacci Hashing演算法(這個數的二進位制形式取反加1就是一個Fibonacci Hashing常數),具體細節可跳轉到文末參考連結。

當然,在計算i之前還要進行一個位運算,非常簡單,比如在沒擴充套件之前len是16(2的4次方),那麼len - 1的二進位制形式就是1111,按位與也就是取後四位。

為了防止碰撞衝突,這裡採用的是線性探測法,並沒有採用拉鍊法。探測的索引規則如下:

private static int nextIndex(int i, int len) {
  return ((i + 1 < len) ? i + 1 : 0);
}
複製程式碼

for迴圈的執行邏輯是這樣的:

  1. 首先獲取hash表索引位置為i的Entry元素tab[i];
  2. 判斷tab[i]為是否為null,如果tab[i]為null,說明這個位置之前還沒有存在過Entry例項,跳出迴圈,在hash表中該位置儲存新生成的Entry物件;
  3. 如果tab[i]不為null,要麼存在指向相同物件的鍵,如果是這種情況,則修改value為需要設定的值;要麼弱引用指向為null,如果是這種情況,執行replaceStaleEntry方法;
  4. 用nextIndex方法修改i值,跳到第二步繼續判斷;

在跳出迴圈並在hash表相應位置儲存新生成的Entry物件後,size也會加1,在滿足!cleanSomeSlots(i, sz) && sz >= threshold的條件下,還要重新進行rehash()處理。

replaceStaleEntry以及cleanSomeSlots的主要作用都是用來刪除弱引用為null的entry,後者查詢的時間是log2(n),限於篇幅就不展開了,而threshold和HashMap中定義的預置作用相似,主要是擴容用的,這裡為len * 2 / 3。

記憶體清理

還是沿用最初的例子,如果將local置為null,那麼new出來的ThreadLocal物件就只被執行緒中的ThreadLocalMap例項弱引用,此時只要呼叫System.gc(),物件將在下一次垃圾收集時被回收。如果要主動斷掉弱引用呢?Java提供瞭如下方法:

clear()
複製程式碼

它是Reference抽象類提供的方法。

接下來用一個例子討論ThreadLocal可能出現的記憶體洩漏問題。

記憶體洩漏例項

例項原始碼如下:

public class ThreadLocalTest throws InterruptedException{

    public static void main(String[] args) {
        MyThreadLocal<Create50MB> local = new MyThreadLocal<>();

        ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(5, 5, 1,
                TimeUnit.MINUTES, new LinkedBlockingQueue<Runnable>());
        for (int i = 0; i < 5; i++) {
            final int[] a = new int[1];
            final ThreadLocal[] finallocal = new MyThreadLocal[1];
            finallocal[0] = local;
            a[0] = i;
            poolExecutor.execute(new Runnable() {
                @Override
                public void run() {
                    finallocal[0].set(new Create50MB());
                    System.out.println("add i = " + a[0]);
                }
            });
        }

        Thread.sleep(50000);
        local = null;
    }

    static class Create50MB {
        private byte[] bytes = new byte[1024 * 1024 * 50];
    }

    static class MyThreadLocal<T> extends ThreadLocal {
        private byte[] bytes = new byte[1024 * 1024 * 500];
    }
}
複製程式碼

先說一說該小程式的設計思路:

該程式旨在構造出一種記憶體洩漏的情況:當執行緒池執行完當前任務處於等待狀態的時候,將local置null,回收main方法一開始new出來的MyThreadLocal物件,執行緒池內單個執行緒的ThreadLocalMap例項雖然弱引用於這個MyThreadLocal物件,但內部持有的value卻仍然被強引用著不能回收。

在該程式中,我們自定義了一個MyThreadLocal,目的是使new出來的MyThreadLocal物件的大小能達到500MB;Create50MB是建立出來的容量包,每個執行緒最後持有的value就是一個50MB大小的Create50MB物件;執行緒池也是自定義傳參,做到更好的掌控,一次能同時工作5個執行緒;for迴圈中用到了兩個臨時變數,是為了規避匿名內部類引用外部變數必須要宣告為final的語言限制。

啟動程式,執行狀態見下圖:

001

使用的堆的大小是750MB,這符合預期,new出來的MyThreadLocal物件500MB,有五個執行緒,每個執行緒50MB,加起來一共750MB。

50秒後,將local置null,這個時候不再有強引用指向new的MyThreadLocal物件,此時執行垃圾回收,結果如下:

002

使用的堆大小變為250MB,單就這個結果還不能證明每個執行緒內對MyThreadLocal物件存在弱引用,但是一定不存在強引用。

之前本人曾研究過執行緒池的原始碼,執行緒池內的執行緒在執行完一個任務後,並沒有銷燬,在本例中,它們處於waiting狀態,所以,本程式始終維持在250MB大小,得不到釋放,一旦將程式中的條件改得足夠大,就能出現明顯的效能問題。解決的方法通常是線上程內呼叫ThreadLocal的remove方法,實際上,ThreadLocal提供的公有API並不多,但是這個方法足夠解決問題。

小結

不得不說,通過對ThreadLocal的解析,本人收穫很多。整篇文章寫起來也是一氣呵成(所以可能也包藏著錯誤),估摸著如果以後有對共享變數進行私有設定的需求時,也可以參考這種方法來寫;之前對四種引用只是瞭解,這次算是弄明白怎麼運用;用線性探測解決hash表的碰撞衝突,有別於HashMap,也是ThreadLocal的特點;最後列舉的記憶體洩漏,算是對前面寫的內容進行了一次實戰。

cool.

參考

WeakReference

JVM原理與實現——Reference

What is the meaning of 0x61C88647 constant in ThreadLocal.java

Fibonacci Hashing

列印GC:-XX:+PrintGCDetails,更多可見:檢視GC日誌時使用的虛擬機器引數

相關文章