面試官:HashMap線程不安全 怎麼解決?你來說說ConcurrentHashMap (JDK7)
從構造方法探尋ConcurrentHashMap的數據結構
我們先大概看一張圖 讓大家先有一個認識 在去通過構造方法深入的分析
這裏 我們要去瞭解到 ConcurrentHashMap 由一個個的Segment組成 而一個個的Segment由一個個HashEntry組成 每個HashEntry裏存放了Key-Value鍵值對 我們先大概清楚這個結構 然後再去進入源碼分析究竟是怎麼回事
這個是 ConcurrentHashMap的構造方法 傳入了3個參數
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// Find power-of-two sizes best matching arguments
int sshift = 0;
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
// create segments and segments[0]
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
}
這個是ConcurrentHashMap的1.7下的構造方法 我們先了解默認情況下構造方法的三個參數的默認賦值是怎樣的
static final int DEFAULT_INITIAL_CAPACITY = 16;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
我們選取代碼塊一步步的分析
int sshift = 0;
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
初始情況下 ssize=1 concurrencyLevel=16
第一次循環 ssize=1<16 進入循環 ssize左移兩位變成2;
第二次循環 ssize=2<16 進入循環 ssize左移兩位變成4;
第三次循環 ssize=4<16 進入循環 ssize左移兩位變成8;
第四次循環 ssize=8<16 進入循環 ssize左移變成16;
此時 ssize變成了16 不小於16 跳出循環
這塊代碼的目的是去找到一個最小的大於等於concurrencyLevel的2的冪次方數
大於等於16的最小2的冪次方數就是16呀 所以我們找到ssize就是16
同時 心細一點的同學跟着剛纔的循環去計算 得到的sshift是4 也就是找到了2^4等於16的這個4
我們往下看下一部分代碼
我們先註釋掉一部分不去管它
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
int c = initialCapacity / ssize;
/* if (c * ssize < initialCapacity)
++c;*/
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
這塊代碼就要引出ConcurrentHashMap的底層結構了 要認真理解這塊
//initialCapacity的默認值是16
//ssize上面我們算出來是16
//我們可以得到下面這個寫的不太規範的式子
int c=16/16=1
這裏的c是什麼呢 ? 這裏的c先把他理解成就是Segment數組的大小(其實c的含義是不對的 但我們先這麼理解) 我們是根據 initialCapacity和ssize計算出來得
彆着急 下面還有完善
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
這個MIN_SEGMENT_TABLE_CAPACITY的**默認值是2(也就是說設計者設計這個Segment數組最小長度就是2) **就是說現在cap=2;c=1;
2不小於1 所以不會進入這個while循環 接着往下
我們這裏算的c=1 比設計者設計的Segment數組最小長度還要小 所以我們按cap=2去初始化
如上圖所示 cap指定了每一個Segment可以放幾個HashEntry
ssize指定了一個ConcurrentHashMap可以放多少個Segment
現在 我們就可以重新繪製一下這個數據結構圖了
這其實就是默認情況下 ConcurrentHashMap的數據結構了
這裏 我們把剛剛註釋掉的代碼打開 再去分析一波
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
假設 我這裏指定了initialCapacity是33
呢我們這個時候33/16 計算出來得c就是2 這個時候就會進入if這裏 c就會加1 此時c等於了3
while (cap < c)
cap <<= 1;
當代碼走到這裏 cap=2 c=3 2<3
就會進入while當中 cap左移變成了4 這裏實質性的改變就是對應的每一個Segment就會有4個HashEntry
這裏說明一下 爲什麼cap使用了左移 從2變成了4 這是因爲設計者要Segment的大小不論是幾
都應該是2的冪次方數
此時的數據結構是這樣
ConcurrentHashMap的數據插入原理(put方法)—怎麼去保證線程安全
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
int hash = hash(key);
//(1) j是算出來的Segment數組的下標
int j = (hash >>> segmentShift) & segmentMask;
//(2) 通過Unsafe類去取segments數組第j個位置的元素看是不是null
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
//(3) 如果是null 去生成一個Segment對象
s = ensureSegment(j);
//(4) 去調用生成的Segment對象的put方法
return s.put(key, hash, value, false);
}
我們把(3)處的ensureSegment方法展開說明一下 去理解他是怎麼生成一個Segment對象並保證線程安全的
private Segment<K,V> ensureSegment(int k) {
final Segment<K,V>[] ss = this.segments;
long u = (k << SSHIFT) + SBASE; // raw offset
Segment<K,V> seg;
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
//這部分代碼就是將一開始構造方法生成的ss[0]作爲一個原型(雛形)
//利用ss[0]去初始化我們此時的Segment對象
//但是真正初始化在下面一個if之後 這裏可以理解是做了一個準備工作
//準備了一些需要的屬性 如負載因子啊 cap長度啊
Segment<K,V> proto = ss[0]; // use segment 0 as prototype
int cap = proto.table.length;
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) { // recheck
//這時候又再次判斷該位置有沒有其他線程進行了初始化
//沒有其他線程的話 這時候真正去創建一個Segment對象
//但是這裏還沒有把Segment對象放到數組對應的位置
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
//這裏的這個CAS操作真正對第u個位置進行賦值
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
這個時候問題的關鍵來了 他在創建Segment對象的過程中是怎麼確保線程安全的呢?
我們選取這部分代碼
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
//...省略
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) { // recheck
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
//這裏利用cas原子操作真正對數組第u個位置賦值
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
首先 他運用了一個雙重檢驗判斷 這個判斷類似單例模式的雙重檢驗判斷
public class Singleton {
private static Singleton instance=null;
private Singleton(){
}
public static Singleton getInstance() {
if(instance==null){//檢查判斷1
synchronized (Singleton.class){
if(instance==null){//檢查判斷2
instance=new Singleton();
}
}
}
return instance;
}
}
(1)當線程A、B同時調用getInstance()方法,他們同時發現 instance == null成立(檢查判斷 1),同時去獲取的Singleton.class鎖
(2)其中線程A獲取到鎖,線程B 處於等待狀態;線程A會創建一個SingleTon實例,之後釋放鎖
(3)線程A釋放鎖後,線程B 被喚醒,線程B獲取到鎖,然後線程B檢查
instance == null 不成立(檢查判斷2),不會再創建Singleton實例對象
第二 也是更重要的是 他在馬上要賦值的時候 利用了CAS這個原子操作
CAS這個原子操作是不能被中斷的 我們這裏簡單談一下CAS幹了什麼
CAS就是先獲取主物理內存中的值作爲期望值 然後我們再去獲得此時主物理內存的真實值 如果期望值與真實值一致 我們就進行修改 否則 就一直取值比較 直到成功
所以每到一個關鍵節點 他就會做一次關於線程安全的判斷 利用了雙重檢查機制 也利用了CAS思想
總結一下 這個方法幹了什麼事情 也就是在new 一個Segment對象的時候保證了線程安全
我們把(4)處的Segment對象的put方法展開說明一下
//(4) 去調用生成的Segment對象的put方法
return s.put(key, hash, value, false);
我們去繼續學習這個Segment對象的put方法
這裏我們先去抽象出一個數據結構 也就是說Segment內部維護的一個個HashEntry整合起來 就好像一個小的HashMap一樣 也是數組+鏈表的形式(Segment內部就像一個小的HashMap)
我們先不去看加鎖的邏輯 我們先把中間怎麼put數據的流程大致理解清楚
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
//取tab數組 下表爲index的值作爲first
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
//鏈表的頭結點不爲空的情況
if (e != null) {
K k;
//遍歷當前位置的鏈表
//判斷傳入的key 和當前遍歷的 key 是否相等,相等則覆蓋舊的 value
//這裏根HashMap的邏輯很像
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
e = e.next;
}
//爲空的情況
//情況1 頭結點爲空 把key-value放在頭結點
//情況2 遍歷完整個鏈表 然後頭插法插入
else {
if (node != null)
node.setNext(first);
else
//生成了一個HashEntry對象 記爲node
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
//如果超過閾值 就rehash
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
//沒有超過閾值 就把剛剛生成的node通過setEntryAt這個方法放進去
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
我們上面初步分析了一下 put添加數據的過程
下面我們重點分析一下加鎖的過程
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
首先 我們去理解 scanAndLockForPut這個方法幹了什麼事情
我們這裏要做一個小的鋪墊
trylock()
lock()
trylock()這個方法 如果能夠獲取到鎖 就會立馬返回一個true
trylock()這個方法 如果獲取不到鎖 就會立馬返回一個false
trylock()不會阻塞
lock()這個方法如果獲取不到鎖 就會一直阻塞在這裏
這個scanAndLockForPut方法大概幹了什麼事情 我給大家解釋一下
當trylock()獲取不到鎖的時候 通過剛剛我們的鋪墊我們知道trylock()是不會阻塞的
那我們不能傻傻的等在這裏 我們既然不會阻塞 我們在這個過程中可以準備一些什麼事情呀?
這個過程就好比做飯 你在燒水等水開的過程中 可以去準備個涼菜 算是合理安排 提高效率
我們這裏的合理安排就是根據key-value去new一個HashEntry 我們把這個HashEntry記成node
這裏我們就把scanAndLockForPut這個方法做的事情給大家大致說明白了
scanAndLockForPut這個方法本身就設計的非常精妙 由於篇幅的原因就不在展開描述
之後會有更加詳細的說明解釋
tryLock() ? null : scanAndLockForPut(key, hash, value);
這裏不是有個三目運算符嗎 trylock()獲取不到鎖的時候 就會走scanAndLockForPut這個方法準備一個node對象出來
第二 我們理解一下這裏的保證線程安全 爲什麼用了lock
如上圖所示 我們根本的目的是把key-value放進去
我們要在鏈表當中去插入元素 注意是插入元素 這個時候CAS就沒有更好的辦法了 因爲CAS對某一個具體的位置賦 值還是可以的 但是讓CAS去插入是不能實現的 所以這個插入時候我們爲了保證線程安全 就要去加鎖
這裏保證線程安全的方法很實在 就是加了一把鎖 讓同一時間只有一個線程去put數據
我們總結一下jdk7 下 ConcurrentHashMap是怎麼保證併發安全的
第一 在進行一些鏈表的插入數據時 用了ReentrantLock去加了一把鎖
第二 用了UNSAFE的各種方法 這其中包括了我們最熟悉的CAS 還有UNSAFE類的一些其他方法呀
比如 UNSAFE.putOrderedObject等等