ThreadLoclc初衷是線程並發時,解決變量共享問題,但是由於過度設計,比如弱引用的和哈希碰撞,導致理解難度大、使用成本高,反而成為故障高發點,容易出現內存泄露,臟數據、貢獻對象更新等問題。單從ThreadLoacl命名來看人們認為只要用它就對了,包治變量共享問題,然而並不是。一下以內存模型、弱引用,哈希算法為鋪墊,然后從cs真人游戲的示例代碼入手,詳細分析Threadlocal源碼。我們從中可以學習到全新的編程思維方式,並認識到問題的來源,也能夠幫助我們諳熟此類設計之道,揚長避短。
引用類型
對象在堆上創建之后所持有的引用其實是一種變量類型,引用之間可以通過賦值構成一條引用鏈。從GC Roots 開始遍歷,判斷引用是否可達。引用的可達性是判斷能否被垃圾回收的基本條件。JVM會據此自動管理內存分配與回收,不需要開發工程師干預。但是在某些場景下,即使引用可達,也希望根據語義的強弱進行有選擇的回收,以保證系統的正常運行。根據引用類型語義的強弱來決定垃圾回收的階段,我們可以把引用分為強引用,軟引用,弱引用和虛引用四類。后三類引用,本質上可以讓開發工程師通過代碼的方式來決定對象的垃圾回收時機。我們先簡要了解一下這個四類引用。
強引用,即Strong Reference , 最為常見,如Object object = new Object();這樣的變量聲明和定義就會產生該對象的強引用。只要對象有強引用指向,並且GC roots 可達,那么java內存回收時,即使瀕臨內存耗盡,也不會回收該對象。
軟引用,即soft Reference ,引用力度弱於"強引用",是用在非必須對象的場景。在即將OOM之前,垃圾回收器會把這些軟引用指向的對象加入回收范圍,以獲得更多的內存空間,讓程序能夠繼續健康運行。主要用來緩存服務器中間計算結果集不需要試試保存的用戶行為等。
弱引用,即Weak Reference,引用強度較前兩者更弱,也是用來描述非必須對象的。如果弱引用指向的對象只存在弱引用這一條線路,則在下一次YGC的時候被回收。由於YGC時間的不確定性,弱引用何時被回收也有不確定性。弱引用主要用於指向某個易消失的對象,在強引用斷開后,此引用不會劫持對象。調用WeakReference.get() 可能返回null,要注意空指針異常。
虛引用,即Phantom Reference ,是極弱的一種引用關系,定義完成后,就無法通過該引用獲取指定的對象。為對象設置虛引用的唯一目的就是希望能在這個對象被回收時收到一個系統通知,虛引用必須與引用隊列聯合使用,當垃圾回收時,如果發現存在虛引用,就會在回收對象內存前,把這個虛引用加入與之關聯的引用隊列中。
強引用是最常用的,而虛引用在業務中幾乎很難用到。下面重點介紹一下軟引用和弱引用。先來說明一下軟引用的回收機制。首先設置JVM 參數:-Xms 20m,-Xmx 20m,即只有20m的堆內存空間。
1 public class SoftReferenceHouse { 2 public static void main(String[] args) { 3 //List<House> houses = new ArrayList<>(); //(第1處) 4 List<SoftReference> houses = new ArrayList<>(); 5 6 //劇情反轉注釋處 7 int i = 0; 8 while (true){ 9 //houses.add(new House()); //(第2處) 10 11 //劇情反轉注釋處 12 SoftReference<House> buyer2 = new SoftReference<>(new House()); 13 14 //劇情反轉注釋處 15 houses.add(buyer2); 16 System.out.println("i=" + (++i)); 17 } 18 } 19 } 20 21 class House{ 22 private static final Integer DOOR_NUMBER = 2000; 23 public Door [] doors = new Door[DOOR_NUMBER]; 24 class Door{} 25 }
new House() 是匿名對象,產生之后即賦值給軟引用。正常運行一段時間后,內存達到耗盡的臨界狀態。
ThreadLoacl 價值
我們從真人 CS 游戲說起。游戲開始時,每個人能夠領到一把電子槍,槍把上有三個數字,子彈數,殺敵數,自己的命數,為其設置的初始值分別為:1500,0,10.假設戰場上每個人都是一個線程,那么這三個出事值寫在哪里呢?如果每個線程寫死這三個值,萬一將初始字段數統一改成1000發呢?如果共享,那么線程直接的並發修改會導致數據不准確。能不能構造這樣一個對象,將這個對象設置為共享變量,統一設置初始值,但是每個線程都這個值的修改都是相互獨立的。這個對象就是ThreadLoacl。注意不能將其翻譯成線程本地化或者本地線程。英語恰當的名稱應該叫做:CopyValueIntoEveryThread。具體代碼示例如下:
1 /** 2 * @Author: MikeWang 3 * @Date: 2019/1/13 3:38 PM 4 * @Description: 5 */ 6 public class CsGameByThreadLoacl { 7 private static final Integer BULLET_NUMBER = 1500; 8 private static final Integer KILLED_ENEMIES = 0; 9 private static final Integer LIFE_VALUE = 10; 10 private static final Integer TOTAL_PLAYERS = 10; 11 //隨機數用來展示每個對象的不同的數據(第1處) 12 private static final ThreadLocalRandom RANDOM = ThreadLocalRandom.current(); 13 14 //初始化子彈數 15 private static final ThreadLocal<Integer> BULLET_NUMBER_THREADLOCAL = new ThreadLocal<Integer>(){ 16 @Override 17 protected Integer initialValue() { 18 return BULLET_NUMBER; 19 } 20 }; 21 //初始化殺敵數 22 private static final ThreadLocal<Integer> KILLED_ENEMIES_THREADLOCAL = new ThreadLocal<Integer>(){ 23 @Override 24 protected Integer initialValue() { 25 return KILLED_ENEMIES; 26 } 27 }; 28 //初始化自己的命數 29 private static final ThreadLocal<Integer> LIFE_VALUE_THREADLOCAL = new ThreadLocal<Integer>(){ 30 @Override 31 protected Integer initialValue() { 32 return LIFE_VALUE; 33 } 34 }; 35 36 37 //定義每位隊員 38 private static class Player extends Thread{ 39 @Override 40 public void run(){ 41 Integer bullets = BULLET_NUMBER_THREADLOCAL.get() - RANDOM.nextInt(BULLET_NUMBER); 42 Integer killEnemies = KILLED_ENEMIES_THREADLOCAL.get() + RANDOM.nextInt(TOTAL_PLAYERS/2); 43 Integer lifeValue = LIFE_VALUE_THREADLOCAL.get() - RANDOM.nextInt(LIFE_VALUE); 44 45 System.out.println(getName()+", BULLET_NUMBER is "+ bullets); 46 System.out.println(getName()+", KILLED_ENEMIES is "+ killEnemies); 47 System.out.println(getName()+", LIFE_VALUE is "+ lifeValue +"\n"); 48 49 BULLET_NUMBER_THREADLOCAL.remove(); 50 BULLET_NUMBER_THREADLOCAL.remove(); 51 BULLET_NUMBER_THREADLOCAL.remove(); 52 } 53 } 54 55 public static void main(String[] args) { 56 57 for (int i = 0 ; i < TOTAL_PLAYERS;i++){ 58 new Player().start(); 59 } 60 } 61 }
此例中,沒有進行set 操作,那么初始值又是如何進入每個線程成為獨立拷貝的呢?首先,雖然ThreadLocal 在定義時覆寫了initiaValue() 方法,但並非是在 BULLET_NUMBER_THREADLOCAL
對象加載靜態變量的時候執行的,而是每個線程在ThreadLoacl.get() 的時候都會執行到,其源碼如下:
1 public T get() { 2 Thread t = Thread.currentThread(); 3 ThreadLocalMap map = getMap(t); 4 if (map != null) { 5 ThreadLocalMap.Entry e = map.getEntry(this); 6 if (e != null) { 7 @SuppressWarnings("unchecked") 8 T result = (T)e.value; 9 return result; 10 } 11 } 12 return setInitialValue(); 13 }
每個線程都有自己的ThreadLoaclMap , 如果 map == null ,則直接執行setInitiaValue()。如果map 已經創建,就表示Thread 類的ThreadLocals 屬性已經初始化; 如果 e == null ,依然會執行到setInitiaValue()。setInitiaValue()的源碼如下:
1 private T setInitialValue() { 2 T value = initialValue(); 3 Thread t = Thread.currentThread(); 4 ThreadLocalMap map = getMap(t); 5 if (map != null) 6 map.set(this, value); 7 else 8 createMap(t, value); 9 return value; 10 }
在 CsGameByThreadLoacl 類的第1處 ,使用了ThreadLocalRandom 生成單獨的Random 實例。此類在JDK7 中引入,它使得每個線程都可以有自己的隨機數生成器。我們要避免Random 實例被多線程使用,雖然共享實例是線程安全的,但是會因競爭同一seed 而導致性能下降。 我們已經知道ThreadLoacl是每個線程單獨持有的。因為每個線程都有獨立的變量副本。其他線程不能訪問,所以不存在線程安全問題,也不會影響程序執行性能。ThreadLocal 對象通常是由private static 修飾的,因為都需要復制進入本地線程,所以非static 作用不大。需要注意的是,ThreadLocal 無法解決共享對象的更新問題。所以使用某個引用來操作共享對象是,依然需要進行線程同步。
ThreadLocal 有個靜態內部類叫ThreadLoaclMap,它還有個靜態內部類叫Entry ,在Thread 中的ThreadLocalMap 屬性的賦值是在ThreadLocal 類中的createMap() 中進行的,ThreadLoacl 與 ThreadLoclMap 有三組對應的方法:get()、set()、和remove(),在Threadlocal 中對他們只做校驗和判斷,最終的實現會落在ThreadLocalMap 上。Entry 繼承自WeakReference,沒有方法,只有一個value 成員變量,它的Key 是ThreadLocal對象。兩者簡要關系如下:
- 1個Thread 有且僅有一個ThreadLoaclMap 對象;
- 1個Entry 對象的key 弱應用指向一個ThreadLocal對象;
- 1個ThreadLocalMap 對象存儲多個Entry 對象;
- 1個ThreadLocal 對象可以被多個線程共享;
- ThreadLocal 對象不持有Value,Value 由線程的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; } }
所有Entry 對象都被ThreadLocalMap 類實例化對象threadLocals 持有。當線程對象執行完畢時,線程對象內的示例屬性均會被垃圾回收。源碼中weakReference 標識 ThreadLocal 的弱引用,及時線程正在執行中,只要ThreadLoacl對象引用被置成null,Entry 的key 就會在下一次YGC時被垃圾回收。而在ThreadLoacl 使用set() 和get()時,又會自動地將那些 key == null 的Value 置為null,使value 能夠被垃圾回收,避免內存泄露,但是理想很豐滿,現實很骨感,ThreadLocal 如源碼注釋所述:
ThreadLocal instances are typically private static fields in classes.
ThreadLocal 對象 通常作為私有靜態變量使用,那么其生命周期至少不會隨着線程池結束而結束。
線程池使用ThreadLocal 有三個重要方法。
set():如果沒有set 操作的ThreadLoacl,容易引起臟讀數據問題。
get():始終沒有get 操作的ThreadLocal 對象是沒有意義的。
remove() : 如果沒有remove 操作,容易引起內存泄露。
如果說一個Thread 是非靜態的,屬於某一個線程實例類,那就失去了線程間共享的本質屬性。那么ThreadLocal 到底有什么作用呢?我們知道,局部變量在方法內各個代碼塊間進行傳遞,而類變量在類方法間進行傳遞。復雜的線程方法可能需要調用多個方法來實現某個功能,這個時候用什么來傳遞線程內變量呢?答案就是ThreadLocal , 它通常用於同一線程內,跨類,誇方法傳遞數據。如果沒有ThreadLocal ,那么相互之間的信息傳遞,勢必要靠返回值和參數,這樣無形之中,有些類甚至有些架構會相互耦合。通過將Thread構造方法的最后一個參數設置為true,可以把當前線程的變量繼續往下傳遞給它創建子線程。
ThreadLocal 副作用
為了使線程安全地共享某個變量,JDK 開出了ThreadLocal 這劑葯方,但是葯有三分毒。ThreadLocl 主要會產生臟數據和內存泄露。這兩個問題通常是在線程池的線程中使用ThreadLocal 引發的,因為線程池有線程復用和內存常駐兩個特點。
1.臟數據
線程復用會產生臟數據。由於線程池會重用Thread對象,那么與Thread綁定的類靜態屬性也會被重用。如果在實現線程run() 方法中不顯示的調用remove() 清理與線程相關的ThreadLocal 信息。如果先一個線程不調用set() 設置初始值,那么就get() 到重用信息,包括ThreadLocl 所關聯線對象的值。
臟數據問題在實際故障中十分常見。比如 用戶A下單后沒有看到訂單記錄,而B卻看到了A的訂單記錄。通過排查發現是通過session 優化引起的。在原來的請求中,用戶每次請求Server,都需要去緩存里查詢用戶的session信息,這樣做無疑增加了一次調用。因此開發工程師決定采用某框架來緩存每個用戶對應的SecurityContext,它封裝了session 相關信息。優化后雖然為每一個用戶新建了一個session 相關的上下文,但是因為ThreadLoacl 沒有再線程結束是及時進行remove() 清理操作,在高並發場景下,線程池中的線程可能會讀取到上一個線程緩存的用戶信息。為了便於理解,用一段簡要代碼來模擬,如下所示:
public class DirtyDataInThreadLocal { private static ThreadLocal<String> threadLocal = new ThreadLocal<>(); public static void main(String[] args) { ExecutorService pool = Executors.newFixedThreadPool(1); for (int i = 0; i < 2; i++) { Mythread mythread = new Mythread(); pool.execute(mythread); } } private static class Mythread extends Thread{ private static boolean flag = true; @Override public void run() { if (flag){ threadLocal.set(this.getName()+". session info ."); flag = false; } System.out.println(this.getName()+" 線程是 "+threadLocal.get()); } } }
執行結果如下:
Thread-0 線程是 Thread-0. session info .
Thread-1 線程是 Thread-0. session info .
內存泄露
在源碼注釋中提示使用static 關鍵字來修改ThreadLocal。在此場景下,寄希望於ThreadLocal對象失去引用后,觸發弱引用機制來回收Entry 的Value 就不現實了。在上例中,如果不進行remove() 操作,那么這個線程執行完成后,通過ThreadLocal 對象持有的string對象是不會被釋放的。
以上兩個問題解決的辦法很簡單,就是每次用完ThreadLocal 時,必須調用remove() 方法清理。
ThreadLocal 並不解決多線程 共享 變量的問題。