線程的私有領地 ThreadLocal


從名字上看,『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

https://github.com/SingleYam/overview_java

歡迎來踩!

YangAM 公眾號


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM