當前位置: 妍妍網 > 碼農

服了,一個ThreadLocal被問出了花

2024-02-22碼農

故事

地鐵上,小帥無力地倚靠著桿子,腦子裏盡是剛才面試官的奪命連環問,「用過ThreadLocal麽?ThreadLocal是如何解決共享變量存取的安全性的呢?你覺得啥場景下會用到ThreadLocal? 我們在日常用ThreadLocal的時候需要註意什麽?ThreadLocal在高並行場景下會造成記憶體泄漏嗎?為什麽?如何避免?......」


小夥伴們,試問一下,如果是你,面對上述的問題,你能否對答如流呢?

概要

我們將從以下點來全面剖析一下ThreadLocal。

概覽

基本篇

什麽是ThreadLocal?

ThreadLocal英文轉譯過來就是:執行緒本地量,它其實是一種執行緒的隔離機制,保障了多執行緒環境下對於共享變量存取的安全性。

看到上面的定義之後,那麽問題就來了,ThreadLocal是如何解決共享變量存取的安全性的呢?

其實ThreadLocal為變量在每個執行緒中都建立了一個副本,那麽每個執行緒可以存取自己內部的副本變量。由於副本都歸屬於各自的執行緒,所以就不存在多執行緒共享的問題了。

便於理解,我們看一下下圖。

結構圖

至於上述圖中提及的threadLocals(ThreadLocalMap),我們後文看原始碼的時候再繼續來看。大家心中暫時有個概念。

既然都是保證執行緒存取的安全性,那麽和Synchronized區別是什麽呢?

在上面聊到共享變量存取安全性的問題上,其實大家還會很容易想起另外一個關鍵字Synchronized。聊聊區別吧,整理了一張圖,看起來可能會更加直觀一些,如下。

對比

透過上圖,我們發現ThreadLocal其實是一種執行緒隔離機制。Synchronized則是一種基於Happens-Before規則裏的監視器鎖規則從而保證同一個時刻只有一個執行緒能夠對共享變量進行更新。

Synchronized加鎖會帶來效能上的下降。ThreadLocal采用了空間換時間的設計思想,也就是說每個執行緒裏面都有一個專門的容器來儲存共享變量的副本資訊,然後每個執行緒只對自己的變量副本做相對應的更新操作,這樣避免了多執行緒鎖競爭的開銷。

ThreadLocal的使用

上面說了這麽多,咱們來使用一下。就拿SimpleDateFormat來做個例子。當然也會有一道這樣的面試題,SimpleDateFormat是否是執行緒安全的?在阿裏Java開發規約中,有強制性地提到SimpleDateFormat 是執行緒不安全的類。其實主要的原因是由於多執行緒操作SimpleDateFormat中的Calendar物件參照,然後出現臟讀導致的。

踩坑程式碼:

/**
 * @author 公眾號:程式設計師老貓
 * @date 2024/2/1 22:58
 */

public classDateFormatTest{
privatestaticfinal SimpleDateFormat simpleDateFormat =
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
publicstatic Date parse(String dateString){
Date date = null;
try {
date = simpleDateFormat.parse(dateString);
catch (ParseException e) {
e.printStackTrace();
}
return date;
}
publicstaticvoidmain(String[] args){
ExecutorService executorService = Executors.newFixedThreadPool(20);
for (int i = 0; i < 20; i++) {
executorService.execute(()->{
System.out.println(parse("2024-02-01 23:34:30"));
});
}
executorService.shutdown();
}
}

上述咱們透過執行緒池的方式針對SimpleDateFormat進行了測試(如果大家需要深入了解一下執行緒池的相關原理,可以戳「 」)。其輸出結果如下。

我們可以看到剛開始好好的,後面就異常了。

我們透過ThreadLocal的方式將其最佳化一下。程式碼如下:

/**
 * @author 公眾號:程式設計師老貓
 * @date 2024/2/1 22:58
 */

public classDateFormatTest{
privatestaticfinal ThreadLocal<SimpleDateFormat> dateFormatThreadLocal =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
publicstatic Date parse(String dateString){
Date date = null;
try {
date = dateFormatThreadLocal.get().parse(dateString);
catch (ParseException e) {
e.printStackTrace();
}
return date;
}
publicstaticvoidmain(String[] args){
ExecutorService executorService = Executors.newFixedThreadPool(10);
for (int i = 0; i < 20; i++) {
executorService.execute(()->{
System.out.println(parse("2024-02-01 23:34:30"));
});
}
executorService.shutdown();
}
}


執行了一下,完全正常了。

Thu Feb 01 23:34:30 CST 2024
Thu Feb 01 23:34:30 CST 2024
Thu Feb 01 23:34:30 CST 2024
Thu Feb 01 23:34:30 CST 2024
Thu Feb 01 23:34:30 CST 2024
Thu Feb 01 23:34:30 CST 2024
Thu Feb 01 23:34:30 CST 2024
Thu Feb 01 23:34:30 CST 2024
Thu Feb 01 23:34:30 CST 2024

TheadLocal使用場景

那麽我們什麽時候會用到ThreadLocal呢?

  1. 上面針對SimpleDateFormat的封裝也算是一個吧。

  2. 用來替代參數鏈傳遞:在編寫API介面時,可以將需要傳遞的參數放入ThreadLocal中,從而不需要在每個呼叫的方法上都顯式地傳遞這些參數。這種方法雖然不如將參數封裝為物件傳遞來得常見,但在某些情況下可以簡化程式碼結構。

  3. 資料庫連線和會話管理:在某些套用中,如Web應用程式,ThreadLocal可以用來保持對資料庫連線或會話的管理,以簡化並行控制並提高效能。例如,可以使用ThreadLocal來維護一個連線池,使得每個請求都能共享相同的連線,而不是每次都需要重新建立連線。

  4. 全域儲存資訊:例如在前後端分離的套用中,ThreadLocal可以用來在伺服端維護使用者的上下文資訊或者一些配置資訊,而不需要透過HTTP請求攜帶大量的使用者資訊。這樣做可以在不改變原有架構的情況下,提供更好的使用者體驗。

如果大家還能想到其他使用的場景也歡迎留言。

昇華篇

ThreadLocal原理

上述其實咱們聊得相對而言還是比較淺的。那麽接下來,咱們豐富一下之前提到的結構圖,從原始碼側深度剖一下ThreadLocal吧。

深度結構圖

對應上述圖中,解釋一下。

  1. 圖中有兩個執行緒Thread1以及Thread2。

  2. Thread類中有一個叫做threadLocals的成員變量,它是ThreadLocal.ThreadLocalMap型別的。

  3. ThreadLocalMap內部維護了Entry陣列,每個Entry代表一個完整的物件,key是ThreadLocal本身,value是ThreadLocal的泛型物件值。

對應的我們看一下Thread的原始碼,如下:

public classThreadimplementsRunnable{
...
ThreadLocal.ThreadLocalMap threadLocals = null;
...
}

在源碼中threadLocals的初始值為Null。

抽絲剝繭,咱們繼續看一下ThreadLocalMap在呼叫建構函式進行初始化的原始碼:


static classThreadLocalMap{
privatestaticfinalint INITIAL_CAPACITY = 16//初始化容量
private Entry[] table; //ThreadLocalMap數據真正儲存在table中
privateint size = 0//ThreadLocalMap條數
privateint threshold; // 預設為0,達到這個大小,則擴容
//類Entry的實作
static classEntryextendsWeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
//建構函式
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY]; //初始化table陣列,INITIAL_CAPACITY預設值為16
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1); //key和16取得哈希值
table[i] = new Entry(firstKey, firstValue);//建立節點,設定key-value
size = 1;
setThreshold(INITIAL_CAPACITY); //設定擴容閾值
}
}

在源碼中涉及比較核心的還有set,get以及remove方法。我們依次來看一下:

set方法如下:

publicvoidset(T value){
Thread t = Thread.currentThread(); //獲取當前執行緒t
ThreadLocalMap map = getMap(t); //根據當前執行緒獲取到ThreadLocalMap
if (map != null) //如果獲取的ThreadLocalMap物件不為空
map.set(this, value); //K,V設定到ThreadLocalMap中
else
createMap(t, value); //建立一個新的ThreadLocalMap
}
ThreadLocalMap getMap(Thread t){
return t.threadLocals; //返回Thread物件的ThreadLocalMap內容
}
voidcreateMap(Thread t, T firstValue)//呼叫ThreadLocalMap的建構函式
t.threadLocals = new ThreadLocalMap(this, firstValue); //this表示當前類ThreadLocal
}

get方法如下:

public T get(){
//1、獲取當前執行緒
Thread t = Thread.currentThread();
//2、獲取當前執行緒的ThreadLocalMap
ThreadLocalMap map = getMap(t);
//3、如果map數據不為空,
if (map != null) {
//3.1、獲取threalLocalMap中儲存的值
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
//如果是數據為null,則初始化,初始化的結果,TheralLocalMap中存放key值為threadLocal,值為null
return setInitialValue();
}

private T setInitialValue(){
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}

remove方法:

publicvoidremove(){
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}

那麽為什麽需要remove方法呢?其實這裏會涉及到記憶體泄漏的問題了。後面咱們細看。

對照著上述的結構圖以及源碼,如果面試官問ThreadLocal原理的時候,相信大家應該可以說出個所以然來。

  1. Thread執行緒類有一個型別為ThreadLocal.ThreadLocalMap的變量threadLocals,即每個執行緒都有一個屬於自己的ThreadLocalMap。

  2. ThreadLocalMap方法內部維護著Entry陣列,其中key是ThreadLocal本身,而value則為其泛型值。

  3. 並行場景下,每個執行緒都會儲存當前變量副本到自己的ThreadLocalMap中,後續這個執行緒對於共享變量的操作,都是從TheadLocalMap裏進行變更,不會影響全域共享變量的值。

高並行場景下ThreadLocal會造成記憶體泄漏嗎?什麽原因導致?如何避免?

造成記憶體泄漏的原因

這個問題其實還是得從ThreadLocal底層源碼的實作去看。高並行場景下,如果對ThreadLocal處理得當的話其實就不會造成記憶體泄漏。我們看下面這樣一組原始碼片段:

static classThreadLocalMap{
...
//類Entry的實作
static classEntryextendsWeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
...
}

上文中其實我們已經知道Entry中以key和value的形式儲存,key是ThreadLocal本身,上面程式碼中我們看到entry進行key設定的時候用的是super(k)。那就意味著呼叫的父類的方法去設定了key,我們再看一下父類是什麽,父類其實是WeakReference。關於WeakReference底層的實作,大家有興趣可以展開去看看原始碼。

WeakReference 如字面意思,弱參照,當一個物件僅僅被weak reference(弱參照)指向, 而沒有任何其他strong reference(強參照)指向的時候, 如果這時GC執行, 那麽這個物件就會被回收,不論當前的記憶體空間是否足夠,這個物件都會被回收。

關於這些參照的強弱,稍微聊一下,這裏其實涉及到jvm的回收機制。在JDK1.2之後,java對參照的概念其實做了擴充的,分為強參照,軟參照,弱參照,虛參照。

強參照:其實就是咱們一般用「=」的賦值行為,如 Student s = new Student(),只要強參照還在,物件就不會被回收。

軟參照:不是必須存活的物件,jvm在記憶體不夠的情況下即將記憶體溢位前會對其進行回收。例如緩存。

弱參照:非必須存活的物件,參照關系比軟參照還弱,無論記憶體夠還是不夠,下次的GC一定會被回收。

虛參照:別名幽靈參照或者幻影參照。等同於沒有參照,唯一的目的是物件被回收的時候會受到系統通知。

明白這些概念之後,咱們再看看上面的原始碼,我們就會發現,原來Key其實是弱參照,而裏面的value因為是直接賦值行為所以是強參照。

如下圖:

jvm儲存

圖中我們可以看到由於threadLocal物件是弱參照,如果外部沒有強參照指向的話,它就會被GC回收,那麽這個時候導致Entry的key就為NULL,如果此時value外部也沒有強參照指向的話,那麽這個value就永遠無法存取了,按道理也該被回收。但是由於entry還在強參照value(看原始碼)。那麽此時value就無法被回收,此時記憶體泄漏就出現了。本質原因是因為value成為了一個永遠無法被存取也無法被回收的物件。

那肯定有小夥伴會有疑問了,執行緒本身生命周期不是很短麽,如果短時間內被銷毀,就不會記憶體泄漏了,因為只要執行緒銷毀,那麽value也會被回收。這話是沒錯。但是咱們的執行緒是電腦珍貴資源,為了避免重復建立執行緒帶來開銷,系統中我們往往會使用執行緒池,如果使用執行緒池的話,那麽執行緒的生命周期就被拉長了,那麽就可想而知了。

如何避免

解法如下:

  1. 每次使用完畢之後記得呼叫一下remove()方法清除數據。

  2. ThreadLocal變量盡量定義成static final型別,避免頻繁建立ThreadLocal例項。這樣可以保證程式中一直存在ThreadLocal強參照,也能保證任何時候都能透過ThreadLocal的弱參照存取Entry的value值,從而進行清除。

不過話說回來,其實ThreadLocal內部也做了最佳化的。在set()的時候也會采樣清理,擴容的時候也會檢查(這裏希望大家自己深入看一下原始碼),在get()的時候,如果沒有直接命中或者向後環形尋找的時候也會進行清理。但是為了系統的穩健萬無一失,所以大家盡量還是將上面的兩個註意點在寫程式碼的時候註意下。

總結

面試的時候大家總會去背一些八股文,但是這種也只是臨時應付面試官而已,真正的懂其中的原理才是硬道理。無論咋問,萬變不離核心原理。當然這些核心原理在我們的日常編碼中也會給我們帶來很大的幫助,用法很簡單,翻車了如何處理,那還不是得知其所以然麽。夥伴們,你們覺得呢?

👇🏻 點選下方閱讀原文,獲取魚皮往期編程幹貨。

往期推薦