這是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 個用不上的傻吊知識點了吧。送給你,不必客氣。
好了,這次的文章就到這里啦。