?Java Map中那些巧妙的設(shè)計
最近拜讀了一些Java Map的相關(guān)源碼,不得不驚嘆于JDK開發(fā)者們的鬼斧神工。他山之石可以攻玉,這些巧妙的設(shè)計思想非常有借鑒價值,可謂是最佳實踐。然而,大多數(shù)有關(guān)Java Map原理的科普類文章都是專注于“點”,并沒有連成“線”,甚至形成“網(wǎng)狀結(jié)構(gòu)”。因此,本文基于個人理解,對所閱讀的部分源碼進行了分類與總結(jié),歸納出Map中的幾個核心特性,包括:自動擴容、初始化與懶加載、哈希計算、位運算與并發(fā),并結(jié)合源碼進行深入講解,希望看完本文的你也能從中獲取到些許收獲(本文默認采用JDK1.8中的HashMap)。
一 自動擴容
最小可用原則,容量超過一定閾值便自動進行擴容。
擴容是通過resize方法來實現(xiàn)的。擴容發(fā)生在putVal方法的最后,即寫入元素之后才會判斷是否需要擴容操作,當自增后的size大于之前所計算好的閾值threshold,即執(zhí)行resize操作。

通過位運算<<1進行容量擴充,即擴容1倍,同時新的閾值newThr也擴容為老閾值的1倍。

擴容時,總共存在三種情況:
哈希桶數(shù)組中某個位置只有1個元素,即不存在哈希沖突時,則直接將該元素copy至新哈希桶數(shù)組的對應(yīng)位置即可。
哈希桶數(shù)組中某個位置的節(jié)點為樹節(jié)點時,則執(zhí)行紅黑樹的擴容操作。
哈希桶數(shù)組中某個位置的節(jié)點為普通節(jié)點時,則執(zhí)行鏈表擴容操作,在JDK1.8中,為了避免之前版本中并發(fā)擴容所導致的死鏈問題,引入了高低位鏈表輔助進行擴容操作。

HashMap hashMap = new HashMap(2);hashMap.put("1", 1);hashMap.put("2", 2);hashMap.put("3", 3);
初始化的時候只會設(shè)置默認的負載因子,并不會進行其他初始化的操作,在首次使用的時候才會進行初始化。



hashCode(key1) = 0000 0000 0000 1111 0000 0000 0000 0010hashCode(key2) = 0000 0000 0000 0000 0000 0000 0000 0010
hashCode(key1) ^ (hashCode(key1) 16)0000 0000 0000 1111 0000 0000 0000 1101hashCode(key2) ^ (hashCode(key2) 16)0000 0000 0000 0000 0000 0000 0000 0010

若不存在沖突,則重新進行hash取模,并copy到新buckets數(shù)組中的對應(yīng)位置。
若存在沖突元素,則采用高低位鏈表進行處理。通過e.hash & oldCap來判斷取模后是落在高位還是低位。舉個例子:假設(shè)當前元素hashCode為0001(忽略高位),其運算結(jié)果等于0,說明擴容后結(jié)果不變,取模后還是落在低位[0, 7],即0001 & 1000 = 0000,還是原位置,再用低位鏈表將這類的元素鏈接起來。假設(shè)當前元素的hashCode為1001, 其運算結(jié)果不為0,即1001 & 1000 = 1000 ,擴容后會落在高位,新的位置剛好是舊數(shù)組索引(1) + 舊數(shù)據(jù)長度(8) = 9,再用高位鏈表將這些元素鏈接起來。最后,將高低位鏈表的頭節(jié)點分別放在擴容后數(shù)組newTab的指定位置上,即完成了擴容操作。這種實現(xiàn)降低了對共享資源newTab的訪問頻次,先組織沖突節(jié)點,最后再放入newTab的指定位置。避免了JDK1.8之前每遍歷一個元素就放入newTab中,從而導致并發(fā)擴容下的死鏈問題。

找到大于等于給定值的最小2的整數(shù)次冪。

cap = 3n = 2n |= n >>> 1 010 | 001 = 011 n = 3n |= n >>> 2 011 | 000 = 011 n = 3n |= n >>> 4 011 | 000 = 011 n = 3….n = n + 1 = 4
cap = 5n = 4n |= n >>> 1 0100 | 0010 = 0110 n = 6n |= n >>> 2 0110 | 0001 = 0111 n = 7….n = n + 1 = 8
0100 10100111 11111000 0000
獲取給定值的最高有效位數(shù)(移位除了能夠進行乘除運算,還能用于保留高、低位操作,右移保留高位,左移保留低位)。

i 16 0000 0000 0000 0000 0000 0000 0000 0100 不為0i 24 0000 0000 0000 0000 0000 0000 0000 0000 等于0
i = 0000 0100 0000 0000 0000 0000 0000 0000i = 0100 0000 0000 0000 0000 0000 0000 0000i 30 0000 0000 0000 0000 0000 0000 0000 0001 不為0i 31 0000 0000 0000 0000 0000 0000 0000 0000 等于0


使用segment之后,會增加ConcurrentHashMap的存儲空間。
當單個segment過大時,并發(fā)性能會急劇下降。





當counterCells不為空,或counterCells為空且對baseCount進行CAS操作失敗時進入到后續(xù)計數(shù)處理邏輯,否則對baseCount進行CAS操作成功,直接返回。
后續(xù)計數(shù)處理邏輯中會調(diào)用核心計數(shù)方法fullAddCount,但需要滿足以下4個條件中的任意一個:1、counterCells為空;2、counterCells的size為0;3、counterCells對應(yīng)位置上的counterCell為空;4、CAS更新counterCells對應(yīng)位置上的counterCell失敗。這些條件背后的語義是,當前情況下,計數(shù)已經(jīng)或曾經(jīng)出現(xiàn)過并發(fā)沖突,需要優(yōu)先借助于CounterCell來解決。若counterCells與對應(yīng)位置上的元素已經(jīng)初始化(條件4),則先嘗試CAS進行更新,若失敗則調(diào)用fullAddCount繼續(xù)處理。若counterCells與對應(yīng)位置上的元素未初始化完成(條件1、2、3),也要調(diào)用AddCount進行后續(xù)處理。
這里確定cell下標時采用了ThreadLocalRandom.getProbe()作為哈希值,這個方法返回的是當前Thread中threadLocalRandomProbe字段的值。而且當哈希值沖突時,還可以通過advanceProbe方法來更換哈希值。這與HashMap中的哈希值計算邏輯不同,因為HashMap中要保證同一個key進行多次哈希計算的哈希值相同并且能定位到對應(yīng)的value,即便兩個key的哈希值沖突也不能隨便更換哈希值,只能采用鏈表或紅黑樹處理沖突。然而在計數(shù)場景,我們并不需要維護key-value的關(guān)系,只需要在counterCells中找到一個合適的位置放入計數(shù)cell,位置的差異對最終的求和結(jié)果是沒有影響的,因此當沖突時可以基于隨機策略更換一個哈希值來避免沖突。

A:表示counterCells已經(jīng)初始化完成,因此可以嘗試更新或創(chuàng)建對應(yīng)位置的CounterCell。
B:表示counterCells未初始化完成,且無沖突(拿到cellsBusy鎖),則加鎖初始化counterCells,初始容量為2。
C:表示counterCells未初始化完成,且有沖突(未能拿到cellsBusy鎖),則CAS更新baseCount,baseCount在求和時也會被算入到最終結(jié)果中,這也相當于是一種兜底策略,既然counterCells正在被其他線程鎖定,那當前線程也沒必要再等待了,直接嘗試使用baseCount進行累加。
a1:對應(yīng)位置的CounterCell未創(chuàng)建,采用鎖+Double Check的策略嘗試創(chuàng)建CounterCell,失敗的話則continue進行重試。這里面采用的鎖是cellsBusy,它保證創(chuàng)建CounterCell并放入counterCells時一定是串行執(zhí)行,避免重復(fù)創(chuàng)建,其實就是使用了DCL單例模式的策略。在CounterCells的創(chuàng)建、擴容中都需要使用該鎖。
a2:沖突檢測,變量wasUncontended是調(diào)用方addCount中傳入的,表示前置的CAS更新cell失敗,有沖突,需要更換哈希值【a7】后繼續(xù)重試。
a3:對應(yīng)位置的CounterCell不為空,直接CAS進行更新。
a4:
沖突檢測,當counterCells的引用值不等于當前線程對應(yīng)的引用值時,說明有其他線程更改了counterCells的引用,出現(xiàn)沖突,則將collide設(shè)為false,下次迭代時可進行擴容。 容量限制,counterCells容量的最大值為大于等于NCPU(實際機器CPU核心的數(shù)量)的最小2的整數(shù)次冪,當達到容量限制時后面的擴容分支便永遠不會執(zhí)行。這里限制的意義在于,真實并發(fā)度是由CPU核心來決定,當counterCells容量與CPU核心數(shù)量相等時,理想情況下就算所有CPU核心在同時運行不同的計數(shù)線程時,都不應(yīng)該出現(xiàn)沖突,每個線程選擇各自的cell進行處理即可。如果出現(xiàn)沖突,一定是哈希值的問題,因此采取的措施是重新計算哈希值a7,而不是通過擴容來解決。時間換空間,避免不必要的存儲空間浪費,非常贊的想法~
a5:更新擴容標志位,下次迭代時將會進行擴容。
a6:進行加鎖擴容,每次擴容1倍。
a7:更換哈希值。
private final void fullAddCount(long x, boolean wasUncontended) {int h;// 初始化probeif ((h = ThreadLocalRandom.getProbe()) == 0) {ThreadLocalRandom.localInit(); // force initializationh = ThreadLocalRandom.getProbe();wasUncontended = true;}// 用來控制擴容操作boolean collide = false; // True if last slot nonemptyfor (;;) {CounterCell[] as; CounterCell a; int n; long v;// 【A】counterCells已經(jīng)初始化完畢if ((as = counterCells) != null && (n = as.length) > 0) {// 【a1】對應(yīng)位置的CounterCell未創(chuàng)建if ((a = as[(n - 1) & h]) == null) {// cellsBusy其實是一個鎖,cellsBusy=0時表示無沖突if (cellsBusy == 0) { // Try to attach new Cell// 創(chuàng)建新的CounterCellCounterCell r = new CounterCell(x); // Optimistic create// Double Check,加鎖(通過CAS將cellsBusy設(shè)置1)if (cellsBusy == 0 &&U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {boolean created = false;try { // Recheck under lockCounterCell[] rs; int m, j;// Double Checkif ((rs = counterCells) != null &&(m = rs.length) > 0 &&rs[j = (m - 1) & h] == null) {// 將新創(chuàng)建的CounterCell放入counterCells中rs[j] = r;created = true;}} finally {// 解鎖,這里為什么不用CAS?因為當前流程中需要在獲取鎖的前提下進行,即串行執(zhí)行,因此不存在并發(fā)更新問題,只需要正常更新即可cellsBusy = 0;}if (created)break;// 創(chuàng)建失敗則重試continue; // Slot is now non-empty}}// cellsBusy不為0,說明被其他線程爭搶到了鎖,還不能考慮擴容collide = false;}//【a2】沖突檢測else if (!wasUncontended) // CAS already known to fail// 調(diào)用方addCount中CAS更新cell失敗,有沖突,則繼續(xù)嘗試CASwasUncontended = true; // Continue after rehash//【a3】對應(yīng)位置的CounterCell不為空,直接CAS進行更新else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))break;//【a4】容量限制else if (counterCells != as || n >= NCPU)// 說明counterCells容量的最大值為大于NCPU(實際機器CPU核心的數(shù)量)最小2的整數(shù)次冪。// 這里限制的意義在于,并發(fā)度是由CPU核心來決定,當counterCells容量與CPU核心數(shù)量相等時,理論上講就算所有CPU核心都在同時運行不同的計數(shù)線程時,都不應(yīng)該出現(xiàn)沖突,每個線程選擇各自的cell進行處理即可。如果出現(xiàn)沖突,一定是哈希值的問題,因此采取的措施是重新計算哈希值(h = ThreadLocalRandom.advanceProbe(h)),而不是通過擴容來解決// 當n大于NCPU時后面的分支就不會走到了collide = false; // At max size or stale// 【a5】更新擴容標志位else if (!collide)// 說明映射到cell位置不為空,并且嘗試進行CAS更新時失敗了,則說明有競爭,將collide設(shè)置為true,下次迭代時執(zhí)行后面的擴容操作,降低競爭度// 有競爭時,執(zhí)行rehash+擴容,當容量大于CPU核心時則停止擴容只進行rehashcollide = true;// 【a6】加鎖擴容else if (cellsBusy == 0 &&U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {// 加鎖擴容try {if (counterCells == as) {// Expand table unless stale// 擴容1倍CounterCell[] rs = new CounterCell[n << 1];for (int i = 0; i < n; ++i)rs[i] = as[i];counterCells = rs;}} finally {cellsBusy = 0;}collide = false;continue; // Retry with expanded table}//【a7】更換哈希值h = ThreadLocalRandom.advanceProbe(h);}// 【B】counterCells未初始化完成,且無沖突,則加鎖初始化counterCellselse if (cellsBusy == 0 && counterCells == as &&U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {boolean init = false;try { // Initialize tableif (counterCells == as) {CounterCell[] rs = new CounterCell[2];rs[h & 1] = new CounterCell(x);counterCells = rs;init = true;}} finally {cellsBusy = 0;}if (init)break;}// 【C】counterCells未初始化完成,且有沖突,則CAS更新baseCountelse if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))break; // Fall back on using base}
有道無術(shù),術(shù)可成;有術(shù)無道,止于術(shù)
歡迎大家關(guān)注Java之道公眾號
好文章,我在看??
評論
圖片
表情
