一文帶你瞭解併發 HashMap 的一種簡單實現

##前言

java.util.concurrent.ConcurrentHashMap,
java.util.concurrent.ConcurrentHashMap 雖然效果不錯,但其實現相當複雜。在開發一款工具的過程中,由於無法使用 java.util.concurrent.ConcurrentHashMap (工具的目標之一就是跟蹤 ConcurrentHashMap 內部實現) 因此筆者決定自己實現一個 “乞丐版” computeIfAbsent 方法。這樣一個簡單可擴展的 ConcurrentHashMap 就唾手可得。

##1 算法

算法基於 Jeff Preshing[1] 與 Cliff Click[2] 博士的工作,使用了帶線性探測的開放尋址。開放尋址即 key-value 存儲在一個數組中。指向 key-value 的 index 等於 hashcode 對數組大小取模。線性探測表示,如果數組元素已佔用則使用下一個數組元素。循環往復直到發現一個空的 slot。下面的示例中,在兩個已佔用的 slot 後面插入新元素:

爲了保證算法的併發性,需要使用 compareAndSet 在空 slot 中填入新元素。這樣可以保證“檢查是否爲 null 與填入新元素"是原子(atomic)操作。如果 compareAndSet 順利執行就成功地插入了一個新元素,返回該元素;如果執行失敗,則需要檢查是否其他線程使用了相同的 key 進行插入操作。接着尋找下一個空 slot 繼續嘗試。算法源代碼如下:

private static final VarHandle ARRAY = 

1 工作原理上述方案之所以可以奏效,當線程讀到的數組位置已填充元素則元素不變同時 key 也不變。唯一需要確認是,當前讀取元素爲 null 時另一個線程是否在這裏進行了修改。上面通過使用 compareAndSet 確保了這種意外情況不會發生。由於算法本身不會移除 key 且 key 本身不可變 (immutable) 因此算法成立。

##2 改變大小

在調整大小過程中,需要確保新增元素不丟失即不更新當前數組。具體的做法,會把數組中每個空元素設爲特殊值 MOVED_NULL_KEY。看到這個值,線程知道當前正在進行大小調整。待調整結束再插入元素。

HashMap 調整大小步驟如下:

  1. 把數組中所有值爲 null 的元素設爲 MOVED_NULL_KEY;
  2. 創建新數組;
  3. 拷貝所有當前數組的值到新數組;
  4. 設置 currentArray 爲新數組。

實現代碼:

private static final Object MOVED_NULL_KEY = new Object();  

3 基準測試

不同 HashMap 的實現取決於具體負載以及哈希函數,測試結果僅供參考。下面使用 JMH 用隨機數 key 調用 computeIfAbsent。基準測試方法的代碼如下:

測試環境:Intel Xeon Platinum 8124M CPU @ 3.00GHz, 兩個 CPU 插槽 (18核/槽), 每個核心啓動2個硬件線程;JVM 使用 Amazon Corretto 11。

新算法的 map 大小與ConcurrentHashMap 類似。500萬隨機key, java.util.concurrent.ConcurrentHashMap 需要285M字節,新 map 需要253M字節。

##4 差異分析

如果其他線程已更新空 slot,新 map 會進行重試。java.util.concurrent.ConcurrentHashMap 使用 array bin 作爲同步塊監視器,因此同一時刻只有一個線程修改 bin 中的內容。由此帶來了差異:新 map 會多次調用回調函數進行計算,每次更新失敗會調用一次方法;而 java.util.concurrent.ConcurrentHashMap 最多隻計算一次。

5 總結

自己實現的 computeIfAbsent 比 java.util.concurrent.ConcurrentHashMap 擴展性更好。文中的算法之所以簡單,因爲沒有涉及元素刪除以及 key 的改變。可以用來存儲 class 以及相關元數據。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章