天天看點

速讀原著-深入分析 ConcurrentHashMap

深入分析 ConcurrentHashMap

術語定義

線程不安全的HashMap

因為多線程環境下,使用 HashMap 進行 put 操作會引起死循環,導緻 CPU 使用率接近 100%, 是以在并發情況下不能使用 HashMap,如以下代碼:

final HashMap<String, String> map = new HashMap<String, String>(2);
    Thread t = new Thread(new Runnable() {
        @Override

        public void run() {

            for (int i = 0; i < 10000; i++) {
                new Thread(new Runnable() {
                    @Override

                    public void run() {
                        map.put(UUID.randomUUID().toString(), "");
                    }

                }, "ftf" + i).start();

            }

        }
        t.start();

        t.join();
           

效率低下的HashTable 容器

HashTable 容器使用 synchronized 來保證線程安全,但線上程競争激烈的情況下 HashTable 的效率非常低下。因為當一個線程通路HashTable 的同步方法時,其他線程通路HashTable 的同步方法時,可能會進入阻塞或輪詢狀态。如線程 1 使用 put 進行添加元素,線程 2 不但不能使用 put 方法添加元素,并且也不能使用 get 方法來擷取元素,是以競争越激烈效率越低。

鎖分段技術

HashTable 容器在競争激烈的并發環境下表現出效率低下的原因是所有通路 HashTable 的線程都必須競争同一把鎖,那假如容器裡有多把鎖,每一把鎖用于鎖容器其中一部分資料,那麼當多線程通路容器裡不同資料段的資料時,線程間就不會存在鎖競争,進而可以有效的提高并發通路效率,這就是 ConcurrentHashMap 所使用的鎖分段技術,首先将資料分成一段一段的存儲, 然後給每一段資料配一把鎖,當一個線程占用鎖通路其中一個段資料的時候,其他段的資料也能被其他線程通路。

ConcurrentHashMap 的結構

我們通過ConcurrentHashMap 的類圖來分析ConcurrentHashMap 的結構。

速讀原著-深入分析 ConcurrentHashMap

ConcurrentHashMap 是由 Segment 數組結構和 HashEntry 數組結構組成。Segment 是一種可重入鎖ReentrantLock,在ConcurrentHashMap 裡扮演鎖的角色,HashEntry 則用于存儲鍵值對資料。一個 ConcurrentHashMap 裡包含一個 Segment 數組,Segment 的結構和 HashMap 類似,是一種數組和連結清單結構, 一個Segment 裡包含一個HashEntry 數組,每個HashEntry 是一個連結清單結構的元素, 每個 Segment 守護者一個 HashEntry 數組裡的元素,當對 HashEntry 數組的資料進行修改時,必須首先獲得它對應的 Segment 鎖。

速讀原著-深入分析 ConcurrentHashMap

ConcurrentHashMap 的初始化

ConcurrentHashMap 初始化方法是通過 initialCapacity,loadFactor, concurrencyLevel 幾個參數來初始化 segments 數組,段偏移量 segmentShift,段掩碼 segmentMask 和每個 segment 裡HashEntry 數組 。

初始化segments 數組。讓我們來看一下初始化segmentShift,segmentMask 和segments 數組的源代碼。

速讀原著-深入分析 ConcurrentHashMap

由上面的代碼可知 segments 數組的長度 ssize 通過 concurrencyLevel 計算得出。為了能通過按位與的雜湊演算法來定位segments數組的索引,必須保證segments數組的長度是2 的N次方(power- of-two size),是以必須計算出一個是大于或等于 concurrencyLevel 的最小的 2 的 N 次方值來作為 segments 數組的長度。假如 concurrencyLevel 等于 14,15 或 16,ssize 都會等于 16,即容器裡鎖的個數也是 16。注意 concurrencyLevel 的最大大小是 65535,意味着 segments 數組的長度最大為 65536,對應的二進制是 16 位。

初始化 segmentShift 和 segmentMask。這兩個全局變量在定位 segment 時的雜湊演算法裡需要使用, sshift 等于 ssize 從 1 向左移位的次數,在預設情況下 concurrencyLevel 等于 16,1 需要向左移位 移動 4 次,是以sshift 等于 4。segmentShift 用于定位參與hash 運算的位數,segmentShift 等于 32 減sshift,是以等于 28,這裡之是以用 32 是因為ConcurrentHashMap 裡的 hash()方法輸出的最大數是 32 位的,後面的測試中我們可以看到這點。segmentMask 是哈希運算的掩碼,等于ssize 減1,即 15,掩碼的二進制各個位的值都是 1。因為 ssize 的最大長度是 65536,是以 segmentShift 最大值是 16,segmentMask 最大值是 65535,對應的二進制是 16 位,每個位都是 1。

初始化每個 Segment。輸入參數 initialCapacity 是 ConcurrentHashMap 的初始化容量,loadfactor 是每個 segment 的負載因子,在構造方法裡需要通過這兩個參數來初始化數組中的每個 segment

速讀原著-深入分析 ConcurrentHashMap

上面代碼中的變量 cap 就是 segment 裡 HashEntry 數組的長度,它等于 initialCapacity 除以 ssize 的倍數 c,如果 c 大于 1,就會取大于等于 c 的 2 的 N 次方值,是以 cap 不是 1,就是 2 的 N 次方。segment的容量threshold=(int)cap*loadFactor,預設情況下initialCapacity等于16,loadfactor 等于 0.75,通過運算cap 等于 1,threshold 等于零。

定位Segment

既然 ConcurrentHashMap 使用分段鎖 Segment 來保護不同段的資料,那麼在插入和擷取元素的時候, 必須先通過雜湊演算法定位到 Segment。可以看到 ConcurrentHashMap 會首先使用Wang/Jenkins hash 的變種算法對元素的 hashCode 進行一次再哈希

private static int hash(int h) {

        h += (h << 15) ^ 0xffffcd7d; h ^= (h >>> 10);
        h += (h << 3);
        h ^= (h >>> 6);

        h += (h << 2) + (h << 14);

        return h ^ (h >>> 16);

    }
           

之是以進行再哈希,其目的是為了減少哈希沖突,使元素能夠均勻的分布在不同的 Segment 上, 進而提高容器的存取效率。假如哈希的品質差到極點,那麼所有的元素都在一個 Segment 中, 不僅存取元素緩慢,分段鎖也會失去意義。我做了一個測試,不通過再哈希而直接執行哈希計 算。

System.out.println(Integer.parseInt("0001111", 2) & 15);

System.out.println(Integer.parseInt("0011111", 2) & 15);

System.out.println(Integer.parseInt("0111111", 2) & 15);

System.out.println(Integer.parseInt("1111111", 2) & 15);
           

計算後輸出的哈希值全是 15,通過這個例子可以發現如果不進行再哈希,哈希沖突會非常嚴重, 因為隻要低位一樣,無論高位是什麼數,其哈希值總是一樣。我們再把上面的二進制資料進行 再哈希後結果如下,為了友善閱讀,不足 32 位的高位補了 0,每隔四位用豎線分割下。

0100|0111|0110|0111|1101|1010|0100|1110

1111|0111|0100|0011|0000|0001|1011|1000

0111|0111|0110|1001|0100|0110|0011|1110

1000|0011|0000|0000|1100|1000|0001|1010
           

可以發現每一位的資料都散列開了,通過這種再哈希能讓數字的每一位都能參加到哈希運算當中,進而減少哈希沖突。ConcurrentHashMap 通過以下雜湊演算法定位 segment。

速讀原著-深入分析 ConcurrentHashMap

預設情況下 segmentShift 為 28,segmentMask 為 15,再哈希後的數最大是 32 位二進制資料,向右無符号移動 28 位, 意思是讓高 4 位參與到 hash 運算中,

(hash >>> segmentShift) & segmentMask

的運算結果分别是 4,15,7 和 8,可以看到 hash 值沒有發生沖突。

ConcurrentHashMap 的get 操作

Segment 的 get 操作實作非常簡單和高效。先經過一次再哈希,然後使用這個哈希值通過哈希運算定位到 segment,再通過雜湊演算法定位到元素,代碼如下:

public V get(Object key) {
        int hash = hash(key.hashCode());
        return segmentFor(hash).get(key, hash);
    }
           

get 操作的高效之處在于整個 get 過程不需要加鎖,除非讀到的值是空的才會加鎖重讀,我們知道 HashTable 容器的 get 方法是需要加鎖的,那麼 ConcurrentHashMap 的 get 操作是如何做到不加鎖的呢?原因是它的 get 方法裡将要使用的共享變量都定義成 volatile,如用于統計目前Segement 大小的 count 字段和用于存儲值的HashEntry 的 value。定義成 volatile 的變量,能夠線上程之間保持可見性,能夠被多線程同時讀,并且保證不會讀到過期的值,但是隻能被單線程寫(有一種情況可以被多線程寫,就是寫入的值不依賴于原值),在 get 操作裡隻需要讀不需要寫共享變量 count 和 value,是以可以不用加鎖。之是以不會讀到過期的值,是根據 java 記憶體模型的happen before 原則,對volatile 字段的寫入操作先于讀操作,即使兩個線程同時修改和擷取 volatile 變量,get 操作也能拿到最新的值,這是用 volatile 替換鎖的經典應用場景。

transient volatile int count;

volatile V value;
           

在定位元素的代碼裡我們可以發現定位HashEntry 和定位Segment 的雜湊演算法雖然一樣,都與數組的長度減去一相與,但是相與的值不一樣,定位 Segment 使用的是元素的 hashcode 通過再哈希後得到的值的高位,而定位 HashEntry 直接使用的是再哈希後的值。其目的是避免兩次哈希後的值一樣,導緻元素雖然在 Segment 裡散列開了,但是卻沒有在 HashEntry 裡散列開。

hash >>> segmentShift) & segmentMask//定位 Segment 所使用的 hash 算法

int index = hash & (tab.length - 1);// 定位 HashEntry 所使用的 hash 算法
           

ConcurrentHashMap 的Put 操作

由于 put 方法裡需要對共享變量進行寫入操作,是以為了線程安全,在操作共享變量時必須得加鎖。Put 方法首先定位到 Segment,然後在 Segment 裡進行插入操作。插入操作需要經曆兩個步驟,第一步判斷是否需要對Segment 裡的HashEntry 數組進行擴容,第二步定位添加元素的位置然後放在HashEntry 數組裡。

是否需要擴容。在插入元素前會先判斷 Segment 裡的 HashEntry 數組是否超過容量(threshold), 如果超過閥值,數組進行擴容。值得一提的是,Segment 的擴容判斷比 HashMap 更恰當,因為HashMap 是在插入元素後判斷元素是否已經到達容量的,如果到達了就進行擴容,但是很有可 能擴容之後沒有新元素插入,這時HashMap 就進行了一次無效的擴容。

ConcurrentHashMap 的size 操作