這玩意比ThreadLocal叼多了,嚇得why哥趕緊分享出來。


這是why哥的第 70 篇原創文章

從Dubbo的一次提交開始

故事得從前段時間翻閱 Dubbo 源碼時,看到的一段代碼講起。

這段代碼就是這個:

org.apache.dubbo.rpc.RpcContext

使用 InternalThreadLocal 提升性能。

相信作為一個程序猿,都會被 improve performance(提升性能)這樣的字眼抓住眼球。

心里開始癢癢的,必須要一探究竟。

剛看到這段代碼的時候,我就想:既然他是要提升性能,那說明之前的東西表現的不太好。

那之前的東西是什么?

經過長時間的推理、縝密的分析,我大膽的猜測到之前的東西就是:ThreadLocal。

來,帶大家看一下:

果不其然,我真是太厲害了。

2018 年 5 月 15 日的提交:New threadLocal provides more performance. (#1745)

可以看到這次提交的后面跟了一個數字:1745。它對應一個 pr,鏈接如下:

https://github.com/apache/dubbo/pull/1745

在這個 pr 里面還是有很多有趣的東西的,出場人物一個比一個騷,文章的最后帶大家看看。

能干啥用?

在說 ThreadLocal 和 InternalThreadLocal 之前,還是先講講它們是干啥用的吧。

InternalThreadLocal 是 ThreadLocal 的增強版,所以他們的用途都是一樣的,一言蔽之就是:傳遞信息。

你想象你有一個場景,調用鏈路非常的長。當你在其中某個環節中查詢到了一個數據后,最后的一個節點需要使用一下。

這個時候你怎么辦?你是在每個接口的入參中都加上這個參數,傳遞進去,然后只有最后一個節點用嗎?

可以實現,但是不太優雅。

你再想想一個場景,你有一個和業務沒有一毛錢關系的參數,比如 traceId ,純粹是為了做日志追蹤用。

你加一個和業務無關的參數一路透傳干啥玩意?

通常我們的做法是放在 ThreadLocal 里面,作為一個全局參數,在當前線程中的任何一個地方都可以直接讀取。當然,如果你有修改需求也是可以的,視需求而定。

絕大部分的情況下,ThreadLocal 是適用於讀多寫少的場景中。

舉三個框架源碼中的例子,大家品一品。

第一個例子:Spring 的事務。

在我的早期作品《事務沒回滾?來,我們從現象到原理一起分析一波》里面,我曾經寫過:

Spring 的事務是基於 AOP 實現的,AOP 是基於動態代理實現的。所以 @Transactional 注解如果想要生效,那么其調用方,需要是被 Spring 動態代理后的類。

因此如果在同一個類里面,使用 this 調用被 @Transactional 注解修飾的方法時,是不會生效的。

為什么?

因為 this 對象是未經動態代理后的對象。

那么我們怎么獲取動態代理后的對象呢?

其中的一個方法就是通過 AopContext 來獲取。

其中第三步是這樣獲取的:AopContext.currentProxy();

然后我還非常高冷的(咦,想想就覺得羞恥)說了句:對於 AopContext 我多說幾句。

看一下 AopContext 里面的 ThreadLocal:

調用 currentProxy 方法時,就是從 ThreadLocal 里面獲取當前類的代理類。

那他是怎么放進去的呢?

我高冷的第二句是這樣說的:

對應的代碼位置如下:

可以看到,經過一個 if 判斷,如果為 true ,則調用 AopContext.setCurrentProxy 方法,把代理對象放到 AopContext 里面去。

而這個 if 判斷的配置默認是 false,所以需要通過剛剛說的配置修改為 true,這樣 AopContext 才會生效。

附送一個知識點給你,不客氣。

第二個例子:mybatis 的分頁插件,PageHelper。

使用方法非常簡單,從官網上截個圖:

這里它為什么說:緊跟着的第一個 select 方法會被分頁。

或者說:什么情況下會導致不安全的分頁?

來,就當是一個面試題,並且我給你提示了:從 ThreadLocal 的角度去回答。

其實就是因為 PageHelper 方法使用了靜態的 ThreadLocal 參數,分頁參數和線程是綁定的:

如果我們寫出下面這樣的代碼,就是不安全的用法:

這種情況下由於 param1 存在 null 的情況,就會導致 PageHelper 生產了一個分頁參數,但是沒有被消費,這個參數就會一直保留在這個線程上,也就是放在線程的 ThreadLocal 里面。

當這個線程再次被使用時,就可能導致不該分頁的方法去消費這個分頁參數,這就產生了莫名其妙的分頁。

上面這個代碼,應該寫成下面這個樣子:

這種寫法,就能保證安全。

核心思想就一句話:只要你可以保證在 PageHelper 方法調用后緊跟 MyBatis 查詢方法,這就是安全的。

因為 PageHelper 在 finally 代碼段中自動清除了 ThreadLocal 存儲的對象。

就算代碼在進入 Executor 前發生異常,導致線程不可用的情況,比如常見的接口方法名稱和 XML 中的不匹配,導致找不到 MappedStatement ,由於自動清除,也不會導致 ThreadLocal 參數被錯誤的使用。

所以,我看有的人為了保險起見這樣去寫:

怎么說呢,這個代碼....

第三個例子:Dubbo 的 RpcContext。

RpcContext 這個對象里面維護了兩個 InternalThreadLocal,分別是存放 local 和 server 的上下文。

也就是我們說的增強版的 ThreadLocal:

作為一個 Dubbo 應用,它既可能是發起請求的消費者,也可能是接收請求的提供者。

每一次發起或者收到 RPC 調用的時候,上下文信息都會發生變化。

比如說:A 調用 B,B 調用 C。這個時候 B 既是消費者也是提供者。

那么當 A 調用 B,B 還是沒調用 C 之前,RpcContext 里面保存的是 A 調用 B 的上下文信息。

當 B 開始調用 C 了,說明 A 到 B 之前的調用已經完成了,那么之前的上下文信息就應該清除掉。

這時 RpcContext 里面保存的應該是 B 調用 C 的上下文信息。否則會出現上下文污染的情況。

而這個上下文信息里面的一部分就是通過InternalThreadLocal存放和傳遞的,是 ContextFilter 這個攔截器維護的。

ThreadLocal 在 Dubbo 里面的一個應用就是這樣。

當然,還有很多很多其他的開源框架都使用了 ThreadLocal 。

可以說使用頻率非常的高。

什么?你說你用的少?

那可不咋的,人家都給你封裝好了,你當個黑盒,開箱即用。

其實你用了,只是你不知道而已。

強在哪里?

前面說了 ThreadLocal的幾個應用場景,那么這個 InternalThreadLocal 到底比 ThreadLocal 強在什么地方呢?

先說結論。

答案其實就寫在類的 javadoc 上:

InternalThreadLocal 是 ThreadLocal 的一個變種,當配合 InternalThread 使用時,具有比普通 Thread 更高的訪問性能。

InternalThread 的內部使用的是數組,通過下標定位,非常的快。如果遇得擴容,直接數組擴大一倍,完事。

而 ThreadLocal 的內部使用的是 hashCode 去獲取值,多了一步計算的過程,而且用 hashCode 必然會遇到 hash 沖突的場景,ThreadLocal 還得去解決 hash 沖突,如果遇到擴容,擴容之后還得 rehash ,這可不得慢嗎?

數據結構都不一樣了,這其實就是這兩個類的本質區別,也是 InternalThread 的性能在 Dubbo 的這個場景中比 ThreadLocal 好的根本原因。

而 InternalThread 這個設計思想是從 Netty 的 FastThreadLocal 中學來的。

本文主要聊聊 InternalThread ,但是我希望的是大家能學到這個類的思想,而不是用法。

首先,我們先搞個測試類:

public class InternalThreadLocalTest {

    private static InternalThreadLocal<Integer> internalThreadLocal_0 = new InternalThreadLocal<>();

    public static void main(String[] args) {
        new InternalThread(() -> {
            for (int i = 0; i < 5; i++) {
                internalThreadLocal_0.set(i);
                Integer value = internalThreadLocal_0.get();
                System.out.println(Thread.currentThread().getName()+":"+value);
            }
        }, "internalThread_have_set").start();

        new InternalThread(() -> {
            for (int i = 0; i < 5; i++) {
                Integer value = internalThreadLocal_0.get();
                System.out.println(Thread.currentThread().getName()+":"+value);
            }
        }, "internalThread_no_set").start();
    }
}

上面代碼的運行結果是這樣的:

由於 internalThread_no_set 這個線程沒有調用 InternalThreadLocal 類的 set 方法,所以調用 get 方法輸出為 null。

里面主要用到了 set、get 這一對方法。

下面借助 set 方法,帶大家看看內部原理(先說一下,為了方便截圖,我有可能會調整一下源碼順序):

首先是判斷了傳進來的 value 是否是 null 或者是 UNSET,如果是則調用 remove 方法。

null 是好理解的。這個 UNSET 是個什么鬼?

根據 UNSET 能很容易的找到這個地方:

原來是 InternalThreadLocalMap 初始化的時候會填充 UNSET 對象。

所以,如果 set 的對象是 UNSET,我們可以認為是需要把當前位置上的值替換為 UNSET,也就是 remove 掉。

而且,我們還看到了兩個關鍵的信息:

1.InternalThreadLocalMap 雖然名字叫做 Map ,但是它掛羊頭賣狗肉,其實里面維護的是一個數組。

2.數組初始化大小是 32。

接着我們回去看 else 分支的邏輯:

調用的是 InternalThreadLocalMap 對象的 get 方法。

而這個方法里面的兩個 get 就有趣了。

能走到 fastGet 方法的,說明當前線程是 InternalThread 類,直接可以獲取到類里面的 InternalThreadLocalMap。

如果走到 slowGet 了,則回退到原生的 ThreadLocal ,只是在原生的里面,我還是放的 InternalThreadLocalMap:

所以,其實線程上綁定的數據都是放到 InternalThreadLocalMap 里面的,不管你操作什么 ThreadLocal,實際上都是操作的 InternalThreadLocalMap。

那問題來了,你覺得一個叫做 fastGet ,一個叫做 slowGet。這個快慢,指的是 get 什么東西的快慢?

對咯,就是獲取 InternalThreadLocalMap。

InternalThreadLocalMap 在 InternalThread 里面是一個變量維護的,可以直接通過 InternalThread.threadLocalMap() 獲得:

標號為 ① 的地方是獲取,標號為 ② 的地方是設置。

都是一步到位,操作起來非常的方便。

這是 fastGet。

而 slowGet 是從 ThreadLocal 中獲取:

這里的 get ,就是原生 ThreadLocal 的 get 方法,一眼望去,就復雜多了:

標號為 ① 的地方,首先計算 hash 值,然后拿着 hash 值去數組里面取數據。如果取出來的數據不是我們想要的數據,則到標號為 ② 的邏輯里面去。

那么我問你,除了這個位置上的值真的為 null 外,還有什么原因會導致我拿着計算出來的 hash 值去數組里面取數據取不到?

就是看你熟不熟悉 ThreadLocal 對 hash 沖突的處理方式了。

那么這個問題稍微的升級一下就是:你知道哪些 hash 沖突的解決方案呢?

1.開放定址法。

2.鏈式地址法。

3.再哈希法。

4.建立公共溢出區。

我們非常熟悉的 HashMap 就是采用的鏈式地址法解決 hash 沖突。

而 ThreadLocal 用的就是開放定址法中的線性探測。

所謂線性探測就是,如果某個位置的值已經存在了,那么就在原來的值上往后加一個單位,直至不發生哈希沖突,就像這樣的:

上面的動圖就是需要在一個長度為 7 的數組里面,再放一個進過 hash 計算后為下標為 2 的數據,但是該位置上有值,也就是發生了 hash 沖突。

於是解決 hash 沖突的方法就是一次次的往后移,直到找到沒有沖突的位置。

所以,當我們取值的時候如果發生了 hash 沖突也需要往后查詢,這就是上面標號為 ③ 的 while 循環代碼的其中一個目的。

當然還有其他目的,就隱藏在 440 行的 expungeStaleEntry 方法里面。不是本文重點,就不多說了。

但是如果你不知道這個方法,你一定要去查閱一下相關的資料,有可能會在一定程度上改變你印象中的:用 ThreadLocal 會導致內存泄漏的風險。

至少,你可以知道 JDK 為了避免內存泄漏的問題,是做了自己的最大努力的。

好了,不扯遠了,說回來。

從上面我們知道了,從 ThreadLocal 中獲取 InternalThreadLocalMap 會經歷如下步驟:

1.計算 hash 值。

2.判斷通過 hash 值是否能直接獲取到目標對象。

3.如果沒有獲取到目標對象則往后遍歷,直至獲取成功或者循環結束。

比從 InternalThread 里面獲取 InternalThreadLocalMap 復雜多了。

現在你知道了 fastGet/slowGet 這個兩個方法中的快慢,指的是從兩個不同的 ThreadLocal 中獲取 InternalThreadLocalMap 的操作的快慢。而快慢的根本原因是數據結構的差異。

好,現在我們獲取到 InternalThreadLocalMap 了,接着看 set 方法:

標號為 ① 的地方就是往 InternalThreadLocalMap 這個數組中存放我們傳進來的 value。

存的時候分為兩種情況。

標號為 ② 的地方是數組容量還夠,能放進去,那么可以直接設置。

標號為 ③ 的地方是數組容量不夠用,需要擴容了。

這里拋出兩個問題:

擴容是怎么擴的?

數組下標是怎么來的?

先看問題一,怎么擴容的?

看源碼:

怎么樣,看到的第一眼你想到了什么?

大聲的說出來,是不是想到了 HashMap 里面的一段源碼?

和 HashMap 里面的位運算異曲同工。

在 InternalThreadLocalMap 中擴容就是變成原來大小的 2 倍。從 32 到 64,從 64 到 128 這樣。

擴容完成之后把原數組里面的值拷貝到新的數組里面去。

然后剩下的部分用 UNSET 填充。最后把我們傳進來的 value 放到指定位置上。

再看看問題二,數組下標怎么來的?也就是這個 index:

從上往下看,可以看到最后,這個 index 本質上是一個 AtomicInteger 。

主要看一下標號為 ① 的地方。

index 每次都是加一,對應的是 InternalThreadLocalMap 里的數組下標。

第一眼看到的時候,里面的 if 判斷 index<0 我是可以理解的,防止溢出嘛。

但是下面在拋出異常之前,還調用了 decrementAndGet 方法,又把值減回去了。

你說這是為什么?

開始我沒想明白。但是有天晚上睡覺之前,電光火石一瞬間我想明白了。

如果不把值減回去,加一的代碼還在不斷的被調用,那么這個 index 理論上講是有可能又被加到正數的,這一點你能明白吧?

為什么我說理論上呢?

int 的取值范圍是 [-2147483648 到 2147483647]。

如果 int 從 0 增加,一直溢出到 -2147483648,再從 -2147483648 加到 0,中間有 4294967295 個數字。

一個數字對應數組的一個下標,就算里面放的是一個字節的 boolean 型,那么大概也就是 4T 的內存吧。

所以,我覺得這是理論上的。

到這一步,我們已經完成了從 Thread 里面取出 InternalThreadLocalMap ,並且往里面放數據的操作。

最后,InternalThreadLocal 的 set 方法只剩下最后一行代碼,我們還沒說:

就是 setIndexedVariable 方法返回 true 后,會執行 addToVariablesToRemove 方法。

這個方法其實就是在數組的第一個位置維護當前線程里面的所有的 InternalThreadLocalMap 。

這里的關鍵點其實就是這個變量:

static final,能保證 VARIABLE_TO_REMOVE_INDEX 恆等於 0,也就是數組的第一個位置。

用示例程序,給大家演示一下,它第一個位置放的東西:

在第 21 行打上斷點,然后看一下執行完 addToVariablesToRemove 方法后,InternalThreadLocalMap 數組的情況:

誠不欺你,第 0 個位置上放的是所有的 InternalThreadLocal 的集合。

所以,我們看一下它的 size 方法,就能明白這里為什么要減一了:

那么在第一個位置維護線程里面所有的 InternalThreadLocal 集合的用處是什么?

看看它的 removeAll 方法:

直接從數組中取出第 0 個位置的數據,然后循環干掉它就行。

set 方法就分析到這里啦,算是保姆級的一行行手把手教學了吧。

借助這個方法,也帶大家看了內部結構。

點到為止。get 方法很簡單的,大家記得自己去看一下哦。

我們再看一下這次 pr 提交的東西:

我們看看這四個線程池有什么變化:

就是換了工廠類。

換工廠類的目的是什么呢?

newThread 的時候,new 的是 InternalThread 線程。

好一個偷天換日。

前面我們說了,要用改造版的 ThreadLocal ,必須要配合 InternalThread 線程使用,否則就會退化為原生的 ThreadLocal 。

其實, Dubbo 這次提交,改造的東西並不多。關鍵的、核心的代碼都是從 Netty 那邊 copy 過來的。

我這就是一個引子,大家可以再去看看 Netty 的 FastThreadLocal 類。

關於這次 pr 提交

接下來又是 get 奇怪知識點的時刻了。

前面說了,這個 pr 里面出場人物一個比一個“騷”,這一節我帶大家看一下,是怎么個“騷”法。

https://github.com/apache/dubbo/pull/1745·

首先是 pr 的提交者,carryxyh 同學的代碼在 2018 年 5 月 15 日的時候被 merge 了:

正常來說,carryxyh 同學對於開源社區的一次貢獻就算是完美結束了,簡歷上又可以濃墨重彩的寫上一小筆。

但是 15 天之后發生的事情,可能是他做夢也想不到的。

那一天,一個叫做 normanmaurer 的哥們在這個 pr 下面說了一句話:

先不管他說的啥。

你知道他是誰嗎?他在我之前的文章中其實也出現過的。

他就是 Netty 的爸爸。

他是這樣說的:

他的意思就是說:

哥們,你這個東西我怎么覺得是從 Netty 那邊弄過來的呢?本着開源的精神,你直接弄過來是沒有問題的,但是你至少得按照規矩辦事吧?得遵循 AL2 協議來。而且我甚至看到你在你的 pr 里面提到了 Netty 。

至於這個 AL2 到底是什么,我是沒有看明白的。

但是不重要,我就把它理解為一個給開源社區貢獻代碼時需要遵守的一個協議吧。

carryxyh 同學看到 Netty 的爸爸找他了,很快就回復了兩條消息:

carryxyh同學說道:

老哥,我在 javadoc 里面提到了,我的靈感來源就是 Netty 的 FastThreadLocal 類。我寫這個的目的就是告訴所有看到這個類的朋友,這里的大部分代碼來自 Netty。

那我除了在 javadoc 里面寫上來源是 Netty 外,還需要做什么嗎?還有你說的 AL2 是什么東西,你能不能告訴我?

我一定會盡快修復的。

這么一來一回,我大概明白這兩個人在說什么了。

Netty 的爸爸說你用了我的代碼,這完全沒有問題,但是你得遵循一個協議哦。

carryxyh 同學說,我已經在 javadoc 里說了我這部分代碼就是來自 Netty 的,我真不知道還該做什么,請你告訴我。

Netty 的爸爸回復了一個鏈接:

他說:你就看着這個鏈接,按照它整就行。

他發的這個鏈接我看了,怎么說呢,非常的哇塞,純英文,內容非常的多。先不關注是啥吧,反正 carryxyh 同學肯定會認真閱讀的。

在 carryxyh 同學沒有回復之前,一個叫做 justinmclean 的哥們出來對 Netty 的爸爸說話了:


他說:實際上,ALv2 許可證已經不適用了,有新的政策出來了,以新的通知和許可證文件為准。

這個哥們既然這樣輕描淡寫的說有新政策了。我潛意識就覺得他不是一個一般人,於是我查了一下:

主席、30年+、PMC、導師......

還愣着干嘛,開始端茶吧。

大佬都出來了,接下來的對話大概也就是圍繞着怎么才是一次符合開源標准的提交。

主席說,到底需不需要聲明版權,得看代碼的改造點多不多。

Netty 的爸爸說:據我所知,除了包名和類名不一樣外,其他的基本沒有變化。

最終 carryxyh 同學說把 Netty 的 FastThreadLocal 的文件頭弄過來,是不是就完事了,

主席說:沒毛病,我就是這樣想的。

所以,我們現在在 Dubbo 的 InternalThreadLocal 文件的最開始,還可以看到這樣的 Netty 的說明:

這個東西,就是這樣來的,不是隨便寫的,是有講究。

好了,這應該是我所有文章中出現過的第 9 個用不上的傻吊知識點了吧。送給你,不必客氣。

好了,這次的文章就到這里啦。


免責聲明!

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



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