1. ThreadLocal實現原理
本文參考的java 版本是11。
在講述ThreadLocal實現原理之前,我先來簡單地介紹一下什么是ThreadLocal。ThreadLocal提供線程本地變量,每個線程擁有本地變量的副本,各個線程之間的變量相互獨立。在高並發場景下,可以實現無狀態的調用,特別適用於各個線程依賴不通的變量值完成操作的場景。以下英文描述來源於ThreadLocal類的注釋:
This class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its get or set method) has its own, independently initialized copy of the variable.
下面我就來說一說ThreadLocal是如何做到線程之間的變量相互獨立的,也就是它的實現原理。每一個線程都有一個對應的Thread對象,而Thread類有一個ThreadLocalMap類型變量threadLocals和一個內部類ThreadLocal。這個threadLocals的key就是ThreadLocal的引用,而value就是當前線程在key所對應的ThreadLocal中存儲的值。當某個線程需要獲取存儲在自己線程Thread的ThreadLocal變量中的值時,ThreadLocal底層會獲取當前線程的Thread對象中的Map集合threadLocals,然后以ThreadLocal作為key,從threadLocals中查找value值。這就是ThreadLocal實現線程獨立的原理。
ThreadLocal通俗理解就是線程的私有變量,用於保證當前線程對其修改和讀取。
2. ThreadLocal堆棧分析
Entry繼承自WeakReference(弱引用,生命周期只能存活到下次GC前),但只有Key是弱引用類型的,Value並非弱引用。
在《Java 8 ThreadLocal 源碼解析》一文,我們知道每個thread中都存在一個map,它的類型是ThreadLocal.ThreadLocalMap。map中的Entry是ThreadLocalMap的靜態內部類,繼承自WeakReference,其key為一個ThreadLocal實例,使用弱引用(弱引用,生命周期只能存活到在下次 JVM 垃圾收集時被回收前),而其value卻使用了強引用。在ThreadLocal的整個生命周期中,都存在這些引用。ThreadLocal堆棧結構示意圖如下圖所示,實線代表強引用,虛線代表弱引用:

圖2 ThreadLocal堆棧結構示意圖
從上面的結構圖,我們可以窺見ThreadLocal的核心機制:
- 每個Thread線程內部都有一個ThreadLocalMap。
- ThreadLocalMap里面存儲線程本地對象(key)和線程的變量副本(value)
- 線程運行時,初始化ThreadLocal對象,存儲在Heap,同時線程運行的棧區保存了指向該實例的引用,也就是圖中的Thread Local Ref。
- 當調用ThreadLocal的set/get函數時,虛擬機根據當前線程的引用也就是Current Thread Ref找到其在堆區的實例,然后查看其對應的ThreadLocalMap實例是否被創建,若沒有,則創建並初始化。
- ThreadLocalMap實例化之后,就可以將當前ThreadLocal對象作為key,進行存取操作。
- 當弱引用key被GC回收時,強引用value不被自動回收,有可能導致內存泄漏。
通過如上4和5的分析,我們得知對於不同的線程,每次獲取副本值時,別的線程並不能獲取到當前線程的副本值,形成了對副本的隔離,互不干擾。
ThreadLocalMap因為使用了弱引用,所以為了便於描述,我們把entry的狀態區分為三種:即有效(key和value均未回收),無效(key已回收但是value未回收)和空(entry==null)。
為什么ThreadLocalMap需要Entry數組呢?
之所以用數組,是因為開發過程中,一個線程可以擁有多個TreadLocal以存放不同類型的對象,但是他們都將放到當前線程的ThreadLocalMap里,所以需要以數組的形式來存儲。
3. remove方法
remove方法主要是為了防止內存溢出和內存泄露,使用的時機一般是在線程運行結束之后使用,也就是run()方法結束之后。下面介紹一下內存泄漏和內存溢的基本概念:
內存泄露(Memory Leak):是指程序中己動態分配的堆內存由於某種原因程序未釋放或無法釋放,造成系統內存的浪費,導致程序運行速度減慢甚至系統崩潰等嚴重后果。
內存溢出(Out Of Memory,簡稱OOM):是指應用系統中存在無法回收的內存或使用的內存過多,最終使得程序運行要用到的內存大於系統能提供的最大內存。此時程序就運行不了,系統會提示內存溢出。
簡單來說,內存泄露就是創建了太多的ThreadLocal變量,然后呢,又沒有及時的釋放內存;內存溢出可以理解為創建了多個ThreadLocal變量,然后又給她們分配了占用內存比較大的對象,使得多個線程累計占用太多內存,導致系統出現內存溢出。
remove()
public void remove() { // 獲取ThreadLocalMap對象,此對象在ThreadLocal中是一個靜態內部類
ThreadLocalMap m = getMap(Thread.currentThread()); // 如果存在的話,調用方法remove,看②
if (m != null) { m.remove(this); } }
②remove(ThreadLocal<?> key)
/** * Remove the entry for key. */
private void remove(ThreadLocal<?> key) { Entry[] tab = table; int len = tab.length;// 獲取長度 // 通過key的hash值找到當前key的位置
int i = key.threadLocalHashCode & (len-1); // 遍歷,直到找到Entry中key為當前對象key的那個元素
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { if (e.get() == key) { e.clear(); // 清除對象的引用
expungeStaleEntry(i); // 去除陳舊的對象鍵值對(相當於幫派清理門戶,就是將沒用的東西清理出去)
return; } } }
③clear
public void clear() { this.referent = null; // 將引用指向null
}
④ expungeStaleEntry
看這個方法之前,需要明確全局變量size是什么,size是鍵值對的個數,定義如下:
/** * The number of entries in the table. */
private int size = 0;
函數expungeStaleEntry是ThreadLocal中的核心清理函數,它做的事情大致如下:從staleSlot開始遍歷,清理無效entry並且將此entry置為null,直到掃到空entry。另外,在遍歷過程中還會對非空的entry作rehash,可以說她的作用就是從staleSlot開始清理連續段中的slot(斷開強引用,rehash slot等)。
private int expungeStaleEntry(int staleSlot) { Entry[] tab = table; int len = tab.length; // expunge entry at staleSlot // 因為entry對應的ThreadLocal已經被回收,value設為null,顯式斷開強引用
tab[staleSlot].value = null; tab[staleSlot] = null; // 將整個鍵值對清除
size--; // 數量減一 // Rehash until we encounter null 直到遇到null,然后rehash操作
Entry e; int i; // 從當前的staleSlot后面的位置開始,直到遇到null為止
for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { // 獲取鍵對象,也就是map中的key對象
ThreadLocal<?> k = e.get(); // 如果為null,直接清除值和整個entry,數量size減一
if (k == null) { e.value = null; tab[i] = null; size--; } else { // k不為null,說明當前key未被GC回收,弱引用還存在 // 此時執行再哈希操作
int h = k.threadLocalHashCode & (len - 1); if (h != i) { // 如果不等的話,表明與之前的hash值不同這個元素需要更新
tab[i] = null; // 將這個地方設置為null // Unlike Knuth 6.4 Algorithm R, we must scan until // null because multiple entries could have been stale.
while (tab[h] != null) // 從當前h位置找一個為null的地方將當前元素放下
h = nextIndex(h, len); tab[h] = e; } } } return i; // 返回的是第一個entry為null的下標
}
這段源碼提及了Knuth高德納的著作TAOCP(《計算機程序設計藝術》)的6.4章節(散列)中的R算法。R算法描述了如何從使用線性探測的散列表中刪除一個元素。R算法維護了一個上次刪除元素的index,當在非空連續段中掃到某個entry的哈希值取模后的索引還沒有遍歷到時,會將該entry挪到index那個位置,並更新當前位置為新的index,繼續向后掃描直到遇到空的entry。
正是因為ThreadLocalMap的entry有三種狀態,所以不能完全采用高德納原書的R算法。
因為expungeStaleEntry函數在掃描過程中還需要對無效slot清理,並將它轉為空entry,如果直接套用R算法,可能會出現具有相同哈希值的entry之間斷開(中間有空entry)。
關鍵節點梳理:
1)刪除staleSlot處的值value和entry。
2)對從staleSlot位置到下一個為空的slot之間碰撞的entry進行rehash。
碰撞的判斷:h = k.threadLocalHashCode & (len - 1) 不等於當前的索引i,所以從h處向后線性探測查找空的slot插入。
3)刪除從staleSlot位置到下一個為空的slot之間所有無效的entry。
4. ThreadLocal內存泄露
我們從前面兩個章節可以得知在Thread運行時,線程的一些局部變量和引用使用的內存屬於Stack(棧)區,而普通的對象是存儲在Heap(堆)區。由於ThreadLocalMap的key是弱引用而Value是強引用,這就導致了一個問題:ThreadLocal在沒有外部對象強引用且發生GC時弱引用Key會被回收,而我們往里面放的value對於【當前線程->當前線程的threadLocals(ThreadLocal.ThreadLocalMap對象)->Entry數組->某個entry.value】這樣一條強引用鏈是可達的,因此value不會被回收。如果創建ThreadLocal的線程一直持續運行,那么這個Entry對象中的value就有可能一直得不到回收,滴水成河,最終造成系統發生內存泄露。
所以得出一個結論就是只要這個線程對象被GC回收,就不會出現內存泄露,但在ThreadLocal設為null和線程結束這段時間內,線程對象不會被回收,就會發生我們認為的內存泄露。
Java為了降低內存泄露的可能性和風險,在ThreadLocal的get和set方法中都自帶一套自我清理的機制,以清除線程ThreadLocalMap里所有無效的entry。為了避免內存泄漏,我們需要養成良好的編程習慣,使用完ThreadLocal之后,及時調用remove方法,顯示地設置Entry對象為null。
ThreadLocal<String> threadLocal = new ThreadLocal<String>(); try { threadLocal.set("業務數據"); // TODO 其它業務邏輯
} finally { threadLocal.remove(); }
當使用static ThreadLocal的時候,會延長ThreadLocal的生命周期,那也可能導致內存泄漏。因為,static變量在類未加載的時候,它就已經加載,當線程結束的時候,static變量不一定會回收。那么,比起普通成員變量使用的時候才加載,static的生命周期加長將更容易導致內存泄漏危機。
5.為什么使用弱引用
為避免占用空間較大或生命周期較長的數據常駐於內存引發一系列問題,類ThreadLocalMap中有關英文原文描述如下:
To help deal with very large and long-lived usages, the hash table entries use WeakReferences for keys. However, since reference queues are not used, stale entries are guaranteed to be removed only when the table starts running out of space.
由於ThreadLocalMap的生命周期跟Thread一樣長,使用弱引用可以多一層保障:弱引用不會導致內存泄漏,無效entry在ThreadLocalMap調用set,get和remove函數的時候會被清除。
6. ThreadLocal內存泄漏案例分析
案例一
首先,設置-Xms100m -Xmx100m,然后,使用如下的代碼
public class ThreadlocalApplication { // 線程私有變量,和當前線程綁定,所以各個線程對其的改變不會被其他線程讀取到到
public static ThreadLocal<byte[]> threadLocal = new ThreadLocal<>(); public static void main(String[] args) { SpringApplication.run(ThreadlocalApplication.class, args); ExecutorService exec = Executors.newFixedThreadPool(99); for (int i = 0; i < 1000; i++) { exec.execute(() -> { threadLocal.set(new byte[1024 * 1024]); try { TimeUnit.MILLISECONDS.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } finally { threadLocal.remove(); } }); } } }
運行上面的代碼沒有拋出任何異常,但是若將 threadLocal.remove() 注釋掉再執行,就會出現內存泄漏的問題,原因是1m的數組沒有被及時回收,這也從側面證明了手動 remove() 的必要性。
案例二
下面我們用代碼來驗證一下,
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; /** * TODO * * @author Wiener * @date 2020/10/27 */
public class ThreadPoolProblem { private static ThreadLocal<AtomicInteger> sequencer = new ThreadLocal<AtomicInteger>() { @Override protected AtomicInteger initialValue() { return new AtomicInteger(0); } }; static class BarTask implements Runnable { @Override public void run() { AtomicInteger s = sequencer.get(); int initial = s.getAndIncrement(); // 期望初始為0
System.out.println(initial); } } public static void main(String[] args) { //線程池線程數設置為2,線程池中線程數超過2時,將復用已創建的2條線程
ExecutorService executor = Executors.newFixedThreadPool(2); // 創建四條線程
executor.execute(new BarTask()); executor.execute(new BarTask()); executor.execute(new BarTask()); executor.execute(new BarTask()); executor.shutdown(); } }
對於在線程池中執行異步任務BarTask而言,我們翹首以待的初始值應該始終是0,但如下圖所示的程序執行結果卻和期望值大相徑庭:

由此可見,第二次執行異步任務時期望的結果就不對了,為什么呢?因為線程池里面的線程都是復用的,在線程在執行下一個任務時,其ThreadLocal對象並不會被清空,修改后的值帶到了下一個任務。那怎么辦呢?下面提供有幾種解決思路:
l 第一次使用ThreadLocal對象時,總是先調用set設置初始值,或者如果ThreadLocal重寫了initialValue方法,先調用remove。
l 使用完ThreadLocal對象后,總是調用其remove方法。
l 使用自定義的線程池,執行新任務時總是清空ThreadLocal。
按照道理一個線程使用完,ThreadLocalMap是應該要被清空的,但是現在線程被復用了。
7. 小結
本文討論了ThreadLocal實現原理和內存泄漏相關的問題。首先,介紹了ThreadLocal的實現原理。其次,擼了擼remove函數的源碼。然后,基於remove函數分析了ThreadLocal內存泄露的問題。最后,給出導致內存泄漏的兩個案例,幫助各位讀者進一步熟悉ThreadLocal。
作為Josh Bloch和Doug Lea兩位大師之作,ThreadLocal源碼所使用的算法與技巧很優雅。在開發過程中,如果ThreadLocal運用得當,可以提高代碼復用率。但也要注意過度使用ThreadLocal很容易加大類之間的耦合度與依賴關系。
Reference
https://www.jianshu.com/p/1a5d288bdaee
https://www.cnblogs.com/onlywujun/p/3524675.html
https://www.cnblogs.com/micrari/p/6790229.html
https://www.cnblogs.com/kancy/p/10702310.html
https://cloud.tencent.com/developer/article/1333298
