天天看點

這4種ThreadLocal你都知道嗎?

什麼是ThreadLocal

ThreadLocal類顧名思義可以了解為線程本地變量。也就是說如果定義了一個ThreadLocal, 每個線程往這個ThreadLocal中讀寫是線程隔離,互相之間不會影響的。它提供了一種将可變資料通過每個線程有自己的獨立副本進而實作線程封閉的機制。

實際應用

實際開發中我們真正使用ThreadLocal的場景還是比較少的,大多數使用都是在架構裡面。最常見的使用場景的話就是用它來解決資料庫連接配接、Session管理等保證每一個線程中使用的資料庫連接配接是同一個。還有一個用的比較多的場景就是用來解決SimpleDateFormat解決線程不安全的問題,不過現在java8提供了DateTimeFormatter它是線程安全的,感興趣的同學可以去看看。還可以利用它進行優雅的傳遞參數,傳遞參數的時候,如果父線程生成的變量或者參數直接通過ThreadLocal傳遞到子線程參數就會丢失,這個後面會介紹一個其他的ThreadLocal來專門解決這個問題的。

ThreadLocal api介紹

ThreadLocal的API還是比較少的就幾個api

這4種ThreadLocal你都知道嗎?

我們看下這幾個api的使用,使用起來也超級簡單

private static ThreadLocal<String> threadLocal = ThreadLocal.withInitial(()->"java金融");
    public static void main(String[] args) {
        System.out.println("擷取初始值:"+threadLocal.get());
        threadLocal.set("關注:【java金融】");
        System.out.println("擷取修改後的值:"+threadLocal.get());
        threadLocal.remove();
    }           

輸出結果:

擷取初始值:java金融
擷取修改後的值:關注:【java金融】           

是不是炒雞簡單,就幾行代碼就把所有api都覆寫了。下面我們就來簡單看看這幾個api的源碼吧。

成員變量

/**初始容量,必須為2的幂
         * The initial capacity -- MUST be a power of two.
         */
        private static final int INITIAL_CAPACITY = 16;

        /** Entry表,大小必須為2的幂
         * The table, resized as necessary.
         * table.length MUST always be a power of two.
         */
        private Entry[] table;

        /**
         * The number of entries in the table.
         */
        private int size = 0;

        /**
         * The next size value at which to resize.
         */
        private int threshold; // Default to 0           

這裡會有一個面試經常問到的問題:為什麼entry數組的大小,以及初始容量都必須是2的幂?對于 firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1); 以及很多源碼裡面都是使用 hashCode &( -1) 來代替hashCode% 。這種寫法好處如下:

  • 使用位運算替代取模,提升計算效率。
  • 為了使不同 hash 值發生碰撞的機率更小,盡可能促使元素在哈希表中均勻地散列。

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

set方法還是比較簡單的,我們可以重點看下這個方法裡面的ThreadLocalMap,它既然是個map(注意不要與java.util.map混為一談,這裡指的是概念上的map),肯定是有自己的key和value組成,我們根據源碼可以看出它的key是其實可以把它簡單看成是ThreadLocal,但是實際上ThreadLocal中存放的是ThreadLocal的弱引用,而它的value的話是我們實際set的值

static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value; // 實際存放的值

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

Entry就是是ThreadLocalMap裡定義的節點,它繼承了WeakReference類,定義了一個類型為Object的value,用于存放塞到ThreadLocal裡的值。我們再來看下這個ThreadLocalMap是位于哪裡的?我們看到ThreadLocalMap 是位于Thread裡面的一個變量,而我們的值又是放在ThreadLocalMap,這樣的話我們就實作了每個線程間的隔離。下面兩張圖的基本就把ThreadLocal的結構給介紹清楚了。

這4種ThreadLocal你都知道嗎?
這4種ThreadLocal你都知道嗎?

接下來我們再看下ThreadLocalMap裡面的資料結構,我們知道HaseMap解決hash沖突是由連結清單和紅黑樹(jdk1.8)來解決的,但是這個我們看到ThreadLocalMap隻有一個數組,它是怎麼來解決hash沖突呢?ThreadLocalMap采用「線性探測」的方式,什麼是線性探測呢?就是根「據初始key的hashcode值确定元素在table數組中的位置,如果發現這個位置上已經有其他key值的元素被占用,則利用固定的算法尋找一定步長的下個位置,依次判斷,直至找到能夠存放的位置」。ThreadLocalMap解決Hash沖突的方式就是簡單的步長加1或減1,尋找下一個相鄰的位置。

/**
         * Increment i modulo len.
         */
        private static int nextIndex(int i, int len) {
            return ((i + 1 < len) ? i + 1 : 0);
        }

        /**
         * Decrement i modulo len.
         */
        private static int prevIndex(int i, int len) {
            return ((i - 1 >= 0) ? i - 1 : len - 1);
        }           

這種方式的話如果一個線程裡面有大量的ThreadLocal就會産生性能問題,因為每次都需要對這個table進行周遊,清空無效的值。是以我們在使用的時候盡可能的使用少的ThreadLocal,不要線上程裡面建立大量的ThreadLocal,如果需要設定不同的參數類型我們可以通過ThreadLocal來存放一個Object的Map這樣的話,可以大大減少建立ThreadLocal的數量。僞代碼如下:

public final class HttpContext {
    private HttpContext() {
    }
    private static final ThreadLocal<Map<String, Object>> CONTEXT = ThreadLocal.withInitial(() -> new ConcurrentHashMap(64));
    public static <T> void add(String key, T value) {
        if(StringUtils.isEmpty(key) || Objects.isNull(value)) {
            throw new IllegalArgumentException("key or value is null");
        }
        CONTEXT.get().put(key, value);
    }
    public static <T> T get(String key) {
        return (T) get().get(key);
    }
    public static Map<String, Object> get() {
        return CONTEXT.get();
    }
    public static void remove() {
        CONTEXT.remove();
    }
}           

這樣的話我們如果需要傳遞不同的參數,可以直接使用一個ThreadLocal就可以代替多個ThreadLocal了。如果覺得不想這麼玩,我就是要建立多個ThreadLocal,我的需求就是這樣,而且性能還得要好,這個能不能實作列?可以使用netty的FastThreadLocal可以解決這個問題,不過要配合使FastThreadLocalThread或者它子類的線程線程效率才會更高,更多關于它的使用可以自行查閱資料哦。

下面我們先來看下它的這個哈希函數

// 生成hash code間隙為這個魔數,可以讓生成出來的值或者說ThreadLocal的ID較為均勻地分布在2的幂大小的數組中。
private static final int HASH_INCREMENT = 0x61c88647;

/**
 * Returns the next hash code.
 */
private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
}           

可以看出,它是在上一個被構造出的ThreadLocal的ID/threadLocalHashCode的基礎上加上一個魔數0x61c88647的。這個魔數的選取與斐波那契散列有關,0x61c88647對應的十進制為1640531527.當我們使用0x61c88647這個魔數累加對每個ThreadLocal配置設定各自的ID也就是threadLocalHashCode再與2的幂(數組的長度)取模,得到的結果分布很均勻。我們可以來也示範下通過這個魔數

public class MagicHashCode {
    private static final int HASH_INCREMENT = 0x61c88647;

    public static void main(String[] args) {
        hashCode(16); //初始化16
        hashCode(32); //後續2倍擴容
        hashCode(64);
    }

    private static void hashCode(Integer length) {
        int hashCode = 0;
        for (int i = 0; i < length; i++) {
            hashCode = i * HASH_INCREMENT + HASH_INCREMENT;//每次遞增HASH_INCREMENT
            System.out.print(hashCode & (length - 1));
            System.out.print(" ");
        }
        System.out.println();
    }
}           

運作結果:

7 14 5 12 3 10 1 8 15 6 13 4 11 2 9 0 
7 14 21 28 3 10 17 24 31 6 13 20 27 2 9 16 23 30 5 12 19 26 1 8 15 22 29 4 11 18 25 0 
7 14 21 28 35 42 49 56 63 6 13 20 27 34 41 48 55 62 5 12 19 26 33 40 47 54 61 4 11 18 25 32 39 46 53 60 3 10 17 24 31 38 45 52 59 2 9 16 23 30 37 44 51 58 1 8 15 22 29 36 43 50 57 0            

不得不佩服下這個作者,通過使用了斐波那契散列法,來保證哈希表的離散度,讓結果很均勻。可見「代碼要寫的好,數學還是少不了」啊。其他的源碼就不分析了,大家感興趣可以自行去檢視下。

ThreadLocal的記憶體洩露

關于ThreadLocal是否會引起記憶體洩漏也是一個比較有争議性的問題。首先我們需要知道什麼是記憶體洩露?

在Java中,記憶體洩漏就是存在一些被配置設定的對象,這些對象有下面兩個特點,首先,這些對象是可達的,即在有向圖中,存在通路可以與其相連;其次,這些對象是無用的,即程式以後不會再使用這些對象。如果對象滿足這兩個條件,這些對象就可以判定為Java中的記憶體洩漏,這些對象不會被GC所回收,然而它卻占用記憶體。

ThreadLocal的記憶體洩露情況:

  • 線程的生命周期很長,當ThreadLocal沒有被外部強引用的時候就會被GC回收(給ThreadLocal置空了):ThreadLocalMap會出現一個key為null的Entry,但這個Entry的value将永遠沒辦法被通路到(後續在也無法操作set、get等方法了)。如果當這個線程一直沒有結束,那這個key為null的Entry因為也存在強引用(Entry.value),而Entry被目前線程的ThreadLocalMap強引用(Entry[] table),導緻這個Entry.value永遠無法被GC,造成記憶體洩漏。下面我們來示範下這個場景
    public static void main(String[] args) throws InterruptedException {
        ThreadLocal<Long []> threadLocal = new ThreadLocal<>();
        for (int i = 0; i < 50; i++) {
            run(threadLocal);
        }
        Thread.sleep(50000);
        // 去除強引用
        threadLocal = null;
        System.gc();
        System.runFinalization();
        System.gc();
    }
    
    private static void run(ThreadLocal<Long []> threadLocal) {
        new Thread(() -> {
            threadLocal.set(new Long[1024 * 1024 *10]);
            try {
                Thread.sleep(1000000000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }           
    通過jdk自帶的工具jconsole.exe會發現即使執行了gc 記憶體也不會減少,因為key還被線程強引用着。效果圖如下:
    這4種ThreadLocal你都知道嗎?
  • 針對于這種情況ThreadLocalMap在設計中,已經考慮到這種情況的發生,你隻要調用了set()、get()、remove()方法都會調用cleanSomeSlots()、expungeStaleEntry()方法去清除key為null的value。這是一種被動的清理方式,但是如果ThreadLocal的set(),get(),remove()方法沒有被調用,就會導緻value的記憶體洩漏。它的文檔推薦我們使用static修飾的ThreadLocal,導緻ThreadLocal的生命周期和持有它的類一樣長,由于ThreadLocal有強引用在,意味着這個ThreadLocal不會被GC。在這種情況下,我們如果不手動删除,Entry的key永遠不為null,弱引用也就失去了意義。是以我們在使用的時候盡可能養成一個好的習慣,使用完成後手動調用下remove方法。其實實際生産環境中我們手動remove大多數情況并不是為了避免這種key為null的情況,更多的時候,是為了保證業務以及程式的正确性。比如我們下單請求後通過ThreadLocal建構了訂單的上下文請求資訊,然後通過線程池異步去更新使用者積分,這時候如果更新完成,沒有進行remove操作,即使下一次新的訂單會覆寫原來的值但是也是有可能會導緻業務問題。如果不想手動清理是否還有其他方式解決下列?FastThreadLocal 可以去了解下,它提供了自動回收機制。
  • 線上程池的場景,程式不停止,線程一直在複用的話,基本不會銷毀,其實本質就跟上面例子是一樣的。如果線程不複用,用完就銷毀了就不會存在洩露的情況。因為線程結束的時候會jvm主動調用exit方法清理。
/**
         * This method is called by the system to give a Thread
         * a chance to clean up before it actually exits.
         */
        private void exit() {
            if (group != null) {
                group.threadTerminated(this);
                group = null;
            }
            /* Aggressively null out all reference fields: see bug 4006245 */
            target = null;
            /* Speed the release of some of these resources */
            threadLocals = null;
            inheritableThreadLocals = null;
            inheritedAccessControlContext = null;
            blocker = null;
            uncaughtExceptionHandler = null;
        }           

InheritableThreadLocal

static ThreadLocal<String> threadLocal = new ThreadLocal<>();
    static InheritableThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>();

    public static void main(String[] args) throws InterruptedException {
        threadLocal.set("threadLocal主線程的值");
        Thread.sleep(100);
        new Thread(() -> System.out.println("子線程擷取threadLocal的主線程值:" + threadLocal.get())).start();
        Thread.sleep(100);
        inheritableThreadLocal.set("inheritableThreadLocal主線程的值");
        new Thread(() -> System.out.println("子線程擷取inheritableThreadLocal的主線程值:" + inheritableThreadLocal.get())).start();

    }           
線程擷取threadLocal的主線程值:null
子線程擷取inheritableThreadLocal的主線程值:inheritableThreadLocal主線程的值           

總結

  • 大概介紹了ThreadLocal的常見用法,以及大緻實作原理,以及關于ThreadLocal的記憶體洩露問題,以及關于使用它需要注意的事項,以及如何解決父子線程之間的傳遞。介紹了ThreadLocal、InheritableThreadLocal、FastThreadLocal、transmittable-thread-local各種使用場景,以及需要注意的事項。本文重點介紹了ThreadLocal,如果把這個弄清楚了,其他幾種ThreadLocal就更好了解了。

    結束

  • 由于自己才疏學淺,難免會有纰漏,假如你發現了錯誤的地方,還望留言給我指出來,我會對其加以修正。
  • 如果你覺得文章還不錯,你的轉發、分享、贊賞、點贊、留言就是對我最大的鼓勵。
  • 感謝您的閱讀,十分歡迎并感謝您的關注