執行緒的私有領地 ThreadLocal

YangAM發表於2019-01-29

從名字上看,『ThreadLocal』可能會給你一種本地執行緒的概念印象,可能會讓你聯想到它是一個特殊的執行緒。

但實際上,『ThreadLocal』卻營造了一種「執行緒本地變數」的概念,也就是說,同一個變數在每個執行緒的內部,都有一份副本,且相互之間具有不同的取值。

這樣的設計具有怎樣的應用場景呢?是怎麼樣的一種設計原理呢?

別急,本篇就來詳細的探討探討它。

基本介紹

上面我們粗略的介紹了「什麼是 ThreadLocal ?」的這個問題,下面我們來看看它的一個基本使用是什麼樣的,以及設計出來旨在解決什麼問題等相關內容。

我們先看這麼一段程式:

image

函式 A 呼叫了函式 B,接著呼叫了函式 C、D,這麼深層次的呼叫體系在真實的業務場景下是很常見的。

但是假如我現在要對函式 D 中要列印的字串進行動態的傳入,那你是不是得修改每一個方法的形參列表,增加一個形參位,接著在函式 A 中的呼叫上傳入一個引數過來?

這太繁瑣了,我們使用 ThreadLocal 就可以簡單解決這種「需求變更」的問題:

image

這一連串函式的呼叫必然是同一個執行緒呼叫的,那麼我們只要在最開頭儲存下一個變數,無論當前執行緒呼叫了多少層函式,這個區域性變數一直都存在。

這是 ThreadLocal 的一種使用場景,但有點低估它的價值了,ThreadLocal 最常用的使用場景是,在多執行緒併發情境下避免一些由於共享變數競爭訪問導致的併發問題。

我們來看看廣為大家詬病的 SimpleDateFormat,周所周知,這是個多執行緒不安全的類,我們再次回顧下以前的內容:

SimpleDateFormat 是一個用於格式化日期和字串的工具類,主要有兩個核心方法,format 和 parse,前者用於將一個日期轉換成指定格式的字串,後者用於將一個指定格式的字串轉換成一個日期物件。

但是,這兩個方法都不是執行緒安全的,format 方法倒還好,最多導致傳入的 Date 格式化成錯誤的值,而 parse 將直接導致多種異常。原因很簡單,他們公用了同一個區域性變數。

image

format 方法的第一個行就是將傳入的 Date 物件儲存到父類 DateFormat 的欄位 calendar 上,然後會在後面邏輯中讀取這個 Date 例項並完成轉換字串的邏輯。

但是完全有可能在你設定完日期時間後,其他執行緒也執行 format 方法並覆蓋了你的日期時間 calendar 中的值,這樣你後續的轉換字串的動作基於的日期已經不再是傳入的日期物件了,導致的最終結果就是錯誤將別人的日期 Date 轉換成字串並返回了。

不信,你看這麼一段程式碼:

image

執行後,我給你找一個錯誤的資料列印日誌:

image

明顯的是構造的上一個執行緒傳入的 Date 引數,也就是在格式化的過程中被別的執行緒覆蓋了自己傳入的 Date 導致的錯誤的格式化資料。

parse 方法的執行緒不安全就不帶大家重現了,它更嚴重,因為方法內部會執行一個 clear 操作清空 calendar 欄位儲存的值,並且還是非執行緒安全式的清空,會導致某些其他執行緒發生轉換異常的,具體的大家可以自己去看。

而我們簡單的使用 ThreadLocal 就可以解決上述 format 的執行緒不安全問題:

image

ThreadLocal 的 set 方法將導致每個執行緒的內部都持有一個 SimpleDateFormat 的例項,自己用自己的,也就不存在因為共享變數而導致的資料一致性問題了。

以上,我們介紹了 ThreadLocal 的兩種不同的使用場景,其中第二種更加的常見一點,下面我們來看原理。

基本原理

ThreadLocal 在使用上還是很簡單的,但是其內部實現以及與各個執行緒的關聯還是有些繞的,接下來我們深入去看看。

基本欄位屬性

image

除了 threadLocalHashCode 是一個常量,每當建立一個新的 ThreadLocal 例項的時候就會根據 nextHashCode 和 HASH_INCREMENT 去計算初始的賦值。

因為 nextHashCode 是靜態的,是類共享的,所以,每建立一個 ThreadLocal 例項,它的 threadLocalHashCode 是前一個例項的基礎上加固定常量 0x61c88647

這個值經換算是一個斐波那契數,每次增量該常量可以分散 hash 值的分佈,減少後續在 map 中定位儲存資料時產生衝突。

內部類 ThreadLocalMap

ThreadLocalMap 的內部實現是很類似 HashMap 的內部實現的,如果你分析過 HashMap,這一塊會容易理解很多,下面我們看其中重要的幾個欄位:

image

首先,Entry 這個類是 ThreadLocalMap 中定義的內部類,很簡單,儲存了兩個主要內容,一個是 ThreadLocal 的區域性變數,一個是 Object 型別的 value 值。

INITIAL_CAPACITY 指定了 table 的初始化容量,或者說是預設的陣列初始化長度。

size 指定了 table 中實際有效的 Entry 數量。

threshold 是一個閾值的概念抽象,當 table 的 size 達到了這個閾值,就會觸發一個動態擴容動作,擴容 table。

所以,對於 ThreadLocal 的一個不太恰當的理解是,它只是一個封裝了 hashCode 的 key,這個 key 決定了我們的 value 該儲存在 ThreadLocalMap 內部 table 的哪個位置。

這一點也在它的建構函式中也可見一斑:

image

這個 i 就是當前 Entry 要儲存在 table 上的具體索引,它是如何計算的?

就是用我們的 key(ThreadLocal 例項)內部儲存的 hashcode 取餘 table 容量計算而來。

threshold 會被設定為 table 容量的三分之二。

至於其中的 set、get 方法我們待會分析,至此 ThreadLocal 中已經不剩下什麼重要的東西了,雖然 ThreadLocalMap 是 ThreadLocal 的內部類,但是與 ThreadLocal 所表現出來的語義並沒有很密切的關係,可能為了某些安全性吧,將 ThreadLocalMap 定義為了 ThreadLocal 的靜態內部類。

set、get方法原理

介紹之前,我們先看 Thread 類中的一個欄位:

image

Thread 類中持有了兩個 ThreadLocalMap 例項,兩個例項稍有區別,inheritableThreadLocals 相比於 threadLocals 來說具有更大的特殊性。

區別在於,如果父執行緒(即建立自己的那個執行緒)使用了 inheritableThreadLocals 儲存執行緒本地變數,那麼本執行緒的建立過程中也會使用 inheritableThreadLocals 進行本地變數的儲存並且將父執行緒中所有的本地變數進行一份拷貝,填充到自己的 inheritableThreadLocals 中。

具體怎麼實現的大家可以自行去檢視,jdk 中重新定義了一個 InheritableThreadLocal 類,繼承的 ThreadLocal 並重寫了其中的 getMap 方法,導致你外部的 get 操作會轉而返回 inheritableThreadLocals 而不再是 threadLocals。

現在我們來看 ThreadLocal 的 set 方法:

image

set 方法還是很簡單的,獲取當前執行緒內部的 ThreadLocalMap 例項,如果不是空的就往裡面增加一條記錄,反之先初始化一個 map 再增加一條記錄進去。

核心還是在 ThreadLocalMap 的 set 方法:

image

這個方法的大體邏輯如下:

  1. 根據 ThreadLocal 這個 key 計算出當前節點應該儲存在 table 的哪個索引位置
  2. 如果該位置上不是空,產生了 hash 衝突,被別的節點提前佔有了。那麼會將該節點儲存在 i+1 的索引位置上
  3. 如果該位置是空,那麼將自己掛在這個位置上
  4. 最後,如果新增結束後,發現 table 中有效節點數達到了閾值 threshold,那麼將呼叫 rehash 方法進行一次擴容並轉移資料的過程。

可能有些細心的人會疑問,為什麼整個方法內沒看到一行處理併發的同步語句?

有這樣的疑問,你可能還沒有完全理解 ThreadLocal 的設計思路,ThreadLocalMap 已經是執行緒的私有領地了,別的執行緒是不可能訪問的到的,又何來同步問題?

get 方法:

image

既然存是用的 ThreadLocal 例項作為 key,取自然也是根據該例項進行 get 了,並不難理解。

到這裡,關於 ThreadLocal 基本的類結構體系、與 Thread 的關聯關係,以及核心的 set、get 方法邏輯實現我們都予以了分析,不知道你理解的怎樣了呢?歡迎你和我交流!

記憶體洩露

在這之前,我們關注一個問題,很多人對 ThreadLocal 的一個誤解,覺得他是不安全的,會產生『記憶體洩漏』的問題,我們一起來看看是不是這樣。

首先,ThreadLocal 確實是存在『記憶體洩漏』這個記憶體隱患的,但是一大堆人把源頭指向 Entry 這個節點類。

image

很明顯,我們 Entry 將 key 儲存為『弱引用』,什麼是弱引用這裡不再贅述了,而將 value 儲存為『強引用』,於是他們的記憶體結構就是這樣的(盜了張圖):

image

我們的 ThreadLocal 例項被建立在堆中,方法棧中存在一個對它的強引用,我們的 Entry 例項中存在一個對他的弱引用。

重點來了,有人就認為,一旦我在主程式中丟失了對該例項的強引用,或是賦空了該例項,那麼 GC 會無視該例項存在著一個弱引用,而直接回收了該資源,以至於你永遠無法訪問到該 Entry 例項的 value 屬性且無法回收它,所以導致的記憶體洩漏。

看起來是有道理,但是不使用弱引用就沒有記憶體洩漏了嗎?

你換成強引用,會導致整個 Entry 例項都是無用資料,更大的記憶體洩漏。反而使用弱引用後,當你呼叫 get 方法的時候,會由於 key 為 null,執行清除邏輯,將 Entry 例項賦 null,最後由 GC 回收該記憶體資源。

但這始終不能解決 ThreadLocal 的記憶體洩漏問題,建議的做法是,當某個本地變數不用的時候,手動的呼叫 remove 方法進行移除。期待 jdk 能更新 ThreadLocal 的實現,程式碼層解決這個問題。

關注公眾不迷路,一個愛分享的程式設計師。
公眾號回覆「1024」加作者微信一起探討學習!
每篇文章用到的所有案例程式碼素材都會上傳我個人 github
github.com/SingleYam/o…
歡迎來踩!

YangAM 公眾號

相關文章