作為Javaer,對于Map這個單詞絕對不會陌生,無論是開發過程中還是出去面試的時候,都會經常遇到,而最頻繁使用和面試提問的無非這么幾個,HashMap, HashTable, ConcurrentHashMap。那么本文就針對這幾個知識點做一個歸納和總結。
從HashMap說起
HashMap是上面提到的幾個Map中使用頻率最高的了,畢竟需要考慮到多線程并發的場景并不算太多。下面是Map的一個關系圖,大家了解一下即可。
HashMap在Java8之前和之后有很大差別,在Java8以前,它的數據結構是數組+鏈表的形式,8以后就變成了數組+鏈表+紅黑樹的結構。它的key是保存在一個Set里面的,也就是有去重的功能,values是存在一個Collections里面。
HashMap里的數組每個元素存放的是key-value形式的實例,Java7里面叫做Entry,8里面叫Node。這個Node里面包含了hash值,鍵值對,下一個節點next這幾個屬性組成。數組被分為一個個bucket,也就是桶,通過hash值決定了鍵值對在這個數組中的尋址,hash值相同的則以鏈表的形式存儲,鏈表長度超過閾值就轉成紅黑樹。那么先來看看HashMap的put操作,
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//為空則初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 算出鍵值對在table中的具體位置,沒有就new一個node
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// 如果存在
Node<K,V> e; K k;
//一樣就替換
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//樹化了就用樹的形式保存
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//鏈表的形式插入元素
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 存在就更新
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//超過閾值就擴容
if (++size > threshold)
resize();
// 這是為了繼承HashMap的LinkedHashMap類服務的,用來回調移除最早放入Map的對象
afterNodeInsertion(evict);
return null;
}
那么總結一下就是:
- 若HashMap未被初始化,則進行初始化操作
- 對Key求Hash值,依據Hash值計算下標
- 若未發生碰撞,則直接放入桶中
- 若發生碰撞,則以鏈表的方式鏈接到后面
- 若鏈表長度超過閾值,且HashMap元素超過最低樹化容量,則將鏈表轉成紅黑樹
- 若節點已經存在,則用新值替換舊值
- 若桶滿了,就需要resize(擴容2倍后重排)
這個put的操作引申出幾個知識點,首先,
HashMap的初始容量是多少?為什么設置成這個值呢?
翻看源碼我們可以看到有這么一個變量DEFAULT_INITIAL_CAPACITY,
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
這個就是HashMap的初始容量,也就是16,為啥用位運算這么騷的寫法是因為位運算比算數計算的效率要高。那么為啥用16?看看上面說的下標計算的公式: index = HashCode(Key) & (Length- 1),當長度為16時候,Length-1的二進制就是1111,是一個所有位都為1的數,而且看上述注釋,建議的HashMap的初始長度都是2的冪次方,這種情況下,index的結果等同于HashCode后幾位的值。那么只要輸入的HashCode本身分布均勻,Hash算法的結果就是均勻的。
另一個問題,Java8里面引入了紅黑樹,當鏈表達到一定長度的時候會轉換成紅黑樹,引入紅黑樹的好處是什么?這個變換的閾值是多少,為什么是這個值?
當元素put的時候,首先是要根據哈希函數和長度計算下標的,但即使哈希函數取得再好,也很難達到元素百分百均勻分布,那么就有可能導致 HashMap 中有大量的元素都存放到同一個桶中時,這個桶下有一條長長的鏈表,這個時候 HashMap 就相當于一個單鏈表,假如單鏈表有 n 個元素,遍歷的時間復雜度就是 O(n),完全失去了它的優勢。
引入紅黑樹后,但鏈表長度大于8時,就會轉換成紅黑樹,若鏈表元素個數小于等于6時,樹結構還原成鏈表。至于為什么是8,我看到過兩個說法,一個是因為紅黑樹的平均查找長度是log(n),長度為8的時候,平均查找長度為3,如果繼續使用鏈表,平均查找長度為8/2=4,這才有轉換為樹的必要。鏈表長度如果是小于等于6,6/2=3,雖然速度也很快的,但是轉化為樹結構和生成樹的時間并不會太短。另一個說法是根據泊松分布,在負載因子默認為0.75的時候,單個hash槽內元素個數為8的概率小于百萬分之一,所以將7作為一個分水嶺,等于7的時候不轉換,大于等于8的時候才進行轉換,小于等于6的時候就化為鏈表。兩種都有道理我覺得哪一種都是可以的。
當桶滿了的時候,HashMap會進行擴容resize,它是何時并且如何擴容的呢?
當桶的容量達到長度乘以負載因子的時候就會進行擴容,默認的負載因子為0.75。
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
首先,它會創建一個新的Entry空數組,長度是原數組的2倍。然后遍歷原Entry數組,把所有的Entry重新Hash到新數組。這里要進行ReHash的原因是我們知道下標的計算是跟長度有關的,長度不一樣了,那么index計算的結果自然也不一樣,因此需要重新Hash到新數組,rehash是一個比較耗時的過程。
接下來還是插入相關的問題,新的Entry節點在插入鏈表的時候,是怎么插入的?
這個問題我是在一篇博客上看到的,之前的確從未考慮過這個問題。Java8之前是頭插法,就是說新來的值會取代原有的值,原有的值就順推到鏈表中去,就像上面的例子一樣,因為寫這個代碼的作者認為后來的值被查找的可能性更大一點,提升查找的效率,在Java8之后,都是所用尾部插入了。 由于在擴容的時候會存在條件競爭,如果兩個線程都發現HashMap需要重新調整大小了,它們會同時試著調整大小。用頭插法的話,假設原來鏈表是A指向B指向C,新的鏈表可能出現B指向A但A同時也指向B。用尾插的方法擴容保持鏈表元素原油的順序,就不會出現這種鏈表成環的問題了。
put的時候會先判斷是否碰撞,那么如何減少碰撞呢?
一般有兩個方法,一個是使用擾動函數,讓不同對象返回不同hashcode;一個是使用final對象,防止鍵值改變,并采用合適的equeals方法和hashCode方法,減少碰撞的發生。
那么對于get方法因為比較簡單就不做太多詳細解釋,其實就是根據key的hashcode算出元素在數組中的下標,之后遍歷Entry對象鏈表,直到找到元素為止。
SynchronizedMap
這里額外再提一個Map,也是解決HashMap多線程安全的一種方案。那就是Collections.synchronizedMap(Map)。它會返回一個線程安全的SynchronizedMap的實例。它里面維護了一個排斥鎖mutex。對于里面的public方法,使用了synchronized對mutex進行加鎖。多線程環境下串行化執行,效率低下。
上面就是一些關于HashMap的一些簡單的知識點,我這里整理的其實也不算太多但還是很實用的(我就知道這么多)。
HashTable
關于HashTable其實說不了太多,因為說實話反正我是從來沒用過。都知道它線程安全,但它用的手段很簡單粗暴。涉及到修改的地方使用了synchronized修飾,以串行化方式運行,效率比較低下。它和上面說的SynchronizedMap實現線程安全的方式很接近,只是鎖的對象不一樣。
ConcurrentHashMap
那么還是來談談另一個還挺常見的ConcurrentHashMap,它現在的數據結構和原來的也是不一樣的,早期也是數組+鏈表,現在是數組+鏈表+紅黑樹。
在Java8以前,由Segment數組、HashEntry組成,通過分段鎖Segment來實現線程安全,ConcurrentHashMap內部維護了Segment內部類,繼承了RetrantLock。它將鎖一段一段的存儲,給每一段數據分配一個鎖,也就是segment,當一個線程訪問一個鎖時,其他線程也可以訪問其他segment的數據,不會被阻塞,默認分配16個segment。也就是理論上它的效率比HashTable提高了16倍。而HashEntry跟HashMap差不多,只是它用volatile修飾了數據的value還有下一個節點next。
到了Java8,它就不再是使用Segment分段鎖,而是使用了CAS+synchronized來保證線程安全。
synchronized鎖住當前鏈表或者紅黑樹的首節點,這樣只要哈希不沖突,就不會出現并發問題。
/**
* Table initialization and resizing control. When negative, the
* table is being initialized or resized: -1 for initialization,
* else -(1 + the number of active resizing threads). Otherwise,
* when table is null, holds the initial table size to use upon
* creation, or 0 for default. After initialization, holds the
* next element count value upon which to resize the table.
*/
private transient volatile int sizeCtl;
ConcurrentHashMap和HashMap的參數差不多,但有些特有的,比如sizeCtl。它是哈希表初始化或擴容時的一個控制位標識量,負數代表正在初始化或正在擴容操作。同樣的,我們也看看它的put操作。
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 鍵值都不能為null
if (key == null || value == null) throw new NullPointerException();
//計算key的hash值
int hash = spread(key.hashCode());
int binCount = 0;
// 數組元素的更新,使用CAS,所以需要不斷失敗重試
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
// 初始化
tab = initTable();
//找到f,即鏈表或者紅黑樹的頭節點,沒有就添加
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//CAS添加,失敗break
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 {
//發生hash碰撞,鎖定鏈表或者紅黑樹的頭節點f
V oldVal = null;
synchronized (f) {
// 判斷f是否時鏈表的頭節點
// fh就是頭節點的hash值
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) {
//鏈表長度達到了8,則轉換成樹結構
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
// ConcurrentHashMap的size+1
addCount(1L, binCount);
return null;
}
上面是整段代碼的解釋,總結一下就下面幾個步驟:
- 判斷Node[]數組是否初始化,沒有則進行初始化操作
- 通過hash定位數組的索引坐標,是否有Node節點,如果沒有則使用CAS進行添加(鏈表的頭節點),添加失敗則進入下次循環
- 檢查到內部正在擴容,就幫助它一塊擴容
- 如果頭節點f!=null,則使用synchronized鎖住f元素(鏈表/紅黑二叉樹的頭元素)
- 如果是Node(鏈表結構)則進行鏈表的添加操作
- 如果是TreeNode結構則執行樹添加操作
- 判斷鏈表長度已經達到臨界值8,這個8可以自己調整,當節點數超過這個值就把鏈表轉換為樹結構
使用這種方式相對于Segment而言,鎖拆的更細。首先使用無鎖操作CAS插入節點,失敗則循環重試。若頭節點存在,則嘗試獲取頭節點的同步鎖再進行操作。至于get操作也比較簡單,也是根據hashcode尋址,如果就在桶上就直接返回值,不是的話就按照鏈表或者紅黑樹的方式遍歷獲取值。
HashMap、HashTable以及ConcurrentHashMap的區別
大致講述了他們三個的基礎知識,那么來總結下它們區別。這里做了個list大家可以看看。
- HashMap線程不安全,數組+鏈表+紅黑樹
- HashTable線程安全,鎖住整個對象,數組+鏈表
- ConcurrentHashMap線程安全,CAS+同步鎖,數組+鏈表+紅黑樹
- HashMap的key,value均可為null,其他兩個不可以
- HashTable使用的是安全失敗機制(fail-safe),這種機制會使你此次讀到的數據不一定是最新的數據。如果你使用null值,就會使得其無法判斷對應的key是不存在還是為空,因為你無法再調用一次contain(key)來對key是否存在進行判斷,ConcurrentHashMap同理
- HashMap 的初始容量為:16,Hashtable 初始容量為:11,兩者的負載因子默認都是:0.75。
- 當現有容量大于總容量 * 負載因子時,HashMap 擴容規則為當前容量翻倍,Hashtable 擴容規則為當前容量翻倍 + 1。
- HashMap 中的 Iterator 迭代器是 fail-fast 的,而 Hashtable 的 Enumerator 不是 fail-fast 的。
- 快速失敗(fail—fast)是java集合中的一種機制, 在用迭代器遍歷一個集合對象時,如果遍歷過程中對集合對象的內容進行了修改(增加、刪除、修改),則會拋出Concurrent Modification Exception。
以上就是關于Map相關的一些知識點,里面很多引申的知識點我都沒有再往深里說,比如里面使用到的紅黑樹數據結構,volatile關鍵字,CAS等等,這個在后面會針對相應的知識點再繼續梳理。