以最簡單的方式講HashMap
HashMap可以說是面試中最常出現的名詞,這次頭條的一面,第一個問的問題就是HashMap。所以就讓我們來探討下HashMap吧。
實驗環境:JDK1.8
首先先說一下,和JDK1.7相比,對HashMap做了一些優化,使得HashMap的性能更加的優化。
-
HashMap的儲存結構
-
HashMap中的Hash
-
HashMap是怎么保存數據的
-
HashMap的擴容操作
-
HashMap的線程安全問題
HashMap的儲存結構
只有當我們知道HashMap的儲存結構時,我們才能夠明白HashMap的工作原理。
jdk1.7的存儲結構
在JDK1.7中,HashMap采用的是數組【位桶】+單鏈表
的數據結構

圖片來自這里
jdk1.8的儲存結構
在JDK1.8中,與JDK1.7最不相同的地方就是,采用了紅黑樹進行儲存,采用的是數組【位桶】+鏈表+紅黑樹
,當鏈表的長度超過某一閥值時,就會將鏈表轉換為紅黑樹,這個閥值可以自己設置,默認是8。

圖片來自這里
Hash
首先先說HashMap中的hash。當我們使用HashMap中的put(k,v)
時,
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
首先我們要根據key
算出key的hash值。
- JDK1.8
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
這個hash值不僅僅是通過Object中的hashCode的得到的,還需要進行右移和^位異或。
HashMap保存數據
總所周知,HashMap默認的容量大小是16,那么當我們儲存一個值時,是怎么判斷儲存的位置呢?
首先我們需要明白幾個參數。在使用HashMap的時候我們很可能會使用以下的構造參數:
public HashMap(int initialCapacity, float loadFactor) ;
- initialCapacity:初始化容量默認是16
- capacity:容量,通過initCapacity計算出一個大於或者等於initCapacity且為2的冪的值
- loadFactor:裝載因子,默認是0.75,根據它來確定需要擴容的閥值。
- threshold:閥值,capacity*loadFactor即為閥值。
-
未產生hash沖突
// n是HashMap的大小,Hash為key的hash值,tab為如下圖中的table,i代表儲存的位置 int i; // 為null代表此位置為空的 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null);
例如:當某一hash值與(n-1)相與的結果是3,那么就將這個這個table的第3號的位置。 -
產生hash沖突
但是如果當我們得到的
hash值
一樣或者說相與
的結果的table位置已經存在一個值了,那么我們應該怎么去儲存呢?-
當key與table[i]的所有key進行equals比較,如果相同則直接更新覆蓋value。
-
假如key進行equals比較不相同,則進行元素的插入操作(在jdk1.7中是鏈表的插入,在jdk1.8中既有鏈表的插入操作也有紅黑樹的操作)。
-
HashMap保存數據的JKD1.8源代碼看源代碼能夠更好的理解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;
// 假如table是空的或者說長度為0,則進行擴容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 假如桶中的元素是空的,則直接將元素放在桶中【使用(n - 1) & hash]判斷放的位置】
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 假如桶中已經存在這個元素
else {
Node<K,V> e; K k;
// 假如桶中的第一個元素p的hash值,key與要存的值相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;// 使用e來記錄p
// TreeNode 代表紅黑樹節點
// 假如key不相等,則將元素放入紅黑樹節點中
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 假如p為鏈表節點
else {
// 進行鏈表查找
for (int binCount = 0; ; ++binCount) {
// 假如next為空【代表達到鏈表末尾】
if ((e = p.next) == null) {
// 在末尾插入新的節點
p.next = newNode(hash, key, value, null);
// 如果鏈表長度達到閥值,則轉化為紅黑樹
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
// 插入元素后跳出循環
break;
}
// 在鏈表中也會遇到key一樣的元素,則時候就跳出循環
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 此時e為鏈表中key相等的元素
break;
p = e;
}
}
// e不為nul,代表要相同的元素
if (e != null) { // existing mapping for key
V oldValue = e.value;
// 如果onlyIfAbsent為false或者舊值為空,則進行更新
// 在源碼中onlyIfAbsent默認是false
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 回調以允許LinkedHashMap事后操作
afterNodeAccess(e);
// 返回舊值
return oldValue;
}
}
// modeCount代表HashMap在結構上面被修改的次數
++modCount;
// 加入大小大於閥值則進行擴容
if (++size > threshold)
resize();
// 回調以允許LinkedHashMap事后操作
afterNodeInsertion(evict);
return null;
}
HashMap的擴容操作
在HashMap中進行擴容操作是特別耗費時間的,因為隨着擴容,會重新進行一次hash分配,遍歷hash表中的所有元素,因為桶的大小【也就是數組長度n】變了,那么(n - 1) & hash
的值也會發生改變,所以我們在編寫程序時應該盡量避免resize,盡量在新建HashMap對象的時候指令桶的長度【阿里巴巴開發手冊也是這樣推薦使用】。
HashMap進行擴容時,會完全新建一個桶,我們從上面了解到桶就是數組,而數組是沒辦法自動擴容的,所以我們需要用一個新的數組來代替前面的桶。而當HashMap進行擴容是,閥值
會變成原來的兩倍
,容量
也會變成原來的兩倍
首先我們先講講JDK1.7中的resize(),JDK1.8有紅黑樹,還是有點麻煩。
- JDK1.7 的rezise()
void resize(int newCapacity) { //傳入新的容量
//table為擴容前的Entry數組
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
// 如果擴容前的數組大小如果已經達到最大(2^30)
if (oldCapacity == MAXIMUM_CAPACITY) {
//修改閾值為int的最大值(2^31-1),這樣以后就不會擴容了
threshold = Integer.MAX_VALUE;
return;
}
// 新建一個Entry數組
Entry[] newTable = new Entry[newCapacity];
//將數據轉移到新的Entry數組里
transfer(newTable);
// 修改table的指向對象
table = newTable;
threshold = (int) (newCapacity * loadFactor);//修改閾值
}
void transfer(Entry[] newTable) {
Entry[] src = table; //src引用了舊的Entry數組
int newCapacity = newTable.length;
// 遍歷舊的Entry數組
for (int j = 0; j < src.length; j++) {
Entry<K, V> e = src[j];
// 如果此位置存在元素
if (e != null) {
// for循環過后,舊的Entry數組就不再引用任何對象
src[j] = null;
// 遍歷鏈表
do {
// 獲得鏈表中的下一個元素
Entry<K, V> next = e.next;
// 重新計算數據保存位置
int i = indexFor(e.hash, newCapacity);
// 在jdk1.7中是頭部插入,此時e.next指向新的數組位置newTable[i]
e.next = newTable[i];
// 將newTable指向e
newTable[i] = e;
// 訪問下一個Entry鏈上的元素
e = next;
} while (e != null);
}
}
}
static int indexFor(int h, int length) {
return h & (length - 1);
}
- JDK1.8 的rezise()
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
// 獲得table的大小,並將其長度賦值給oldCap
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 閥值賦值
int oldThr = threshold;
int newCap, newThr = 0;
// 如果table不為空
if (oldCap > 0) {
// 數組大小大於(2^30)
if (oldCap >= MAXIMUM_CAPACITY) {
// 修改閾值為int的最大值(2^31-1),這樣以后就不會擴容了
threshold = Integer.MAX_VALUE;
return oldTab;
}
// newCap = oldCap << 1新的容量為以前的兩倍
// 當新的table長度沒有超過最導致,且以前的table長度大於16,則進行閥值更新
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 閥值擴大成兩倍
newThr = oldThr << 1; // double threshold
}
// 如果table為空,且閥值大於0
else if (oldThr > 0) // initial capacity was placed in threshold
// 則新的容量大小為閥值
newCap = oldThr;
// 假如table為空切閥值小於等於0,則初始化閥值,和table
else { // zero initial threshold signifies using defaults
// 新的table長度為16
newCap = DEFAULT_INITIAL_CAPACITY;
// 新的閥值為負載因子【0.75】*16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
/* *以上都是進行初始化操作,目的是擴大容量,或則初始化HashMap *下面便是重新存放元素操作 */
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 假如oldTab[j]中含有元素
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 假如沒有下一個元素,也就是oldTab[j]中只有e一個元素
if (e.next == null)
// 重新選擇空間
newTab[e.hash & (newCap - 1)] = e;
// 假如有下一個元素,且該節點為紅黑樹節點
else if (e instanceof TreeNode)
// 將該節點進行rehash后,放到新的地方
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
/** * 在JDK1.8中不像JDK1.7一樣重新進行hash值計算,而是利用了一個規律: * 假如e.hash & oldCap為0,那么該元素的引索位置沒有變 * 假如e.hash & oldCap為1,那么該元素的引索位置為原引索+oldCap */
// 假如有下一個元素,但該節點為鏈表節點
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
HashMap的線程安全問題
相信很多人都聽說過HashMap線程不安全,但是HashMap為什么會產生線程安全問題呢?
- 多線程put()操作
設想一個場景,A線程正在進行put操作,它經過hash計算,以及鏈表查找,已經確定了put的位置X
,但是這時候cpu時間片到了,A線程不得不退出put操作的執行,這時候B線程獲得了cpu時間片,在X
的位置進行插入值,如果A線程再執行put操作就會覆蓋以前的值,此時數據就不一致了。
- 多線程resize()操作
當多個線程進行resize()操作時,假如table已經變成新數組,那么下一個線程會使用已經被賦值過得的table做為初始值進行操作。這樣可能就會出現死循環的操作。
至於怎么避免HashMap的多線程安全問題,ConcurrentHashMap是一個好東西,至於它是怎么解決並發的問題,我們下次再聊。
HashMap其實並不是很難,我們主要是要理解它儲存元素的思想與方法。而通過源代碼,我們能夠更好的理解設計的理念