文章目錄
1.傳統集合框架併發編程中Map存在的問題?
- HashMap死循環,造成CPU100%負載
HashMap進行存儲時,如果size超過(當前最大容量*負載因子)時候會發生resize,而resize中又調用了又調用了transfer()方法,而這個方法實現的機制就是將每個鏈表轉化到新鏈表,並且鏈表中的位置發生反轉,而這在多線程情況下是很容易造成鏈表迴路,從而發生死循環; - 元素丟失問題,多線程put操作,hash碰撞時候兩個線程得到同樣的bucketIndex可能會導致覆蓋的情況,有一個元素會丟失;
- 還有其他的,路過的評論區補充一下……
2.早期改進策略
- HashTable
HashTable相比HashMap是線程安全的,因爲HashTable所有的方法都是加了synchronized的,鎖的是整個hashMap,也就是我們說的鎖的粒度比較大,由於最基本的put,set操作都加了互斥鎖,造成的結果就是同一時間點只能由一個線程put或只能get,併發操作時所有的put,get操作都必須等一個線程完了之後再操作,線程安全得到了保證,但大大降低了併發效率,在非高度的併發的場景可取,高度併發時往往不可取, 。 - jdk1.8以前的ConcurrentHashMap
ConcurrentHashMap在jdk1.7及以前採用的是鎖分段機制來保證HashMap的線程安全,鎖分段也就是將HashMap內部分段,每段是一個segment, 對每個segment加鎖( 可以理解爲ConcurrentHashMap是一個segment數組 ),每個段裏面包含多個HashEntry,和原HashMap類似,hash相同的entry也是以鏈表形式存放,這樣鎖的粒度相比HashTable就小了很多,值得注意的是,1.7的ConcurrentHashMap是通過繼承ReentrantLock 來進行加鎖的,不同於之前HashTable使用synchronize的加鎖形式;通過鎖住每個segment來保證每個segment內的操作的線程安全性,也就避免了HashTable的整體同步,一定程度上提升了性能;
另外在構造的時候, Segment的數量由所謂的concurrentcyLevel決定, 默認是16; 和HashMap的初始容量一致, 也可以在相應構造函數直接指定。 同樣是2的冪數值, 如果輸入是類似15這種非冪值, 會被自動調整到16之類2的冪數值。所以,默認情況下此時的ConcurrentHashMap支持16個線程併發操作
- 除了以上兩種方法意外,Collections本身也提供了一種安全機制,就是通過Map<K,V> synchronizedMap(Map<K,V> m)方法將其包裝爲一個線程安全的map,我們看一下它的put源碼實現就清除了:
public V put(K key, V value) {
synchronized (mutex) {return m.put(key, value);}
}
以上簡單的說了早期如何保證HashMap的線程安全,下面詳細分析一下jdk1.8如何保證線程安全
3.ConcurrentHashMap採取了哪些方法來提高並發表現(jdk1.8)?
相比1.7做了兩個改進:
1.取消了鎖分段的設計,直接使用Node 數組來保存數據,並且用Node數組來保存數據,並且採用Node數組元素作爲鎖來實現對每一行數據加鎖來進一步減少併發衝突的概率。
2.引入了紅黑樹的設計,在原來的數組+鏈表的基礎上新增了紅黑樹的設計,當鏈表的長度超過8的時候就將鏈表轉爲紅黑樹,此時查詢的複雜度也降低到了O(logN), 提升了查詢的性能。
3.這一點不知道算不算是改進,但是和1.7確實是不一樣的,爲了解決線程安全問題,這一版的ConcurrentHashMap採用了synchronzied和CAS的方式,至於爲什麼選用了synchronzied我猜是因爲1.8的synchronzied也做了很多的優化,包括偏向鎖到輕量級所到重量級鎖膨脹,因此改進後的synchronzied相較於ReentrantLock的性能在某些情況下並不差或許會更優,所以這裏才選擇了synchronzied來加鎖,cas無鎖操作的特性我就不多說了,比較容易理解。
稍後我們分析put源碼的時候會看到這部分變化的具體實現。
另外,關於1.8版本的synchronzied優化可以查看本系列中博客中的:
【Java併發】-- synchronized原理 (偏向鎖,輕量級鎖,重量級鎖膨脹過程)
結構圖:
這個和jdk1.8的hashmap結構一致,但增加了線程安全的實現,所以結構簡單,但實現會複雜一些;
4.ConcurrentHashMap實現分析
4.1 ConcurrentHashMap中關鍵的屬性
table:
//裝載Node的數組,作爲ConcurrentHashMap的數據容器,採用懶加載的方式,
//直到第一次插入數據的時候纔會進行初始化操作,數組的大小總是爲2的冪次方。
volatile Node<K,V>[] table:
nextTable
//擴容時使用,平時爲null,只有在擴容的時候才爲非null,
volatile Node<K,V>[] nextTable;
sizeCtl (不同場景有不同意義,肥腸重要!!!)
// 該屬性用來控制table數組的大小,根據是否初始化和是否正在擴容有幾種情況:
-------------------------
// 當值爲負數時,-1這時表示數組有一個線程正在初始化,-n表示有n-1個線程正在進行擴容操作
// 注意:(擴容時可以多線程協作,但初始化只能有一個線程來完成)
-------------------------
// 當值爲正數時:表示當前數組的臨界值,也就是數組程度*負載因子得到的臨界值,到達這個值就會進行擴容操作
// 當值爲0時,是數組的默認初始值,此時還未被初始化。
volatile int sizeCtl;
sun.misc.Unsafe U
在ConcurrentHashMap的實現中也可以看到大量的cas操作,也就是U.compareAndSwapXXX類型的方法,調用這些方法去修改ConcurrentHashMap屬性的時候就是利用了cas無鎖算法來保證線程安全性,這是樂觀鎖的完美運用,cas是通過sun.misc.Unsafe類實現的,點到這個類之後我們發現所有的方法基本都是native的,也就是非java實現的接口; Unsafe類提供的方法是可以直接操作內存和線程的底層操作,該成員變量的獲取是在靜態代碼塊中:
static {
try {
U = sun.misc.Unsafe.getUnsafe();
.......
} catch (Exception e) {
throw new Error(e);
}
}
4.2 ConcurrentHashMap中關鍵的CAS操作
tabAt
該方法獲取對象中offset偏移地址對應的對象field的值, 簡單來說也就是獲取該方法用來獲取table數組中索引爲i的Node元素,但大家思考一下爲什麼不直接通過table[i]獲取到第i個元素,而非要通過底層Unsafe類來進行table的操作呢?
因爲我們雖然在table數組上加了volatile關鍵字來保證可見性,但是被volatile修飾的數組只針對數組的引用具有可先性,而不針對數組的元素,所以如果有其他個線程對這個數組的某個元素進行寫操作的時候,不一定能保證可見性,當前線程也就不一定讀到最新的值了。所以這裏調用了Unsafe的getObjectVolatile方法保證每個元素都讀到最新的值,同時也保證了性能。下面的casTabAt和setTabAt也是同理。
// 該方法用來獲取table數組中索引爲i的Node元素
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
casTabAt
// 利用CAS操作設置table數組中索引爲i的元素
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
setTabAt
// 該方法用來設置table數組中索引爲i的元素
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}
4.3 ConcurrentHashMap核心方法
從整體來說爲了解決線程安全的問題,ConcurrentHashMap使用了synchronzied和CAS的方式
put
put方法調用的是putVal來進行put操作,我們來分析一下putVal大致做了哪些事情來保證線程安全,下面是核心邏輯,一定要理解!!
- 首先用spread方法進行了一次重hash從而減小hash衝突的可能性;
- 調用initTable方法初始化table,已經初始化之後會跳過這一步;
- 判斷是否可以直接將新值插入到table數組中,爲什麼需要先判斷呢?這塊其實分了三種情況;首先插入和更新兩種,插入的又分爲直接插入table和接入鏈表;如果待插入的位置table[i]剛好爲null就可以直接插入。如果hash取模之後發現i已經有元素了,需要對比hash值是否相等,若相等則覆蓋原有元素,若不相等則以鏈表的形式將當前節點next屬性更改爲新的節點,把他們連起來。這裏多線程操作的情況下根據happenbefore規則 線程 A 的 casTabAt 操作,一定對線程 B 的 tabAt 操作可見;
- 判斷是否正在擴容,如果正在擴容可以協助擴容(但有協助線程數量有限制,跟cpu的核數有關)
- 當table[i]爲鏈表的頭結點,在鏈表中插入新值,我們可以看這部分代碼用synchronized 同步代碼塊包了起來,加了互斥鎖來保證線程安全。
- 當table[i]爲紅黑樹的根節點,在紅黑樹中插入新值
- 根據節點個數調整紅黑樹
- 對容量大小進行檢查,超過了臨界值需要擴容;
** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
//1. 計算key的hash值
int hash = spread(key.hashCode());
int binCount = 0; // 用來記錄鏈表的長度
for (Node<K,V>[] tab = table;;) {// 自旋,當出現線程競爭時不斷自旋
Node<K,V> f; int n, i, fh;
//2. 如果當前table還沒有初始化先調用initTable方法將tab進行初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable(); // 初始化數組方法
//3. tab中索引爲i的位置的元素爲null,則直接使用CAS將值插入即可
// 通過hash值對應的數組下標得到第一個節點;以volatile讀的方式來讀取table數組中的元素,
// 保證每次拿到的數據都是最新的
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 如果該下標返回的節點爲空,則直接cas插入,cas失敗則存在競爭,進入下一次循環
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
//4. 當前正在擴容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
//5. 當前爲鏈表,在鏈表中插入新的鍵值對
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
// 6.當前爲紅黑樹,將新的鍵值對插入到紅黑樹中
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
// 7.插入完鍵值對後再根據實際大小看是否需要轉換成紅黑樹
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
//8.對當前容量大小進行檢查,如果超過了臨界值(實際大小*加載因子)就需要擴容
addCount(1L, binCount);
return null;
}
詳細分析put的擴容操作
擴容部分有兩個經典的設計:
1.高併發下的擴容
2.如何保證addCount的數據安全性以及性能
// 調用傳參
addCount(1L, binCount);
// 把當前ConcurrentHashMap的元素個數+1
// 這個方法一共做了兩件事,更新baseCount的值,檢測是否進行擴容
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
//利用CAS方法更新baseCount的值
/* 判斷 counterCells 是否爲空,
1. 如果爲空,就通過 cas 操作嘗試修改 baseCount 變量,對這個變量進行原子累加操
作(做這個操作的意義是:如果在沒有競爭的情況下,仍然採用 baseCount 來記錄元素個
數)
2. 如果 cas 失敗說明存在競爭,這個時候不能再採用 baseCount 來累加,而是通過
CounterCell 來記錄
*/
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
boolean uncontended = true; // 是否衝突標識,默認爲沒有衝突
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
s = sumCount();
}
//如果check值大於等於0 則需要檢驗是否需要進行擴容操作
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
//
if (sc < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
//如果已經有其他線程在執行擴容操作
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
//當前線程是唯一的或是第一個發起擴容的線程 此時nextTable=null
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}
CounterCells 解釋
更新map的size值這裏借用了分佈式的思想,起到關鍵作用的是這裏的CounterCell 數組,這個數組裏面每個元素都存着一個value值,而最終map的size就是數組中所有value值相加得來的,詳細可以查看sumCount的源碼;
爲什麼如此設計呢?
一般的集和在進行put操作的時候,size的大小隻要隨着put操作i++即可,但是在多線程情況下i++的不安全結果也一定不準確,爲了保證這個size共享變量的安全性勢必會增加鎖的設計,通過自旋,cas或synchronize鎖等實現,但在競爭非常激烈的情況下如此這般設計一定會佔據資源影響性能,所以這裏採用了引入了CounterCells ,採用分佈式的思想進行分片化處理,其實看到這裏我是非常激動的,必須對Doug Lea大師真的致以最崇高的respect!具體如何實現呢?
注意這裏:as[ThreadLocalRandom.getProbe() & m]
as是CounterCells 數組,ThreadLocalRandom是保證在多線程情況下Random生成隨機數的線程安全;
實現邏輯:
- 計數表(CounterCells 數組)爲空則直接調用 fullAddCount ;
- 從計數表中隨機取出一個數組的位置爲空,直接調用 fullAddCount
- 通過 CAS 修改 CounterCell 隨機位置的值,如果修改失敗說明出現併發情況,繼續cas即可;
舉個簡單的例子:
比如說現在有三個線程ThreadA/B/C在併發進行put操作,ThreadLocalRandom.getProbe()會爲他們生成三個隨機數,範圍是(0,m),m是CounterCell.length-1; 比如說是初始化的長度爲2,此時假設爲這三個線程生成了三個隨機數0,1,0
ThreadA拿到了0,ThreadB拿到了1,ThreadC拿到了0,此時他們會針對CounterCell數組對應下表的value進行+1的cas操作,ThreadA會找到CounterCell[0]對0下標處的value元素+1,默認爲0,此時通過cas+1後變成1;因爲ThreadC也拿到了0下標所以也會對CounterCell[0]進行cas+1操作,cas是無鎖操作,ThreadC會一直cas重試,直到ThreadA操作完畢釋放鎖,於是CounterCell[0]中的value會經歷兩次+1的cas操作變成2;
同理ThreadB會將CounterCell[1]處的value值cas更新爲1,然後再調用sumCount將CounterCell數組中的所有value元素累加得到真正的size值;
這樣設計帶來的好處?
利用分片思維提高了負載能力,CounterCell的數組長度爲多少,就可以支持多少個線程併發的去對size計數,相應的負載能力就會多少倍;CounterCell的默認初始值爲2,也就是至少可以提升2倍的負載能力,CounterCell後期同樣可以擴容,但擴容的契機我還有待研究,暫不多說。
transfer 擴容階段
擴容的基本思想是跟hashMap是很像的,另外注意這裏的併發擴容是是沒有加鎖的,所以這裏支持併發擴容,效率是很高的,但是實現起來要複雜的多,所以這裏也是ConcurrentHashMap 的精華之一;
首先判斷是否需要擴容,也就是當更新後的鍵值對總數 baseCount >= 閾值 sizeCtl 時,進行
rehash,這裏面會有兩個邏輯。
- 如果當前正在處於擴容階段,則當前線程會加入並且協助擴容
- 如果當前沒有在擴容,則直接觸發擴容操作
resizeStamp
這裏先提一下resizeStamp這個擴容戳,是擴容時的重要標記;
static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
Integer.numberOfLeadingZeros 這個方法是返回無符號整數 n 最高位非 0 位前面的 0 的個數
比如 10 的二進制是 0000 0000 0000 0000 0000 0000 0000 1010
那麼這個方法返回的值就是 28
根據 resizeStamp 的運算邏輯,我們來推演一下,假如 n=16,那麼 resizeStamp(16)=32796
轉化爲二進制是
[0000 0000 0000 0000 1000 0000 0001 1100]
接着再來看,當第一個線程嘗試進行擴容的時候,會執行下面這段代碼
U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)
rs 左移 16 位,相當於原本的二進制低位變成了高位 1000 0000 0001 1100 0000 0000 0000 0000
然後再+2
=1000 0000 0001 1100 0000 0000 0000 0000 +10
=1000 0000 0001 1100 0000 0000 0000 0010
這樣存儲帶來的好處??
- 首先在 CHM 中是支持併發擴容的,也就是說如果當前的數組需要進行擴容操作,可以由多個線程來共同負責;
第一個擴容的線程,執行 transfer 方法之前,
會設置 sizeCtl =(resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2)
後續幫其擴容的線程,執行 transfer 方法之前,會設置 sizeCtl = sizeCtl+1
每一個退出 transfer 的方法的線程,退出之前,會設置 sizeCtl = sizeCtl-1
那麼最後一個線程退出時:必然有
sc == (resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2),即 (sc - 2)
== resizeStamp(n) << RESIZE_STAMP_SHIFT
如果 sc - 2 不等於標識符左移 16 位。如果他們相等了,說明沒有線程在幫助他們擴容了。也就是說,擴容結束了。 - 可以保證每次擴容都生成唯一的生成戳, 每次新的擴容,都有一個不同的 n(n是map的size),這個生成戳就是根據 n 來計算出來的一個數字, n 不同,這個數字也不同
第一個線程嘗試擴容的時候,爲什麼是+2 ??
因爲 1 表示初始化,2 表示一個線程在執行擴容,而且對 sizeCtl 的操作都是基於位運算的,
所以不會關心它本身的數值是多少,只關心它在二進制上的數值,而 sc + 1 會在
低 16 位上加 1。
多線程擴容要注意的問題?
在擴容的時候其他線程也可能正在添加元素,這時又觸發了擴容怎麼辦? 可能大家想到的第
一個解決方案是加互斥鎖,把轉移過程鎖住,雖然是可行的解決方案,但是會帶來較大的性
能開銷。因爲互斥鎖會導致所有訪問臨界區的線程陷入到阻塞狀態,持有鎖的線程耗時越長,
其他競爭線程就會一直被阻塞,導致吞吐量較低。而且還可能導致死鎖
而 ConcurrentHashMap 並沒有直接加鎖,而是採用 CAS 實現無鎖的併發同步策略,最精華
的部分是它可以利用多線程來進行協同擴容
簡單來說,它把 Node 數組當作多個線程之間共享的任務隊列,然後通過維護一個指針來劃
分每個線程鎖負責的區間,每個線程通過區間逆向遍歷來實現擴容,一個已經遷移完的
bucket 會被替換爲一個 ForwardingNode 節點,標記當前 bucket 已經被其他線程遷移完了。
transfer的源碼分析
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
/*
將 (n>>>3 相當於 n/8) 然後除以 CPU 核心數。如果得到的結果小於 16,那麼就使用 16
這裏的目的是讓每個 CPU 處理的桶一樣多,避免出現轉移任務不均勻的現象,如果桶較少
的話,默認一個 CPU(一個線程)處理 16 個桶,也就是長度爲 16 的時候,擴容的時候只會有一
個線程來擴容
*/
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) <
MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE;
//nextTab 未初始化, nextTab 是用來擴容的 node 數組
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
//新建一個 n<<1 原始 table 大小的 nextTab,也就是 32
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;//賦值給 nextTab
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE; //擴容失敗, sizeCtl 使用 int 的最大值
return;
}
nextTable = nextTab; //更新成員變量
transferIndex = n;//更新轉移下標, 表示轉移時的下標
}
int nextn = nextTab.length;//新的 tab 的長度
/* 創建一個 fwd 節點, 表示一個正在被遷移的 Node,並且它的 hash 值爲-1(MOVED),也
就是前面我們在講 putval 方法的時候,會有一個判斷 MOVED 的邏輯。它的作用是用來佔位,表示
原數組中位置 i 處的節點完成遷移以後,就會在 i 位置設置一個 fwd 來告訴其他線程這個位置已經
處理過了,具體後續還會在講
*/
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
/* 首次推進爲 true,如果等於 true,說明需要再次推進一個下標(i--),反之,如果是
false,那麼就不能推進下標,需要將當前的下標處理完畢才能繼續推進
boolean advance = true;咕泡學院-做技術人的指路明燈, 職場生涯的精神導師
判斷是否已經擴容完成,完成就 return,退出循環
*/
boolean finishing = false; // to ensure sweep before committing nextTab
/*通過 for 自循環處理每個槽位中的鏈表元素,默認 advace 爲真,通過 CAS 設置
transferIndex 屬性值,並初始化 i 和 bound 值, i 指當前處理的槽位序號, bound 指需要處理
的槽位邊界,先處理槽位 15 的節點;*/
for (int i = 0, bound = 0;;) {
// 這個循環使用 CAS 不斷嘗試爲當前線程分配任務
// 直到分配成功或任務隊列已經被全部分配完畢
// 如果當前線程已經被分配過 bucket 區域
// 那麼會通過--i 指向下一個待處理 bucket 然後退出該循環
Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
//--i 表示下一個待處理的 bucket,如果它>=bound,表示當前線程已經分配過bucket 區域
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {//表示所有 bucket 已經被分配完畢
i = -1;
advance = false;
}
/*通過 cas 來修改 TRANSFERINDEX,爲當前線程分配任務,處理的節點區間爲
(nextBound,nextIndex)->(0,15)*/
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;//0
i = nextIndex - 1;//15
advance = false;咕泡學院-做技術人的指路明燈, 職場生涯的精神導師
}
}
// i<0 說明已經遍歷完舊的數組,也就是當前線程已經處理完所有負責的 bucket
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) {//如果完成了擴容
nextTable = null;//刪除成員變量
table = nextTab;//更新 table 數組
sizeCtl = (n << 1) - (n >>> 1);//更新閾值(32*0.75=24)
return;
}
// sizeCtl 在遷移前會設置爲 (rs << RESIZE_STAMP_SHIFT) + 2
// 然後, 每增加一個線程參與遷移就會將 sizeCtl 加 1,
// 這裏使用 CAS 操作對 sizeCtl 的低 16 位進行減 1,代表做完了屬於自己的任務
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
/* 第一個擴容的線程,執行 transfer 方法之前,會設置 sizeCtl =
(resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2)
後續幫其擴容的線程,執行 transfer 方法之前,會設置 sizeCtl = sizeCtl+1
每一個退出 transfer 的方法的線程,退出之前,會設置 sizeCtl = sizeCtl-1
那麼最後一個線程退出時:必然有
sc == (resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2),即 (sc - 2)
== resizeStamp(n) << RESIZE_STAMP_SHIFT
// 如果 sc - 2 不等於標識符左移 16 位。如果他們相等了,說明沒有線程在
幫助他們擴容了。也就是說,擴容結束了。*/
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
// 如果相等,擴容結束了,更新 finising 變量
finishing = advance = true;
// 再次循環檢查一下整張表
i = n; // recheck before commit咕泡學院-做技術人的指路明燈, 職場生涯的精神導師
}
}
// 如果位置 i 處是空的,沒有任何節點,那麼放入剛剛初始化的 ForwardingNode ”空節點“
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
//表示該位置已經完成了遷移,也就是如果線程 A 已經處理過這個節點,
// 那麼線程 B 處理這個節點時, hash 值一定爲 MOVED
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
}
}
數據遷移:
擴容之後的數據遷移是藉助高低位來實現的,有兩個問題我們需要注意:
1.高低位如何劃分
通過 fn&n 可以把這個鏈表中的元素分爲兩類, A 類是 hash 值的第 X 位爲 0, B 類是 hash 值的第 x 位爲不等於 0(至於爲什麼要這麼區分,稍後分析),並且通過 lastRun 記錄最後要處理的節點。最終要達到的目的是, A 類的鏈表保持位置不動, B 類的鏈表爲 14+16(擴容增加的長度)=30
圖解一下過程:
遷移前:
擴容遷移後:
擴容之後關於紅黑樹節點的調整今天暫不做分析了;
get
put如若看得理解了,get就非常容易了;
代碼的邏輯請看註釋,首先先看當前的hash桶數組節點即table[i]是否爲查找的節點,若是則直接返回;若不是,則繼續再看當前是不是樹節點?通過看節點的hash值是否爲小於0,如果小於0則爲樹節點。如果是樹節點在紅黑樹中查找節點;如果不是樹節點,那就只剩下爲鏈表的形式的一種可能性了,就向後遍歷查找節點,若查找到則返回節點的value即可,若沒有找到就返回null。
看一下get的源碼:
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
// 1. 重hash
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 2. table[i]桶節點的key與查找的key相同,則直接返回
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// 3. 當前節點hash小於0說明爲樹節點,在紅黑樹中查找即可
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) {
//4. 從鏈表中查找,查找到則返回該節點的value,否則就返回null即可
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
一篇博客寫了一天半,分析源碼真心好累啊,燒腦…… 燒腦…… 燒腦…… 燒腦…… 智商不夠真捉急……