換個資料結構,一不小心節約了 591 臺機器!

why技術發表於2022-03-28

你好呀,我是歪歪。

前段時間,我在 B 站上看到一個技術視訊,題目叫做《機票報價高併發場景下的一些解決方案》。

up 主是 Qunar技術大本營,也就是我們耳熟能詳的“去哪兒”。

視訊連結在這裡:

https://www.bilibili.com/video/BV1eX4y1F7zJ?p=2

當時其實我是被他的這個圖片給吸引到了(裡面的 12 qps 應該是 12k qps):

他介紹了兩個核心系統在經過一個“資料壓縮”的操作之後,分別節約了 204C 和 2160C 的伺服器資源。

共計就是 2364C 的伺服器資源。

如果按照一般標配的 4C8G 伺服器,好傢伙,這就是節約了 591 臺機器啊,你想想一年就節約了多大一筆開銷。

視訊中介紹了幾種資料壓縮的方案,其中方案之一就是用了高效能集合:

因為他們的系統設計中大量用到“本地快取”,而本地快取大多就是使用 HashMap 來幫忙。

所以他們把 HashMap 換成了效能更好的 IntObjectHashMap,這個類出自 Netty。

為什麼換了一個類之後,就節約了這麼多的資源呢?

換言之,IntObjectHashMap 效能更好的原因是什麼呢?

我也不知道,所以我去研究了一下。

拉原始碼

研究的第一步肯定是要找到對應的原始碼。

你可以去找個 Netty 依賴,然後找到裡面的 IntObjectHashMap。

我這邊本地剛好有我之前拉下來的 Netty 原始碼,只需要同步一下最新的程式碼就行了。

但是我在 4.1 分支裡面找這個類的時候並沒有找到,只看到了一個相關的 Benchmark 類:

點進去一看,確實沒有 IntObjectHashMap 這個類:

很納悶啊,我反正也沒搞懂為啥,但是我直接就是一個不糾結了,反正我現在只是想找到一個 IntObjectHashMap 類而已。

4.1 分支如果沒有的話,那麼就 4.0 上看看唄:

於是我切到了 4.0 分支裡面去找了一下,很順利就找到了對應的類和測試類:

能看到測試類,其實也是我喜歡把專案原始碼拉下來的原因。如果你是通過引入 Netty 的 Maven 依賴的方式找到對應類的,就看不到測試類了。

有時候配合著測試類看原始碼,事半功倍,一個看原始碼的小技巧,送給你。

而我要拉原始碼的最重要的一個目的其實是這個:

可以看到這個類的提交記錄,觀察到這個類的演變過程,這個是很重要的。

因為一次提交絕大部分情況下對應著一次 bug 修改或者效能優化,都是我們應該關注的地方。

比如,我們可以看到這個小哥針對 hashIndex 方法提交了三次:

在正式研究 IntObjectHashMap 原始碼之前,我們先看看只關注 hashIndex 這個區域性的方法。

首先,這個地方現在的程式碼是這樣的:

我知道這個方法是獲取 int 型別的 key 在 keys 這個陣列中的下標,支援 key 是負數的情況。

那麼為啥這一行程式碼就提交了三次呢?

我們先看第一次提交:

非常清晰,左邊是最原始的程式碼,如果 key 是負數的話,那麼返回的 index 就是負數,很明顯不符合邏輯。

所以有人提交了右邊的程式碼,在算出 hash 值為負數的時候,加上陣列的長度,最終得到一個正數。

很快,提交程式碼的哥們,發現了一個更好的寫法,進行了一次優化提交:

拿掉了小於零的判斷。不管 key%length 算出的值是正還是負,都將結果加上一個陣列的長度後再次對陣列的長度進行 % 執行。

這樣保證算出來的 index 一定是一個正數。

第三次提交的程式碼就很好理解了,代入變數:

所以,最終的程式碼就是這樣的:

return (key % keys.length + keys.length) % keys.length;

這樣的寫法,不比判斷小於零優雅的多且效能也好一點嗎?而且這也是一個常規的優化方案。

如果你看不到程式碼提交記錄,你就看不到這個方法的演變過程。我想表達的是:在程式碼提交記錄中能挖掘到非常多比原始碼更有價值的資訊。

又是一個小技巧,送給你。

IntObjectHashMap

接下來我們一起探索一下 IntObjectHashMap 的奧祕。

關於這個 Map,其實有兩個相關的類:

其中 IntObjectMap 是個介面。

它們不依賴除了 JDK 之外的任何東西,所以你搞懂原理之後,如果發現自己的業務場景下有合適的場景,完全可以把這兩個類貼上到自己的專案中去,一行程式碼都不用改,拿來就用。

在研究了官方的測試用例和程式碼提交記錄之後,我選擇先把這兩個類粘出來,自己寫個程式碼除錯一下,這樣的好處就是可以隨時修改其中的原始碼,以便我們進行研究。

在安排 IntObjectHashMap 原始碼之前,我們先關注一下它 javadoc 裡面的這幾句話:

第一句話就非常的關鍵,這裡解釋了 IntObjectHashMap 針對 key 衝突時的解決方案:

它對於 key 使用的是 open addressing 策略,也就是開放定址策略。

為什麼使用開放定址呢,而不是採用和 HashMap 一樣掛個連結串列呢?

這裡也回答了這個問題:To minimize the memory footprint,也就是為了最小化記憶體佔用。

怎麼就減少了記憶體的佔用呢?

這個問題下面看原始碼的時候會說,但是這裡提一句:你就想想如果用連結串列,是不是至少得有一個 next 指標,維護這個東西是不是又得佔用空間?

不多說了,說回開放定址。

開放定址是一種策略,該策略也分為很多種實現方案,比如:

  • 線性探測方法(Linear Probing)
  • 二次探測(Quadratic probing)
  • 雙重雜湊(Double hashing)

從上面劃線部分的最後一句話就可以知道,IntObjectHashMap 使用的就是 linear probing,即線性探測。

現在我們基本瞭解到 IntObjectHashMap 這個 map 針對 hash 衝突時使用的解決方案了。

接下來,我們搞個測試用例實操一把。程式碼很簡單,就一個初始化,一個 put 方法:

就這麼幾行程式碼,一眼望去和 HashMap 好像沒啥區別。但是仔細一想,還是發現了一點端倪。

如果我們用 HashMap 的話,初始化應該是這樣的:

HashMap<Integer,Object> hashMap = new HashMap<>(10);

你再看看 IntObjectHashMap 這個類定義是怎麼樣的?

只有一個 Object:

這個 Object 代表的是 map 裡面裝的 value。

那麼 key 是什麼,去哪兒了呢?是不是第一個疑問就產生了呢?

檢視 put 方法之後,我發現 key 竟然就是 int 型別的值:

也就是這個類已經限制住了 key 就是 int 型別的值,所以不能在初始化的時候指定 key 的泛型了。

這個類從命名上也已經明確說明這一點了:我是 IntObjectHashMap,key 是 int,value 是 Object 的 HashMap。

那麼我為什麼用了個“竟然”呢?

因為你看看 HashMap 的 key 是個啥玩意:

是個 Object 型別。

也就是說,如果我們想這樣初始化 HashMap 是不可以的:

ide 都會提醒你:老弟,別搞事啊,你這裡不能放基本型別,你得搞個包裝型別進來。

而我們平常編碼的時候能這樣把 int 型別放進去,是因為有“裝箱”的操作被隱藏起來了:

所以才會有一道上古時期的八股文問:HashMap 的 key 可以用基本型別嗎?

想也不用想,不可以!

key,從包裝型別變成了基本型別,這就是一個效能優化的點。因為眾所周知,基本型別比包裝型別佔用的空間更小。

接著,我們先從它的構造方法入手,主要關注我框起來的部分:

首先進來就是兩個 if 判斷,對引數合法性進行了校驗。

接著看標號為 ① 的地方,從方法名看是要做容量調整:

從程式碼和方法上的註釋可以看出,這裡是想把容量調整為一個奇數,比如我給進來 8 ,它會給我調整為 9:

至於容量為什麼不能是偶數,從註釋上給了一個解釋:

Even capacities can break probing.

意思是容量為偶數的時候會破壞 probing,即我們前面提到的線性探測。

額...

我並沒有考慮明白為什麼偶數的容量會破壞線性探測,但是這不重要,先存疑,接著往下梳理主要流程。

從標號為 ② 的地方可以看出這是在做資料初始化的操作。前面我們得到了 capacity 為 9,這裡就是初始兩個陣列,分別是 key[] 和 values[],且這兩個陣列的容量是一樣的,都是 9:

兩個陣列在構造方法中完成初始化後,是這樣的:

構造方法我們就主要關注容量的變化和 key[]、values[] 這兩個陣列。

構造方法給你鋪墊好了,接著我們再看 put 方法,就會比較絲滑了:

put 方法的程式碼也沒幾行,分析起來非常的清晰。

首先是標號為 ① 的地方,hashIndex 方法,就是獲取本次 put 的 key 對應在 key[] 陣列中的下標。

這個方法文章開始的時候已經分析過了,我們甚至知道這個方法的演變過程,不再多說。

然後就是進入一個 for(;;) 迴圈。

先看標號為 ② 的地方,你注意看,這個時候的判斷條件是 value[index] == null,是判斷算出來的 index 對應的 value[] 陣列對應的下標是否有值。

前面我專門強調了一句,還給你畫了一個圖:

key[] 和 values[] 這兩個陣列的容量是一樣的。

為什麼不先判斷該 index 在 key[] 中是否存在呢?

可以倒是可以,但是你想想如果 value[] 對應下標中的值是 null 的話,那麼說明這個位置上並沒有維護過任何東西。key 和 value 的位置是一一對應的,所以根本就不用去關心 key 是否存在。

如果 value[index] == null 為 true,那麼說明這個 key 之前沒有被維護過,直接把對應的值維護上,且 key[] 和 values[] 陣列需要分別維護。

假設以我的演示程式碼為例,第四次迴圈結束後是這樣的:

維護完成後,判斷一下當前的容量是否需要觸發擴容:

growSize 的程式碼是這樣的:

在這個方法裡面,我們可以看到 IntObjectHashMap 的擴容機制是一次擴大 2 倍。

額外說一句:這個地方就有點 low 了,原始碼裡面擴大二倍肯定得上位運算,用 length << 1 才對味兒嘛。

但是擴容之前需要滿足一個條件:size > maxSize

size,我們知道是表示當前 map 裡面放了幾個 value 。

那麼 maxSize 是啥玩意呢?

這個值在建構函式裡面進行的初始化。比如在我的示例程式碼中 maxSize 就等於 4:

也就是說,如果我再插入一個資料,它就要擴容了,比如我插入了第五個元素後,陣列的長度就變成了 19:

前面我們討論的是 value[index] == null 為 true 的情況。那麼如果是 false 呢?

就來到了標號為 ③ 的地方。

判斷 key[] 陣列 index 下標處的值是否是當前的這個 key。

如果是,說明要覆蓋。先把原來該位置上的值拿出來,然後直接做一個覆蓋的操作,並返回原值,這個邏輯很簡單。

但是,如果不是這個 key 呢?

說明什麼情況?

是不是說明這個 key 想要放的 index 位置已經被其他的 key 先給佔領了?

這個情況是不是就是出現了 hash 衝突?

出現了 hash 衝突怎麼辦?

那麼就來到了標號為 ③ 的地方,看這個地方的註釋:

Conflict, keep probing ...
衝突,繼續探測 ...

繼續探測就是看當前發生衝突的 index 的下一個位置是啥。

如果讓我來寫,很簡單,下一個位置嘛,我閉著眼睛用腳都能敲出來,就是 index+1 嘛。

但是我們看看原始碼是怎麼寫的:

確實看到了 index+1,但是還有一個先決條件,即 index != values.length -1

如果上述表示式成立,很簡單,採用 index+1。

如果上面的表示式不成立,說明當前的 index 是 values[] 陣列的最後一個位置,那麼就返回 0,也就是返回陣列的第一個下標。

要觸發這個場景,就是要搞一個 hash 衝突的場景。我寫個程式碼給你演示一下:

上面的程式碼只有當算出來的下標為 8 的時候才會往 IntObjectHashMap 裡面放東西,這樣在下標為 8 的位置就出現了 hash 衝突。

比如 100 之內,下標為 8 的數是這些:

第一次迴圈之後是這樣的:

而第二次迴圈的時候,key 是 17,它會發現下標為 8 的地方已經被佔了:

所以,走到了這個判斷中:

返回 index=0,於是它落在了這個地方:

看起來就是一個環,對不對?

是的,它就是一個環。

但是你再細細的看這個判斷:

每次計算完 index 後,還要判斷是否等於本次迴圈的 startIndex。如果相等,說明跑了一圈了,還沒找到空位子,那麼就丟擲 “Unable to insert” 異常。

有的朋友馬上就跳出來了:不對啊,不是會在用了一半空間以後,以 2 倍擴容嗎?應該早就在容量滿之前就擴容了才對呀?

這位朋友,你很機智啊,你的疑問和我第一次看到這個地方的疑問是一樣的,我們都是心思縝密的好孩子。

但是注意看,在丟擲異常的地方,原始碼裡面給了一個註釋:

Can only happen if the map was full at MAX_ARRAY_SIZE and couldn't grow.
這種情況只有 Map 已經滿了,且無法繼續擴容時才會發生。

擴容,那肯定也是有一個上限才對,再看看擴容的時候的原始碼:

最大容量是 Integer.MAX_VALUE - 8,說明是有上限的。

但是,等等,Integer.MAX_VALUE 我懂,減 8 是什麼情況?

誒,反正我是知道的,但是我們就是不說,不是本文重點。你要有興趣,自己去探索,我就給你截個圖完事:

如果我想要驗證一下 “Unable to insert” 怎麼辦呢?

這還不簡單嗎?原始碼都在我手上呢。

兩個方案,一個是修改 growSize() 方法的原始碼,把最長的長度限制修改為指定值,比如 8。

第二個方案是直接嚴禁擴容,把這行程式碼給它註釋了:

然後把測試用例跑起來:

你會發現在插入第 10 個值的時候,丟擲了 “Unable to insert” 異常。

第 10 個值,89,就是這樣似兒的,轉一圈,又走回了 startIndex:

滿足這個條件,所以丟擲異常:

(index = probeNext(index)) == startIndex

到這裡,put 方法就講完了。你也瞭解到了它的資料結構,也瞭解到了它的基本執行原理。

那你還記得我寫這篇文章要追尋的問題是什麼嗎?

IntObjectHashMap 效能更好的原因是什麼呢?

前面提到了一個點是 key 可以使用原生的 int 型別而不用包裝的 Integer 型別。

現在我要揭示第二個點了:value 沒有一些亂七八糟的東西,value 就是一個純粹的 value。你放進來是什麼,就是什麼。

你想想 HashMap 的結構,它裡面有個 Node,封裝了 Hash、key、value、next 這四個屬性:

這部分東西也是 IntObjectHashMap 節約出來的,而這部分節約出來的,才是佔大頭的地方。

你不要看不起著一點點記憶體佔用。在一個巨大的基數面前,任何一點小小的優化,都能被放大無數倍。

不知道你還記不記得《深入理解Java虛擬機器》一書裡面的這個案例:

不恰當的資料結構導致記憶體在佔用過大。這個問題,就完全可以使用 Netty 的 LongObjectHashMap 資料結構來解決,只需要換個類,就能節省非常多的資源。

道理,是同樣的道理。

額外一個點

最後,我再給你額外補充一個我看原始碼時的意外收穫。

Deletions implement compaction, so cost of remove can approach O(N) for full maps, which makes a small loadFactor recommended.
刪除實現了 compaction,所以對於一個滿了的 map 來說,刪除的成本可能接近 O(N) ,所以我們推薦使用小一點的 loadFactor。

裡面有兩個單詞,compaction 和 loadFactor。

先說 loadFactor 屬性,是在構造方法裡面初始化的:

為什麼 loadFactor 必須是一個 (0,1] 之間的數呢?

首先要看一下 loadFactor 是在什麼時候用的:

只會在計算 maxSize 的時候用到,是用當前 capacity 乘以這個係數。

如果這個係數是大於 1 的,那麼最終算出來的值,也就是 maxSize 會大於 capacity。

假設我們的 loadFactor 設定為 1.5,capacity 設定為 21,那麼計算出來的 maxSize 就是 31,都已經超過 capacity 了,沒啥意義。

總之:loadFactor 是用來計算 maxSize 的,而前面講了 maxSize 是用來控制擴容條件的。也就是說 loadFactor 越小,那麼 maxSize 也越小,就越容易觸發擴容。反之,loadFactor 越大,越不容易擴容。loadFactor 的預設值是 0.5。

接下來我來解釋前面註釋中有個單詞 compaction,翻譯過來的話叫做這玩意:

可以理解為就是一種“壓縮”吧,但是“刪除實現了壓縮”這句話就很抽象。

不著急,我給你講。

我們先看看刪除方法:

刪除方法的邏輯有點複雜,如果要靠我的描述給你說清楚的話有點費解。

所以,我決定只給你看結果,你拿著結果去反推原始碼吧。

首先,前面的註釋中說了:哥們,我推薦你使用小一點的 loadFactor。

那麼我就偏不聽,直接給你把 loadFactor 拉滿到 1。

也就是說當這個 map 滿了之後,再往裡面放東西才會觸發擴容。

比如,我這樣去初始化:

new IntObjectHashMap<>(8,1);

是不是說,當前這個 map 初始容量是可以放 9 個元素,當你放第 10 個元素的時候才會觸發擴容的操作。

誒,巧了,我就偏偏只想放 9 個元素,我不去觸發擴容。且我這 9 個元素都是存在 hash 衝突的。

程式碼如下:

這些 value 本來都應該在下標為 8 的位置放下,但是經過線性探測之後,map 裡面的陣列應該是這個情況:

此時我們移除 8 這個 key,正常來說應該是這樣的:

但是實際上卻是這樣的:

會把前面因為 hash 衝突導致發生了位移的 value 全部往回移動。

這個過程,我理解就是註釋裡面提到的“compaction”。

上面程式的實際輸出是這樣的:

符合我前面畫的圖片。

但是,我要說明的是,我的程式碼進行了微調:

如果不做任何修改,輸出應該是這樣的:

key=8 並不在最後一個,因為在這個過程裡面涉及到 rehash 的操作,如果在解釋 “compaction” 的時候加上 reHash ,就複雜了,會影響你對於 “compaction” 的理解。

另外在 removeAt 方法的註釋裡面提到了這個東西:

這個演算法,其實就是我前面解釋的 “compaction”。

我全域性搜尋關鍵字,發現在 IdentityHashMap 和 ThreadLocal 裡面都提到了:

但是,你注意這個但是啊。

在 ThreadLocal 裡面,用的是“unlike”。

ThreadLocal 針對 hash 衝突也用的是線性探測,但是細節處還是有點不一樣。

不細說了,有興趣的同學自己去探索一下,我只是想表達這部分可以對比學習。

這一部分的標題叫做“額外一個點”。因為我本來計劃中是沒有這部分內容的,但是我在翻提交記錄的時候看到了這個:

https://github.com/netty/netty/issues/2659

這個 issues 裡面有很多討論,基於這次討論,相當於對 IntObjectHashMap 進行了一次很大的改造。

比如從這次提交記錄我可以知道,在之前 IntObjectHashMap 針對 hash 衝突用的是“雙重雜湊(Double hashing)”策略,之後才改成線性探測的。

包括使用較小的 loadFactor 這個建議、removeAt 裡面採用的演算法,都是基於這次改造出來的:

引用這個 issues 裡面的一個對話:

這個哥們說:I've got carried away,我對這段程式碼進行了重大改進。

在我看來,這都不算是“重大改進”了,這已經算是推翻重寫了。

另外,這個“I've got carried away”啥意思?

英語教學,雖遲但到:

這個短語要記住,託福口語考試的時候可能會考。

Netty 4.1

文章開始的地方,我說在 Netty 4.1 裡面,我沒有找到 IntObjectHashMap 這個東西。

其實我是騙你的,我找到了,只是藏的有點深。

其實我這篇文章只寫了 int,但是其實基本型別都可以基於這個思想去改造,且它們的程式碼都應該是大同小異的。

所以在 4.1 裡面用了一個騷操作,基於 groovy 封裝了一次:

要編譯這個模板之後:

才會在 target 目錄裡面看到我們想找的東西:

但是,你仔細看編譯出來的 IntObjectHashMap,又會發現一點不一樣的地方。

比如構造方法裡面調整 capacity 的方法變成了這樣:

從方法名稱我們也知道這裡是找一個當前 value 的最近的 2 的倍數。

等等,2 的倍數,不是一個偶數嗎?

在 4.0 分支的程式碼裡面,調整容量還非得要個奇數:

還記得我前面提到的一個問題嗎:我並沒有考慮明白為什麼偶數的容量會破壞線性探測?

但是從這裡有可以看出其實偶數的容量也是可以的嘛。

這就把我給搞懵了。

要是在 4.0 分支的程式碼中,adjustCapacity 方法上沒有這一行特意寫下的註釋:

Adjusts the given capacity value to ensure that it's odd. Even capacities can break probing.

我會毫不猶豫的覺得這個地方奇偶都可以。但是他刻意強調了要“奇數”,就讓我有點拿不住了。

算了,學不動了,存疑存疑!

文章首發於公眾號[why技術],歡迎大家關注,第一時間看到最新文章。

相關文章