前言
離職回老家,實在太無聊,于是乎給自己整了一套桌上型電腦配置,總價 1W+,本以為機器到位後可以打打遊戲,學學技術打發無聊的時光。但是我早已不是從前那個少年了,打 Dota 已經找不到大學時巅峰的自己,當年我一手 SF 真的是打遍天下無敵手......,和朋友打 LOL 又沒有精力去學一個新的遊戲,賊坑。。。
學技術又不想學,太懶了!!!于是乎,寫文章吧...正好年後找工作用得上!今天我們來談一談 Java 中存儲線程局部變量的類 ThreadLocal 。
ThreadLocal 介紹
This class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its {@code get} or {@code set} method) has its own, independently initialized copy of the variable. {@code ThreadLocal} instances are typically private static fields in classes that wish to associate state with a thread (e.g., a user ID or Transaction ID)
上面這段是該類的注釋:該提供線程局部變量。這些變量不同于它們正常的對應變量,因為每個通過 get()、set() 通路 ThreadLocal 變量的線程都有自己的、獨立初始化的變量副本。ThreadLocal 執行個體通常是類中的私有靜态字段,希望将狀态與線程關聯(例如,使用者ID或事務ID)。
簡單來說它的作用是作為一個資料結構,可以為每個線程分别存儲他們私有的資料。我們可以暫時簡單了解為下面這張圖(實際上這個圖是錯的)
後面我們會詳細介紹它的設計原理。
常用 API
方法 | 作用 |
public ThreadLocal() | 執行個體化對象 |
ThreadLocal.withInitial(Supplier<? extends S> supplier ) | 執行個體化對象并賦予它每個線程初始值 |
public void set(T value) | 設定目前線程綁定的變量 |
public T get() | 擷取目前線程綁定的變量 |
public void remove() | 移除目前線程綁定的變量 |
ThreadLocal 使用場景
Spring 事務管理器
在 Spring 事務實作中,TransactionSynchronizationManager 類中聲明了多個 ThreadLocal 類型的成員變量用以将事務執行過程中各種上下文資訊綁定到目前線程,包括目前事務連接配接對象、是否可讀、事務名稱、隔離級别等
public abstract class TransactionSynchronizationManager {
private static final ThreadLocal<Map<Object, Object>> resources = new NamedThreadLocal<>("Transactional resources");
private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations = new NamedThreadLocal<>("Transaction synchronizations");
private static final ThreadLocal<String> currentTransactionName = new NamedThreadLocal<>("Current transaction name");
private static final ThreadLocal<Boolean> currentTransactionReadOnly = new NamedThreadLocal<>("Current transaction read-only status");
private static final ThreadLocal<Integer> currentTransactionIsolationLevel = new NamedThreadLocal<>("Current transaction isolation level");
private static final ThreadLocal<Boolean> actualTransactionActive = new NamedThreadLocal<>("Actual transaction active");
//......
}
複制代碼
SpringMVC 存儲上下文 Request 資料
RequestContextHolder 這個類是 SpringMVC 中提供的持有上下文 Request 的一個類,内部實作就是有兩個 ThreadLocal 屬性去存儲請求對象資料。
private static final ThreadLocal<RequestAttributes> requestAttributesHolder =
new NamedThreadLocal<>("Request attributes");
private static final ThreadLocal<RequestAttributes> inheritableRequestAttributesHolder =
new NamedInheritableThreadLocal<>("Request context");
複制代碼
以便于我們在業務代碼中在沒有 HttpServletRequest 對象的位置也可以通過 ThreadLocal 擷取請求頭等資訊,比如我前面一篇關于 OpenFeign 向下遊傳遞 header 的文章就用到了它。
PageHelper 分頁的實作
之前流行的分頁插件之一 PageHelper 其分頁原理也是通過 ThreadLocal 實作,我們使用它進行分頁時隻需要在代碼中調用靜态方法
PageHelper.startPage(pageNum,pageSize);
複制代碼
接下來的第一條 SQL 就會自動進行分頁,其實原理就是它将分頁參數封裝到一個 Page 對象中,然後将 Page 放進 ThreadLocal 中以達到 web 環境中多個線程互相分頁不影響,後面就是都雷同的 SQL 拼接了。
存儲使用者身份資訊
在很久之前我們使用者登入資訊的存儲通常都是在 Session 中,後來大多是逐漸用 ThreadLocal 去代替從 Session 擷取使用者登入資訊了。首先我們在使用者每次請求需要授權的接口時,會讓使用者攜帶請求頭 token ,後端在攔截器中拿到這個 token 去 redis 查詢使用者資訊,或者如果這個 token 是 jwt 的話,直接解析它得到使用者資訊然後放進 ThreadLocal。
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String header = request.getHeader("x-auth-token");
//如果你的實作是 token 唯一字元串,從 Redis 拿使用者資訊
User user = redisTemplate.opsForValue().get(header);
//如果你的實作是 token 是jwt,那直接解析 jwt 拿到使用者資訊
//.......
if (user != null) {
CurrentUser.set(user);
return true;
}
return false;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
CurrentUser.clear();//請求結束之後不要忘記清除
}
複制代碼
CurrentUser 類
public class CurrentUser {
public static final ThreadLocal<User> USER_THREAD_LOCAL = new ThreadLocal<>();
public static void set(User user){
USER_THREAD_LOCAL.set(user);
}
public static User get(){
return USER_THREAD_LOCAL.get();
}
public static void clear(){
USER_THREAD_LOCAL.remove();
}
}
複制代碼
這樣我們在任何地方隻要使用 CurrentUser.get() 就能輕松擷取到目前登入使用者。
以上就是幾個 ThreadLocal 常見的場景,其核心理念就是利用 ThreadLocal 的線程隔離特性。
ThreadLocal 和 synchronized
值得注意的是 ThreadLocal 在解決線程安全問題上提供了一種不同于傳統并發安全的解決思路,傳統的 synchronized 或者 Lock 類是出于并發操作時讓多個線程排隊去通路共享資料,但是這樣的弊端就是會造成鎖競争,這是以時間換空間。
而 ThreadLocal 将這個問題換了一個角度看待,既然并發安全的問題原因是因為多個線程共享一份資料,那麼我現在就讓每個線程都擁有一份獨立資料,它們各自操作自己私有的本地變量,這樣就不會有并發安全問題,也沒有鎖競争.但是每個線程都要維護一份資料,會有額外的記憶體開銷,這是以空間換時間。
實際項目中我們應該用哪種方式,最終還是取決于業務場景更适合哪一種。
線程隔離的原理
ThreadLocal.set(T value) 源碼解讀
前面說了一些使用場景,這裡我們探究一下 ThreadLocal 是如何實作線程隔離的。這裡我們寫個極緻簡單的例子
ThreadLocal<User> local = ThreadLocal.withInitial(User::new);
new Thread(() -> local.set(new User())).start();
複制代碼
這個例子隻有兩行代碼,我們來看 local.set(T value) 方法的源碼
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
}
複制代碼
代碼很簡單,首先拿到目前線程,然後根據目前線程拿到一個 Map 資料結構,将我們傳進來的值設定到這個 Map 中,那麼重點就在這個 getMap() 中,檢視其源碼
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
複制代碼
我們發現這個代碼就更簡單了,直接傳回了目前線程的一個成員變量,Thread 類中是這樣定義的
public class Thread implements Runnable {
//...
ThreadLocal.ThreadLocalMap threadLocals = null;
//...
}
複制代碼
從這裡就可以明白它是如何實作線程隔離的,我們設定的值全都放進了目前線程對象的一個成員變量中存着呢,那當然是線程隔離的,你在哪個線程中去 set(),那就會儲存到哪個線程對象的成員變量中。
接着我們再看這行代碼 map.set(this, value);,很重要,這裡的 this 是什麼?是目前的 ThreadLocal<Integer> local; 對象,也就是說我們 set() 的值實際上是以目前方法的調用者 local 為 key,傳入的值為 value 儲存起來的鍵值對。簡單的了解為下圖
我們對于 ThreadLocal 的操作其實是對 Thread 的成員變量 threadLocals 進行操作。那麼這個時候我們就要改變一下固有的思維,因為在正常的思維中,我們看到這行代碼 local.set(new User()); 腦海中浮現的第一印象都是向 local 的成員變量中進行一個資料的指派,然而在 ThreadLocal 的實作中,這行代碼的意思是将 ThreadLocal 作為 key,傳入的值作為 value 存入到目前 Thread 對象的一個成員變量中。
ThreadLocalMap
上面我們看到了 Thread 類中的成員變量是 ThreadLocalMap 類型的,ThreadLocal、Thread、ThreadLocalMap 三者的類圖關系為
首先由于我們程式中可能會聲明多個 ThreadLocal 對象,那麼自然用于存放的資料結構就需要類似集合,需要一個線程可以存儲多個以 ThreadLocal 為 key 資料,考慮到查詢的時間複雜度以及各方面綜合考慮,Map 結構再适合不過。
ThreadLocalMap 是 ThreadLocal 的一個靜态内部類,它的内部又聲明了一個靜态内部類 Entry 來實作 K/V,這一點類似于 HashMap。
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
複制代碼
不同的是這裡的 Entry 是 弱引用 WeakReference 的子類,那麼在了解弱引用之後我們會發現在特定的場景下,如果不這麼設計可能造成記憶體洩漏。
弱引用
在分析記憶體洩漏之前我們必須知道 Jvm 中幾種引用類型以及它們的特點。這裡不詳細介紹,隻說結論
引用類型 | 回收機制 |
強引用 | 我們程式中聲明的對象其引用都是強引用,隻要其不指向 null ,GC 時候就不會被回收,即使記憶體溢出 |
軟引用 | 使用 SoftReference 類構造一個軟引用,與強引用的差別是當記憶體不足,GC 會回收軟引用指向的對象 |
弱引用 | 使用 WeakReference 類構造一個弱引用,與軟引用的差別是,隻要觸發 GC 就會回收弱引用指向的對象 |
上面的結論都可以通過簡單的代碼來驗證,這裡我們主要介紹結論。
記憶體洩漏
記憶體洩漏與記憶體溢出
- 記憶體溢出 —— 程式中真的記憶體不夠用了。
- 記憶體洩漏 —— 由于代碼問題導緻程式中本該被釋放的記憶體沒有被釋放,最終造成 “記憶體不夠用” 的假象。
記憶體圖
這裡我們通過上面的兩行樣例代碼。
ThreadLocal<User> local = ThreadLocal.withInitial(User::new);
new Thread(() -> local.set(new User())).start();
複制代碼
結合分析 ThreadLocalMap、Thread、ThreadLocal 的源碼可以得到一張完整的記憶體圖。
值得注意的是我們是沒有辦法直接聲明弱引用的,必須通過 WeakReference 去包裹一個對象持有弱引用,以下面代碼為例
WeakReference<User> wr = new WeakReference<>(new User());
複制代碼
它在記憶體中是這樣的
是以完整的記憶體圖應該能夠了解。
為什麼需要弱引用
使用反證法,假設我們的 Entry 不用弱引用,那麼會出現這樣的情況,我們聲明出來的 ThreadLocal 對象,如果我們不想用它了或者說在程式中它的生命周期結束了(實際上這種場景很少,一般來說我們的 ThreadLocal 對象都是以 static final 的形式定義在全局,這裡隻是存在這個可能),想讓 GC 回收掉它占用的記憶體,那麼我們隻需要讓沒有引用指向它即可 ,也就是将 1号線 幹掉。
但是由于 ThreadLocalMap 裡面也有持有我們聲明的 ThreadLocal 對象的強引用,如果我們想要回收的話就必須把這裡的強引用也幹掉,最好的方法是使用 remove() 方法移除。否則就需要幹掉線程裡面的 ThreadLocal.ThreadLocalMap threadLocals = null; 這個屬性,想要幹掉這個屬性就得等線程銷毀,然而實際業務中有的線程是 24h 不間斷執行的,也有的線程是位于線程池要被複用的,是以隻要有一個線程不銷毀,這個 ThreadLocal 對象就不會被回收,這就會産生記憶體洩漏。
但是如果這裡 Entry 的 key 是弱引用,隻要我們将 1号線 幹掉,下次 GC 的時候發現這個 ThreadLocal 對象隻有一個 2号線 弱引用指向它,就會将它回收掉。
public static void function1() {
ThreadLocal<User> local = ThreadLocal.withInitial(User::new);
local.set(new User());
local.get();
new Thread(() -> {
local.set(new User());
User user = local.get();
while (true) {
Thread.sleep(1000);
System.out.println("測試");
}
}).start();
}
複制代碼
上面這段代碼如果 Entry 是強引用,當 function1() 結束之後 local 指向的記憶體不會被回收,如果是弱引用,就會被回收。
remove() 防止記憶體洩漏
ThreadLocal 為了防止記憶體洩漏,已經用弱引用幫我們解決了一大隐患,難道使用弱引用就能完全避免記憶體洩漏嗎?并不是,還有一種情況,接着上面的章節,當我們 GC 将弱引用指向的 ThreadLocal 記憶體回收之後, ThreadLocalMap 裡面的 Entry 的 key 就變成 null 了,這樣我們就無法通路到它原先對應的 value,是以這個 value 将不會被回收,這才是實際場景中真正的記憶體洩漏問題。
是以我們在用完之後一定需要手動的調用 remove() 清除目前線程的局部變量值,也就是将對應的 Entry(K/V) 删掉,這樣即使後來 ThreadLocal 對象被回收,也不會造成記憶體洩漏問題。
值得注意的是我們觀察 set()、get() 源碼會發現它其實都調用了一個方法
private int expungeStaleEntry(int staleSlot) {
//......
if (k == null) {
e.value = null;
tab[i] = null;
size--;
}
//......
}
複制代碼
在每次操作的時候都會判斷是否存在 key 為 null 的鍵值對,如果存在就會删掉,以此來盡量的避免記憶體洩漏的問題,那這是不是意味着即使我們不手動 remove() 也可以呢?其實不然,因為實際業務中可能會出現長時間不調用 set()、get() 方法的情況,是以當後面的流程裡不再需要使用這個值得時候,手動 remove() 是一個好習慣,也是阿裡巴巴規範裡面的一個強制規定。
remove() 防止資料錯亂
實際 Web 項目中我們很多場景都會用到線程池,當用完之後将線程對象歸還到線程池,如果沒有 remove() ,下個請求到來,這個線程被複用時發現這個資料已經存在了,就直接拿過來用了,這個問題是很嚴重的,因為相當于一個線程用了另一個線程的資料,這會造成嚴重的業務 bug 。
作者:暮色妖娆丶
連結:https://juejin.cn/post/7188851834704887845