Java基礎(1)——ThreadLocal

邁吉發表於2022-05-25

1. Java基礎(1)——ThreadLocal

1.1. ThreadLocal

ThreadLocal是一個泛型類,當我們在一個類中宣告一個欄位:private ThreadLocal<Foo> threadLocalFoo = new ThreadLocal<>();時,這時候,即使不同的執行緒持有了該類的同一個例項,那麼它們在訪問該例項的threadLocalFoo的時候訪問的是不同的Foo物件,這些Foo物件和這些執行緒是一一對應的關係,並被這些執行緒所私有,因此每個執行緒不需要對自己從threadLocalFoo獲得的Foo例項進行加鎖(加鎖也沒用啊),這種無鎖化的設計提高了並行能力,但注意ThreadLocal並不是萬能的,有些場景可以使用ThreadLocal(比如Spring中的事務),但有些場景它的語義就是必須對同一個物件例項進行加鎖後獨佔地訪問,比如單例模式,這種ThreadLocal就起不了作用了。

當然ThreadLocal還提供了initialValue這個protected方法,用來建立宣告的泛型型別物件,因此我們還可以以下面這種方式來宣告一個thread local:

        ThreadLocal<Foo> threadLocal = new ThreadLocal<Foo>(){

            @Override
            protected Foo initialValue() {
                return new Foo();
            }
        };

同時ThreadLocal還提供了一個withInitial靜態方法,該方法接收一個相同泛型型別的Supplier,返回ThreadLocal。

Java的每個Thread例項中,都有一個ThreadLocalMap型別的例項欄位,它存放了該執行緒所用到過的所有ThreadLocal式樣的例項物件,比如,有個類中宣告瞭這個欄位private ThreadLocal<Foo> threadLocalFoo = new ThreadLocal<>();,雖然它的一個例項被多個執行緒持有,但這些執行緒不一定都訪問過這個例項的threadLocalFoo欄位,只有訪問過這個欄位的Thread,它的thread local map中才會存Foo物件(以Entry的方式存,key為該ThreadLocal例項(共享),value為每個執行緒自己持有的Foo物件(私有))。

注意,我們使用ThreadLocal的是因為有些物件每個執行緒都可以持有一份,然後我們才使用ThreadLocal來避免同一個物件的例項方法的併發操作,但這樣的話我們要謹防ThreadLocal的退化:如果使用它的時候,用之前都是set,之後就remove,那麼相當於每訪問一次ThreadLocal都要建立出一個新的物件出來,這樣發揮不出ThreadLocal節省物件數量的作用。ThreadLocal一般被宣告為static欄位。

1.1.1. get方法

如果當前的Thread中的thread local map欄位不空,並且其中存的有對應的物件,那麼返回。

如果thread local map欄位不空,但是沒有存對應的物件,那麼使用initialValue建立物件,然後將它和該ThreadLocal例項,打包成Entry放入當前的thread local map中,返回建立的物件。

如果thread local map欄位為空,那麼首先建立物件,然後建立該執行緒的thread local map,然後再存Entry,再返回建立的物件。

總而言之呢,get方法就是說返回的物件都必須從當前執行緒的thread local map中取,thread local map沒建立,就建立thread local map,建立了但裡面沒有需要的物件,那麼就建立物件並將其塞進去,反正必須從thread local map中拿就對了。

Java基礎(1)——ThreadLocal

setInitialValue方法:

Java基礎(1)——ThreadLocal

createMap方法:

Java基礎(1)——ThreadLocal

1.1.2. set方法

Set方法,將傳入的物件設定到當前的執行緒的thread local map中,注意,Entry的Key為set方法所在的ThreadLocal例項。

還是一樣,沒有thread local map就建立thread local map,反正必須塞入當前的thread local map中。

Java基礎(1)——ThreadLocal

1.1.3. remove方法

remove方法,就是獲取當前執行緒的thread local map,如果它不空的話,就移除key為remove方法所在的ThreadLocal的Entry(不同的ThreadLocal例項對應著不同的Entry,而同一個ThreadLocal例項在一個thread local map中最多存一個,但是可以存在多個thread local map中)。

Java基礎(1)——ThreadLocal

1.2. ThreadLocalMap(ThreadLocal內部類)

ThreadLocalMap是ThreadLocal機制的關鍵,它不被使用ThreadLocal的使用者所感知,它是ThreadLocal的靜態內部類,它的所有方法都是private方法,並且該類的可見性是包可見的,因此ThreadLocalMap類中的所有方法都只能被ThreadLocal的方法呼叫。

ThreadLocalMap的底層儲存是ThreadLocalMap.Entry型別的陣列,它的碰撞處理策略不是HashMap的開鏈法(開雜湊方法),而是線性探測法(linear probing,屬於閉雜湊方法,常見的其他閉雜湊方法還有:平方探測法、雙雜湊法)。這個線性探測法就是說:

  • 在put的時候,先根據key的hash值定位到在陣列中的槽位,如果對應的位置沒有Entry,那麼就可以把當前的鍵值對放入這裡,反之,如果該位置已經被佔用的話,那麼需要獲取該位置的下一個位置(如果當前位置為陣列最後一個位置,那麼下一個位置為0),直到找到空位為止

  • 在get的時候,根據key找Entry,也是首先先根據key的hash值定位到在陣列中的槽位,如果這個槽位空著,那麼說明當前map沒有存這個key,如果這個槽位不空,那麼還要檢查Entry中的key是否就是當前的key,如果不是的話還要繼續向後探測,直到遇到了空位或者遇到了key為當前key的Entry。

  • 在remove的時候,首先跟get一樣,找到key對應的Entry,然後將其移除,但是移除完之後,如果該槽位後面連續的槽位也都被佔用了,那麼還要對這些槽位中的Entry再進行位置修正。

和Map介面中的Entry不一樣,ThreadLocalMap.Entry宣告為:

Java基礎(1)——ThreadLocal

ThreadLocalMap.Entry是一個對ThreadLocal物件的弱引用,也就是說,雖然該Entry會持有ThreadLocal物件,但是並不會影響該ThreadLocal物件的GC,而這個弱引用物件Entry本身是個尋常的Java物件,它還持有了ThreadLocal的泛型型別的物件(比如上面例子中的Foo),這個持有關係是強引用,只有當ThreadLocalMap的底層陣列不再持有這個Entry時,該Entry才會被GC。因此,也就是說,如果ThreadLocalMap如果不做特殊處理的話,那麼即使是ThreadLocal例項都被GC了,但是它們對應的Entry依舊無法被GC,導致實際使用的泛型型別物件也無法被GC,只是這些Entry引用的ThreadLocal變成null了,這個問題其實就是記憶體洩露

為了解決這個記憶體洩露問題,ThreadLocalMap線上性探測操作中,如果發現了持有的thread local已經被GC的Entry(Stale Entry),那麼就不再持有這個Entry,使得這個Entry可以被GC,但是即使這樣依然無法完全保證stale entry都能及時的被清理,這個殘留的問題就是偽記憶體洩露問題

這個偽記憶體洩露問題一般存在於執行緒池的場景下,因為如果執行緒本身被銷燬,那麼thread local map也會銷燬,也不存在什麼洩露問題。

為了解決這個偽記憶體洩露問題,我們作用應用程式的開發者,在使用到threadlocal時,如果我們不再需要它時,那麼就要手動進行remove操作,使得對應的Entry可以被GC。

這個Entry陣列初始容量為16,threshold為當前陣列長度的三分之二(hard code),每次向Thread local map放入entry之後,會檢查更新後的size(陣列中的Entry數量)是否達到了threshold,如果達到了,那麼就需要進行擴容,擴容的邏輯是,先把所有stale entry清理後,判斷清理的數量是否達到了四分之一threshold,如果是,那麼說明當前thread local map只是因為stale entry太多的緣故導致的容量緊張,就只需執行清理動作,而不用將底層陣列容量翻倍並進行entry的遷移,這個策略的目的:

  1. 陣列容量翻倍本身佔用空間,並且擴容時搬運entry的操作相對相不擴容清理stale entry的操作來說開銷更大。

  2. 更好的去抑制上面講的偽記憶體洩露問題。

注意,thread local map底層的Entry陣列只會擴容,不會縮容。

1.2.1. 建構函式

Java基礎(1)——ThreadLocal

1.2.2. getEntry方法

Java基礎(1)——ThreadLocal

getEntryAfterMiss

getEntryAfterMiss就是get操作的線性探測步驟。

Java基礎(1)——ThreadLocal

expungeStaleEntry:

這個expungeStaleEntry就是說呢,需要刪除那些Stale的Entry(已經被GC後的ThreadLocal例項對應的Entry)。

它不止刪除給定stale位置的entry,它還有線性探測該位置之後被連續佔用的位置的entry。在這些entry中,對於不是stale的,我們需要把它們挪到更正後的位置上,對於是stale的,將其刪除。

Java基礎(1)——ThreadLocal

expungeStaleEntries:

expungeStaleEntries方法就是遍歷陣列中的所有Entry,檢查是否stale,如果stale,那麼呼叫expungeStaleEntry來刪除並調整。

Java基礎(1)——ThreadLocal

1.2.3. set方法

Set方法往thread local map中新增一個Entry。

如果該Entry未經線性探測時的位置未被佔用,那麼直接佔用,更新size計數,並且從該位置嘗試清理一些stale entry(見cleanSomeSlots方法)。如果清理成功,那麼此時size鐵定沒有超出threshold(因為此時至少清理了一個Entry,而set方法一次只set一個,並且初始情況下size小於threshold)。如果沒有清理到到,那麼就判斷更新後的size是否超過了threshold,如果超過了,那麼要擴容。

如果原始位置被佔用了,那麼就需要通過線性探測,探測之後的位置,在探測過程中:

  • 如果發現已經有給定的Key的Entry了,那麼直接替換value就完事了。

  • 如果沒有發現stale entry,那麼就將遇到的第一個空位用來放置該Entry,然後完事,此時同樣需要像上面一樣嘗試清理stale entry,如果清理失敗看需不需要擴容等。

  • 如果在探測中發現了stale entry,那麼就進行替換操作,注意這個替換操作很複雜,見replaceStaleEntry方法。

Java基礎(1)——ThreadLocal

replaceStaleEntry:

前兩個引數是需要放置的Entry的資訊,最後一個引數是stale entry的位置。

首先是向前探測,因為給的stale entry的位置可能是處於一個連續被佔用段的中間,因此來向前探測,來找到該連續佔用段的第一個stale位置。

然後再從給定的stale位置向後探測,在這個向後探測的過程中:

  • 如果遇到了跟傳入key對應的Entry,那麼就將該Entry給挪到傳入的stale位置。如果上一步向前探測時沒有找到stale entry,那麼就從當前的位置向後回收連續佔用段的stale entry;如果向前探測時找到了的話,就從這個找到的位置向後回收本連續佔用段的stale entry。

  • 如果沒有遇到該key對應的Entry,並且之前向前探測的時候也沒有找到當前連續佔用段的第一個stale位置,那麼就需要在這個向後探測從儲存第一個stale entry的位置,探測結束後將傳入的stale位置放入entry,然後從這個向後探測過程中儲存的stale位置開始向後回收所在連續佔用段的stale entry。

上面兩種情況結束後,如果它們expungeStaleEntry的開始位置不是傳入的stale位置,那麼在這個expungeStaleEntry操作的結束位置(這個結束位置是一個空位)的下一位置開始向後嘗試回收一些stale entry,見cleanSomeSlots方法。

Java基礎(1)——ThreadLocal Java基礎(1)——ThreadLocal

cleanSomeSlots:

這個方法的作用是說從給定position(不包含該position)開始向後找stale entry,如果連續找了 log(n) 個位置都不是stale entry,那麼就結束,反之如果找到一個stale entry的話,那麼需要再重新向後看 log(len) 個位置。

注意,這個方法在set方法、replaceStaleEntry方法中的末尾都有呼叫,區別在於,set方法中呼叫cleanSomeSlots時設定初始初始向後看的位置數目為log(size),而replaceStaleEntry設定的是log(len)

Java基礎(1)——ThreadLocal

rehash:

先把所有stale entry清理後,判斷清理的數量是否達到了四分之一threshold,如果是,那麼說明當前thread local map只是因為stale entry太多的緣故導致的容量緊張,就只需執行清理動作,而不用將底層陣列容量翻倍並進行entry的遷移。

Java基礎(1)——ThreadLocal Java基礎(1)——ThreadLocal

1.2.4. remove方法

Java基礎(1)——ThreadLocal

1.3. ThreadLocal記憶體洩露

記憶體洩露(Memory Leak)指由於物件永遠無法被垃圾回收導致其佔用的Java虛擬機器記憶體無法被釋放。持續的記憶體洩露會導致Java虛擬機器可用記憶體主鍵減少,並最終可能導致Java記憶體溢位(OOM),直到Java虛擬機器當機。

偽記憶體洩露(Memory Psedo-leak)類似於記憶體洩露,偽記憶體洩露中物件佔用的記憶體在其不再被使用的相當長時間內仍然無法回收,甚至永遠無法回收。就是說,偽記憶體洩露的物件,理論上將是可以被回收的,但是這個等待回收的時間太長了。

談及ThreadLocal map的時候,我們談到了,當使用threadlocal任務不進行remove操作,並且任務又線上程池中執行時,有偽記憶體洩露的風險,這個風險被thread local map本身的實現抑制了,但是仍然存在,解決的辦法就是即使使用remove操作。

此外還有一種更加嚴重的記憶體洩露:每個執行緒例項持有thread local map,然後間接持有了執行緒特有物件(thread local的泛型型別),在Tomcat環境下,Web應用(打包成WAR)自身定義的類由類載入器WebAppClassLoader負責載入, JDK的標準類由類載入器StandardClassLoader負責載入。不管類每個類被哪個載入器載入,它都持有了載入它的載入器的引用,除了最特殊的那個。對於WebAppClassLoader來說,它還會持有它載入過的所有class的引用,這樣就導致,如果如果某個由WebAppClassLoader載入的型別(假設為ThreadLocalMemoryLeak)有個靜態的ThreadLocal欄位(threadLocalFoo),那麼該執行緒特有物件(foo物件)會持有該物件的Class物件(Foo.class),Foo型別會持有WebAppClassLoader,WebAppClassLoader又會持有ThreadLocalMemoryLeak的Class物件,這個Class物件又持有了threadLocalFoo這個靜態欄位,也就是說,foo物件這個執行緒特有物件,最終又反過來持有ThreadLocal例項了,這就導致,如果不及時remove的話,那麼thread local map中的Entry永遠不會stale,即使這個Web app不執行了,但是Tomcat容器還在執行的話,由於底層的這些執行緒不會被銷燬,因此thread local就產生了記憶體洩露,更進一步講Foo類的Class物件、ThreadLocalMemoryLeak的Class物件,以及它們的靜態變數所引用的所有物件,都無法被回收。當然Tomcat提供了一套記憶體洩露的檢查機制以及一定程度的自動規避,但我們不要依賴這個機制。為了解決這個問題,我們要及時remove。