1. 程式人生 > >ConcurrentHashMap實現原理總結--補充

ConcurrentHashMap實現原理總結--補充

       看完了get操作,再看下put操作,put操作的前面也是確定Segment的過程,這裡不再贅述,直接看關鍵的segment的put方法:


       首先對Segment的put操作是加鎖完成的,如果Segment中元素的數量超過了閾值(由建構函式中的loadFactor算出)這需要進行對Segment擴容,並且要進行rehash,關於rehash的過程大家可以自己去了解,這裡不詳細講了。
        getFirst的過程,確定連結串列頭部的位置。
        while迴圈是在連結串列中尋找和要put的元素相同key的元素,如果找到,就直接更新更新key的value,如果沒有找到,則生成一個新的HashEntry並且把它加到整個Segment的頭部,然後再更新count的值。

        在前面的章節中,我們涉及到的操作都是在單個Segment中進行的,但是ConcurrentHashMap有一些操作是在多個Segment中進行,比如size操作,ConcurrentHashMap的size操作也採用了一種比較巧的方式,來儘量避免對所有的Segment都加鎖。
  前面我們提到了一個Segment中的有一個modCount變數,代表的是對Segment中元素的數量造成影響的操作的次數,這個值只增不減,size操作就是遍歷了兩次Segment,每次記錄Segment的modCount值,然後將兩次的modCount進行比較,如果相同,則表示期間沒有發生過寫入操作,就將原先遍歷的結果返回,如果不相同,則把這個過程再重複做一次,如果再不相同,則就需要將所有的Segment都鎖住,然後一個一個遍歷了,

這需要按順序鎖定所有段,操作完畢後,又按順序釋放所有段的鎖。這裡“按順序”是很重要的,否則極有可能出現死鎖,在ConcurrentHashMap內部,段陣列是final的,並且其成員變數實際上也是final的,但是,僅僅是將陣列宣告為final的並不保證陣列成員也是final的,這需要實現上的保證。這可以確保不會出現死鎖,因為獲得鎖的順序是固定的。

        總結:

       執行緒不安全的HashMap:因為多執行緒環境下,使用Hashmap進行put操作會引起死迴圈,導致CPU利用率接近100%,所以在併發情況下不能使用HashMap。

       效率低下的HashTable容器:HashTable容器使用synchronized來保證執行緒安全,但線上程競爭激烈的情況下HashTable的效率非常低下。因為當一個執行緒訪問HashTable的同步方法時,其他執行緒訪問HashTable的同步方法時,可能會進入阻塞或輪詢狀態。如執行緒1使用put進行新增元素,執行緒2不但不能使用put方法新增元素,並且也不能使用get方法來獲取元素,所以競爭越激烈效率越低。

       ConcurrentHashMap完全允許多個讀操作併發進行,讀操作並不需要加鎖。如果使用傳統的技術,如HashMap中的實現,如果允許可以在hash鏈的中間新增或刪除元素,讀操作不加鎖將得到不一致的資料。ConcurrentHashMap實現技術是保證HashEntry幾乎是不可變的。HashEntry代表每個hash鏈中的一個節點。

       可以看到在HashEntry中,除了value不是final的,其它值都是final的,這意味著不能從hash鏈的中間或尾部新增或刪除節點,因為這需要修改next 引用值,所有的節點的修改只能從頭部開始。對於put操作,可以一律新增到Hash鏈的頭部。但是對於remove操作,可能需要從中間刪除一個節點,這就需要將要刪除節點的前面所有節點整個複製一遍,最後一個節點指向要刪除結點的下一個結點。為了確保讀操作能夠看到最新的值,將value設定成volatile,這避免了加鎖。
       為了加快定位段以及段中hash槽的速度,每個段hash槽的的個數都是2^n,這使得通過位運算就可以定位段和段中hash槽的位置。當併發級別為預設值16時,也就是段的個數,hash值的高4位決定分配在哪個段中。

       每個segment是一個傳統意義上的hashtable,相當於一個子Hash表。

       count用來統計該段資料的個數,它是volatile(volatile 變數使用指南),它用來協調修改和讀取操作,以保證讀取操作能夠讀取到幾乎最新的修改。協調方式是這樣的,每次修改操作做了結構上的改變,如增加/刪除節點(修改節點的值不算結構上的改變),都要寫count值,每次讀取操作開始都要讀取count的值。這利用了 Java 5中對volatile語義的增強,對同一個volatile變數的寫和讀存在happens-before關係。modCount統計段結構改變的次數,主要是為了檢測對多個段進行遍歷過程中某個段是否發生改變,在講述跨段操作時會還會詳述。threashold用來表示需要進行rehash的界限值。table陣列儲存段中節點,每個陣列元素是個hash鏈,用HashEntry表示。table也是volatile,這使得能夠讀取到最新的 table值而不需要同步。loadFactor表示負載因子。

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

       由上面的程式碼可知segments陣列的長度ssize通過concurrencyLevel計算得出。為了能通過按位與的雜湊演算法來定位segments陣列的索引,必須保證segments陣列的長度是2的N次方,所以必須計算出一個是大於或等於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。

       定位Segment:
       既然ConcurrentHashMap使用分段鎖Segment來保護不同段的資料,那麼在插入和獲取元素的時候,必須先通過雜湊演算法定位到Segment。可以看到ConcurrentHashMap會首先對元素的hashCode進行一次再雜湊。其目的是為了減少雜湊衝突,使元素能夠均勻的分佈在不同的Segment上,從而提高容器的存取效率。假如雜湊的質量差到極點,那麼所有的元素都在一個Segment中,不僅存取元素緩慢,分段鎖也會失去意義。
       ConcurrentHashMap通過以下雜湊演算法定位segment。預設情況下segmentShift為28,segmentMask為15,再雜湊後的數最大是32位二進位制資料,向右無符號移動28位,意思是讓高4位參與到hash運算中, (hash >>> segmentShift) & segmentMask

       put:

       每個segment是一個傳統意義上的hashtable該方法也是在持有段鎖(鎖定整個segment)的情況下執行的,這當然是為了併發的安全,修改資料是不能併發進行的,必須得有個判斷是否超限的語句以確保容量不足時能夠rehash。接著是找是否存在同樣一個key的結點,如果存在就直接替換這個結點的值。否則建立一個新的結點並新增到hash鏈的頭部,這時一定要修改modCount和count的值,同樣修改count的值一定要放在最後一步。put方法呼叫了rehash方法,rehash方法實現得也很精巧,主要利用了table的大小為2^n,這裡就不介紹了。而比較難懂的是這句int index = hash & (tab.length - 1),原來segment裡面才是真正的hashtable,即每個segment是一個傳統意義上的hashtable,如上圖,從兩者的結構就可以看出區別,這裡就是找出需要的entry在table的哪一個位置,之後得到的entry就是這個鏈的第一個節點,如果e!=null,說明找到了,這是就要替換節點的值(onlyIfAbsent == false),否則,我們需要new一個entry,它的後繼是first,而讓tab[index]指向它,什麼意思呢?實際上就是將這個新entry插入到鏈頭,剩下的就非常容易理解了。

        get:

        Segment的get操作實現非常簡單和高效。先經過一次再雜湊,然後使用這個雜湊值通過雜湊運算定位到segment,再通過雜湊演算法定位到元素,get操作不需要鎖,除非讀到的值是空的才會加鎖重讀。第一步是訪問count變數,這是一個volatile變數,由於所有的修改操作在進行結構修改時都會在最後一步寫count 變數,通過這種機制保證get操作能夠得到幾乎最新的結構更新。對於非結構更新,也就是結點值的改變,由於HashEntry的value變數是 volatile的,也能保證讀取到最新的值。接下來就是根據hash和key對hash鏈進行遍歷找到要獲取的結點,如果沒有找到,直接訪回null。對hash鏈進行遍歷不需要加鎖的原因在於鏈指標next是final的。但是頭指標卻不是final的,這是通過getFirst(hash)方法返回,也就是存在 table陣列中的值。這使得getFirst(hash)可能返回過時的頭結點,例如,當執行get方法時,剛執行完getFirst(hash)之後,另一個執行緒執行了刪除操作並更新頭結點,這就導致get方法中返回的頭結點不是最新的。這是可以允許,通過對count變數的協調機制,get能讀取到幾乎最新的資料,雖然可能不是最新的。要得到最新的資料,只有採用完全的同步。
       最後,如果找到了所求的結點,判斷它的值如果非空就直接返回,否則在有鎖的狀態下再讀一次。這似乎有些費解,理論上結點的值不可能為空,這是因為 put的時候就進行了判斷,如果為空就要拋NullPointerException。空值的唯一源頭就是HashEntry中的預設值,因為 HashEntry中的value不是final的,非同步讀取有可能讀取到空值。仔細看下put操作的語句:tab[index] = new HashEntry<K,V>(key, hash, first, value),在這條語句中,HashEntry建構函式中對value的賦值以及對tab[index]的賦值可能被重新排序,這就可能導致結點的值為空。這裡當v為空時,可能是一個執行緒正在改變節點,而之前的get操作都未進行鎖定,根據bernstein條件,讀後寫或寫後讀都會引起資料的不一致,所以這裡要對這個e重新上鎖再讀一遍,以保證得到的是正確值。

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

       在定位元素的程式碼裡我們可以發現定位HashEntry和定位Segment的雜湊演算法雖然一樣,都與陣列的長度減去一相與,但是相與的值不一樣,定位Segment使用的是元素的hashcode通過再雜湊後得到的值的高位,而定位HashEntry直接使用的是再雜湊後的值。其目的是避免兩次雜湊後的值一樣,導致元素雖然在Segment裡雜湊開了,但是卻沒有在HashEntry裡雜湊開。
hash >>> segmentShift) & segmentMask//定位Segment所使用的hash演算法
int index = hash & (tab.length - 1);// 定位HashEntry所使用的hash演算法

        ConcurrentHashMap的size操作:
       如果我們要統計整個ConcurrentHashMap裡元素的大小,就必須統計所有Segment裡元素的大小後求和。Segment裡的全域性變數count是一個volatile變數,那麼在多執行緒場景下,我們是不是直接把所有Segment的count相加就可以得到整個ConcurrentHashMap大小了呢?不是的,雖然相加時可以獲取每個Segment的count的最新值,但是拿到之後可能累加前使用的count發生了變化,那麼統計結果就不準了。所以最安全的做法,是在統計size的時候把所有Segment的put,remove和clean方法全部鎖住,但是這種做法顯然非常低效。
       因為在累加count操作過程中,之前累加過的count發生變化的機率非常小,所以ConcurrentHashMap的做法是先嚐試2次通過不鎖住Segment的方式來統計各個Segment大小,如果統計的過程中,容器的count發生了變化,則再採用加鎖的方式來統計所有Segment的大小。
       那麼ConcurrentHashMap是如何判斷在統計的時候容器是否發生了變化呢?使用modCount變數,在put , remove和clean方法裡操作元素前都會將變數modCount進行加1,那麼在統計size前後比較modCount是否發生變化,從而得知容器的大小是否發生變化。