面試中常被問到的數據結構就是哈希表,一般都是先問HashMap,再接着問ConcurrentHashMap,所以深入學習源碼以及相關的知識是很重要的。
大家也可以參考我之前的 深入剖析核心源碼之 HashMap
源碼學習之 ConcurrentHashMap
- 1.爲什麼不繼續使用HashMap?
- 2.有什麼方式保證多線程安全?
- 3. Collections.synchronizedMap(Map)怎麼實現的?
- 4.HashTable又是怎麼實現的?
- 5. HashMap和HashTable的區別有哪些?
- 6.ConcurrentHashMap在JDK1.7中是底層結構是怎樣的?
- 7.ConcurrentHashMap在JDK1.7中各種操作是怎樣的?
- 8.ConcurrentHashMap的JDK1.7版本有什麼問題呢?
- 9.ConcurrentHashMap在JDK1.8中是底層結構是怎樣的?
- 10.ConcurrentHashMap在JDK1.8中各種操作是怎樣的?
- 11.ConcurrentHashMap 1.7和1.8版本的異同
1.爲什麼不繼續使用HashMap?
因爲HashMap在多線程的情況下不安全,只適合單線程下使用,在JDK1.7時,HashMap採用頭插方式,這樣如果使用多線程在擴容進行重新再散列後,就會產生環形鏈表的問題。雖然在1.8後優化爲了尾插,解決了環形鏈表的問題,但由於它所有的方法都不是線程安全的,所有多線程環境下並不適合使用。
2.有什麼方式保證多線程安全?
一般在多線程下,有以下幾種方式代替HashMap:
- 使用Collections.synchronizedMap(Map)創建線程安全的map集合;
- 使用 Hashtable
- 使用 ConcurrentHashMap
其中,ConcurrentHashMap的效率要高於前兩種方式
3. Collections.synchronizedMap(Map)怎麼實現的?
首先傳入一個我們自己的Map m後,會創建一個SynchronizedMap
類的實例。
接着,來看看SynchronizedMap
類是怎麼實現的: 這個類維護了一個Map用來接收我們的傳入參數,還維護了一個Object的對象用來作爲鎖對象,也可以自己傳入一個對象作爲鎖對象。而在這個類的內部,定義的所有方法都是用synchronized
關鍵字對mutex對象加鎖包裹一個代碼塊,實現了多線程下的安全問題。 當然,使用這種方式的效率會很低,由於鎖的是同一個對象,所以哪怕是兩個線程同時想讀取數據,其中一個也會被鎖住。
4.HashTable又是怎麼實現的?
在前面學習的 HashMap中,對於HashMap有個描述:HashMap和HashTable實現基本是相同的,除了HashMap是允許null爲Key以及HashMap是線程不安全的。從這裏就可以看出,HashTable與HashMap最大的不同就是它是線程安全的。而HashTable實現線程安全的方式也很簡單粗暴,那就是給每一個方法都加上synchronized關鍵字
大家可以自己去查看源碼:
5. HashMap和HashTable的區別有哪些?
線程安全性不同: HashMap不是線程安全的,Hashtable是線程安全的。
null爲Key的要求不同:HashMap 中null 可以作爲Key,而Hashtable中不可以。
實現方式不同:Hashtable 繼承了 Dictionary類,而 HashMap 繼承的是 AbstractMap 類。
初始化容量不同:HashMap 的初始容量爲:16,Hashtable 初始容量爲:11,兩者的負載因子默認都是:0.75。
底層實現不同:HashMap底層採用 數組+鏈表/紅黑樹 來實現的,HashTable採用 數組+鏈表實現。
擴容機制不同:HashMap 擴容規則爲當前容量2倍,Hashtable 擴容規則爲當前容量2倍 + 1。
- 迭代器不同:HashMap 中的 Iterator 迭代器是 fail-fast 的,而 Hashtable 是fail—safe 的
小知識:
- fail-fast (快速失敗):在用迭代器遍歷一個集合對象時,如果遍歷過程中對集合對象的內容進行了修改(增加、刪除、修改)則會拋出Concurrent Modification Exception。java.util包下的集合類都是快速失敗的,不能在多線程下發生併發修改(迭代過程中被修改)
- fail—safe (安全失敗): 採用安全失敗機制的集合容器,在遍歷時不是直接在集合內容上訪問的,而是先複製原有集合內容,在拷貝的集合上進行遍歷,所以元素的更新不影響遍歷。java.util.concurrent包下的容器都是安全失敗,可以在多線程下併發使用,併發修改。
6.ConcurrentHashMap在JDK1.7中是底層結構是怎樣的?
在JDK1.7 中使用的是分段鎖機制,它的底層結構圖如下:
其中Segment是一種可重入鎖(ReentrantLock) 而HashEntry則用於存儲鍵值對數據。這就實現了使用不同的鎖鎖住不同的數據段,避免了訪問所有方法都競爭同一把鎖,提高了併發效率。
一個ConcurrentHashMap中包含着一個Segment數組,Segment結構 是數組+鏈表,也就是一個Segment中包含一個HashEntry的數組,每個HashEntry又是一個鏈表結構的元素(HahsEntry就像HashMap中的Node一樣,是真正存放數據的桶)。也就是把HashMap的數組拆分成不同段,然後給不同段加上相應的Segment鎖,這樣,只有在對同一個Segment中的元素進行操作時纔會加同一把鎖,而對不同的Segment中元素操作時加不同的鎖,不會由器線程阻塞。
7.ConcurrentHashMap在JDK1.7中各種操作是怎樣的?
get操作:
首先會進過一次再散列,得到散列值通過散列運算定位到對應的Segment,再通過散列算法定位到元素。 get操作的高效之處就在於整個過程是不需要加鎖,爲什麼呢?因爲它將所有的共享變量都定義成了volatile類型,這樣就能保證變量在線程之間的可見性,能夠被多線程同時讀,且保證讀到的都是最新的、正確的值。
put操作:
爲了保證多線程安全,put操作是必須要加鎖的。還是同樣定位到相應的Segment後在其中插入元素,此時就考慮是否需要對Segment中的HashEntry擴容,如果需要擴容就會調用rehash方法將容量擴大爲原來的兩倍,注意,這裏只是擴大某個Segment而不是整個Map
源碼如下:
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
int hash = hash(key);
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject
(segments, (j << SSHIFT) + SBASE)) == null)
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 嘗試獲取鎖,如果獲取失敗肯定就有其他線程存在競爭,利用 scanAndLockForPut() 自旋獲取鎖。
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) {
K k;
// 遍歷該 HashEntry,如果不爲空則判斷傳入的 key 和當前遍歷的 key 是否相等,相等則覆蓋舊的 value。
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
e = e.next;
}
else {
// 不爲空則需要新建一個 HashEntry 並加入到 Segment 中,同時會先判斷是否需要擴容。
if (node != null)
node.setNext(first);
else
node = new HashEntry<K,V>(hash, key, value, first); //新put的節點
int c = count + 1;
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node); //擴容
else
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
size操作:
要統計整個的size,就要統計所有的Segment離元素的大小後求和。雖然Segment裏面的count變量是個volatile類型的變量,但還是不可以直接相加。因爲不能保證在進行相加的這個過程中沒有修改某一個Segment中的count值。第一個想到的就是把能使得count改變的操作都鎖住,比如:put、remove等鎖住,但這樣實在是有些低效。
因爲在累加count時,之前的count變化的機率比較小,所以,ConcurrentHashMap做法就是先嚐試2次不鎖的方式來統計大小,如果在統計過程中,count發生了變化在採用加鎖的方式統計大小。
8.ConcurrentHashMap的JDK1.7版本有什麼問題呢?
可以看出,1.7版本的 ConcurrentHashMap在線程安全問題上時做到位了,但還存在一點點的不足之處,那就是如果一個HashEntry中數據過多,那麼在查詢中只能遍歷一遍,這樣的時間複雜度就是 O(n) 查詢的效率相對低下,所以在1.8版本又對 ConcurrentHashMap進行了進一步的改進。
9.ConcurrentHashMap在JDK1.8中是底層結構是怎樣的?
JDK1.8中ConcurrentHashMap採用數據 + 鏈表/紅黑樹的結構實現的,結構圖如下:
在JDK1.8中,拋棄了原有的分段鎖結構,改爲了CAS + synchronized
來保證併發安全性。也把之前的HashEntry改成了Node,但是作用不變,把值和next採用了volatile去修飾,保證了可見性,並且也引入了紅黑樹,在鏈表大於一定值的時候會轉換(默認是8)。
10.ConcurrentHashMap在JDK1.8中各種操作是怎樣的?
各種屬性:
//最大容量
private static final int MAXIMUM_CAPACITY = 1 << 30;
//初始容量
private static final int DEFAULT_CAPACITY = 16;
//數組最大容量
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
//默認併發度,兼容1.7及之前版本
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
//加載/擴容因子,實際使用n - (n >>> 2)
private static final float LOAD_FACTOR = 0.75f;
//鏈表轉紅黑樹的節點數閥值
static final int TREEIFY_THRESHOLD = 8;
//紅黑樹轉鏈表的節點數閥值
static final int UNTREEIFY_THRESHOLD = 6;
//當數組長度還未超過64,優先數組的擴容,否則將鏈表轉爲紅黑樹
static final int MIN_TREEIFY_CAPACITY = 64;
//擴容時任務的最小轉移節點數
private static final int MIN_TRANSFER_STRIDE = 16;
//sizeCtl中記錄stamp的位數
private static int RESIZE_STAMP_BITS = 16;
//幫助擴容的最大線程數
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;
//size在sizeCtl中的偏移量
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
// ForwardingNode標記節點的hash值(表示正在擴容)
static final int MOVED = -1; // hash for forwarding nodes
// TreeBin節點的hash值,它是對應桶的根節點
static final int TREEBIN = -2; // hash for roots of trees
static final int RESERVED = -3; // hash for transient reservations
static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash
//存放Node元素的數組,在第一次插入數據時初始化
transient volatile Node<K,V>[] table;
//一個過渡的table表,只有在擴容的時候纔會使用
private transient volatile Node<K,V>[] nextTable;
//基礎計數器值(size = baseCount + CounterCell[i].value)
private transient volatile long baseCount;
/**
* 控制table數組的初始化和擴容,不同的值有不同的含義:
* -1:表示正在初始化
* -n:表示正在擴容
* 0:表示還未初始化,默認值
* 大於0:表示下一次擴容的閾值
*/
private transient volatile int sizeCtl;
//節點轉移時下一個需要轉移的table索引
private transient volatile int transferIndex;
//元素變化時用於控制自旋
private transient volatile int cellsBusy;
// 保存table中的每個節點的元素個數 長度是2的冪次方,初始化是2,每次擴容爲原來的2倍
// size = baseCount + CounterCell[i].value
get操作:
與1.7一樣,get方法是全程沒有加鎖的,但由於變量是volatile的,所以可以保證線程安全。它的流程就是:
- 根據計算出來的 hashcode 尋址,如果就在桶上那麼直接返回值。
- 如果是紅黑樹那就按照樹的方式獲取值。
- 如果不滿足那就按照鏈表的方式遍歷獲取值。
源碼如下:
put操作:
put操作就做了一些改動了,也是比較複雜的,它的流程如下:
- 根據 key 計算出 hashcode 找到下標索引
- 判斷是否需要進行初始化。
- 如果定位出的Node爲null,就利用 CAS 嘗試寫入,無條件自旋保證成功。
- 判斷是否在進行擴容(別的線程在擴容,就幫助去擴容)
- 如果都不滿足,那就利用 synchronized 鎖進行寫入數據(分爲鏈表和紅黑樹兩種方式)。
- 最後插入後,如果數量大於 TREEIFY_THRESHOLD 則要轉換爲紅黑樹。
源碼如下:
final V putVal(K key, V value, boolean onlyIfAbsent) {
//Key和value都不爲空
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode()); //得到hash值
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0) //進行初始化整個Map
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//元素定位出的Node位置爲null,表示可直接寫入,使用CAS自旋直到寫入成功
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
//看是否在進行擴容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
//在桶或紅黑樹中插入數據,使用synchronized鎖住要插入的位置節點
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
//鏈表形式插入
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;
}
}
}
//紅黑樹形式插入
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;
}
}
}
}
//插入後看鏈表是否需要樹化
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
JDK1.7到1.8對ConcurrentHashMap有着很大的修改,最大的還是將原本的segment分段鎖改爲了CAS和synchronized(空節點插入用CAS,有節點插入用synchronized),因爲在1.6以後,synchronized進行優化後,不像之前那樣 “ 笨重 ” 而是加上了偏向鎖,輕量級鎖,重量級鎖 之間的一個鎖膨脹的過程,所以1.8優化後不僅保證了高效率下的線程安全,同時解決了1.7中查詢效率慢的問題,改爲紅黑樹後,查詢效率大大提高。
綜合而言,1.8的ConcurrentHashMap已經很接近HashMap了,只是增加了併發控制,所以在理解了HashMap的設計後再理解ConcurrentHashMap,就比較容易了。
11.ConcurrentHashMap 1.7和1.8版本的異同
相同點:
- 讀操作都沒加鎖,使用volatile保證了可見性
- 讀寫分離,無論是對Segment加鎖還是對Node加鎖,只是對一部分數據加鎖,多線程對於不同的Segment和Node都可以併發執行。
- 使用fail-safe迭代器,創建迭代器後可對元素進行更新
不同點:
- JDK1.7 使用數組加鏈表,1.8使用數組+鏈表/紅黑樹
- JDK1.7 使用分段鎖機制,基於ReentrantLock實現,JDK1.8基於CAS和synchronized實現
嘮嘮叨叨:
在理解HashMap的基礎上再來理解HashTable和ConcurrentHashMap就會容易很多,也要理解1.7和1.8的異同點以及各自的put方法都是重點,還有很多細節點都需要自己一點點去看源碼理解。文章如果有什麼問題歡迎留言指正,另外如果對你有幫助也歡迎小夥伴們點贊關注一起進步!