原文鏈接:https://mp.weixin.qq.com/s?__biz=MzAwNDcyODk5NQ==&mid=2647598226&idx=1&sn=a31576ea0f6e4d5169b3b7acba15b557&chksm=831cf55eb46b7c48a5956dde2930a97fc079ee1c8c14f142c80f1029fb61ae5628abe226d53b&scene=21#wechat_redirect
原作者:馮鼕鼕
現在的面試當中凡是那些大廠,基本上都會問到一些關於HashMap的問題了,而且這個集合在開發中也經常會使用到。於是花費了大量的時間去研究分析寫了這篇文章。本文是基於jdk1.8來分析的。篇幅較長,但是都是循序漸進的。耐心讀完相信你會有所收穫。
一、帶着問題分析
這篇文章,希望能解決以下問題。
(1)HashMap的底層數據結構是什麼?
(2)HashMap中增刪改查操作的底部實現原理是什麼?
(3)HashMap是如何實現擴容的?
(4)HashMap是如何解決hash衝突的?
(7)HashMap爲什麼是非線程安全的?
下面我們就帶着這些問題,揭開HashMap的面紗。
二、認識HashMap
HashMap最早是在jdk1.2中開始出現的,一直到jdk1.7一直沒有太大的變化。但是到了jdk1.8突然進行了一個很大的改動。其中一個最顯著的改動就是:
之前jdk1.7的存儲結構是數組+鏈表,到了jdk1.8變成了數組+鏈表+紅黑樹。
另外,HashMap是非線程安全的,也就是說在多個線程同時對HashMap中的某個元素進行增刪改操作的時候,是不能保證數據的一致性的。
下面我們就開始一步一步的分析。
三、深入分析HashMap
1、底層數據結構
爲了進行一個對比分析,我們先給出一個jdk1.7的存儲結構圖
從上圖我們可以看到,在jdk1.7中,首先是把元素放在一個個數組裏面,後來存放的數據元素越來越多,於是就出現了鏈表,對於數組中的每一個元素,都可以有一條鏈表來存儲元素。這就是有名的“拉鍊式”存儲方法。
就這樣用了幾年,後來存儲的元素越來越多,鏈表也越來越長,在查找一個元素時候效率不僅沒有提高(鏈表不適合查找,適合增刪),反倒是下降了不少,於是就對這條鏈表進行了一個改進。如何改進呢?就是把這條鏈表變成一個適合查找的樹形結構,沒錯就是紅黑樹。於是HashMap的存儲數據結構就變成了下面的這種。
我們會發現優化的部分就是把鏈表結構變成了紅黑樹。原來jdk1.7的優點是增刪效率高,於是在jdk1.8的時候,不僅僅增刪效率高,而且查找效率也提升了。
注意:不是說變成了紅黑樹效率就一定提高了,只有在鏈表的長度不小於8,而且數組的長度不小於64的時候纔會將鏈表轉化爲紅黑樹,
問題一:什麼是紅黑樹呢?
紅黑樹是一個自平衡的二叉查找樹,也就是說紅黑樹的查找效率是非常的高,查找效率會從鏈表的o(n)降低爲o(logn)。如果之前沒有了解過紅黑樹的話,也沒關係,你就記住紅黑樹的查找效率很高就OK了。
問題二:爲什麼不一下子把整個鏈表變爲紅黑樹呢?
這個問題的意思是這樣的,就是說我們爲什麼非要等到鏈表的長度大於等於8的時候,才轉變成紅黑樹?在這裏可以從兩方面來解釋
(1)構造紅黑樹要比構造鏈表複雜,在鏈表的節點不多的時候,從整體的性能看來, 數組+鏈表+紅黑樹的結構可能不一定比數組+鏈表的結構性能高。就好比殺雞焉用牛刀的意思。
(2)HashMap頻繁的擴容,會造成底部紅黑樹不斷的進行拆分和重組,這是非常耗時的。因此,也就是鏈表長度比較長的時候轉變成紅黑樹纔會顯著提高效率。
OK,到這裏相信我們對hashMap的底層數據結構有了一個認識。現在帶着上面的結構圖,看一下如何存儲一個元素。
2、存儲元素
我們在存儲一個元素的時候,大多是使用下面的這種方式。
public class Test {
public static void main(String[] args) {
HashMap<String, Integer> map= new HashMap<>();
//存儲一個元素
map.put("張三", 20);
}
}
在這裏HashMap,第一個參數是鍵,第二個參數是值,合起來叫做鍵值對。存儲的時候只需要調用put方法即可。那底層的實現原理是怎麼樣的呢?這裏還是先給出一個流程圖
上面這個流程,不知道你能否看到,紅色字跡的是三個判斷框,也是轉折點,我們使用文字來梳理一下這個流程:
(1)第一步:調用put方法傳入鍵值對
(2)第二步:使用hash算法計算hash值
(3)第三步:根據hash值確定存放的位置,判斷是否和其他鍵值對位置發生了衝突
(4)第四步:若沒有發生衝突,直接存放在數組中即可
(5)第五步:若發生了衝突,還要判斷此時的數據結構是什麼?
(6)第六步:若此時的數據結構是紅黑樹,那就直接插入紅黑樹中
(7)第七步:若此時的數據結構是鏈表,判斷插入之後是否大於等於8
(8)第八步:插入之後大於8了,就要先調整爲紅黑樹,在插入
(9)第九步:插入之後不大於8,那麼就直接插入到鏈表尾部即可。
上面就是插入數據的整個流程,光看流程還不行,我們還需要深入到源碼中去看看底部是如何按照這個流程寫代碼的。
鼠標聚焦在put方法上面,按一下F3,我們就能進入put的源碼。來看一下:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
也就是說,put方法其實調用的是putVal方法。putVal方法有5個參數:
(1)第一個參數hash:調用了hash方法計算hash值
(2)第二個參數key:就是我們傳入的key值,也就是例子中的張三
(3)第三個參數value:就是我們傳入的value值,也就是例子中的20
(4)第四個參數onlyIfAbsent:也就是當鍵相同時,不修改已存在的值
(5)第五個參數evict :如果爲false,那麼數組就處於創建模式中,所以一般爲true。
知道了這5個參數的含義,我們就進入到這個putVal方法中。
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;
//第二部分
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();
afterNodeInsertion(evict);
return null;
}
乍一看,這代碼完全沒有讀下去的慾望,第一次看的時候真實噁心到想吐,但是結合上一開始畫的流程圖再來分析,相信就會好很多。我們把代碼進行拆分(整體分了四大部分):
(1)Node[] tab中tab表示的就是數組。Nodep中p表示的就是當前插入的節點
(2)第一部分:
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
這一部分表示的意思是如果數組是空的,那麼就通過resize方法來創建一個新的數組。在這裏resize方法先不說明,在下一小節擴容的時候會提到。
(3)第二部分:
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
i表示在數組中插入的位置,計算的方式爲(n - 1) & hash。在這裏需要判斷插入的位置是否是衝突的,如果不衝突就直接newNode,插入到數組中即可,這就和流程圖中第一個判斷框對應了。
如果插入的hash值衝突了,那就轉到第三部分,處理衝突
(4)第三部分:
else {
Node<K,V> e; K k;
//第三部分a
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//第三部分b
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//第三部分c
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;
}
}
//第三部分d
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
我們會看到,處理衝突還真是麻煩,好在我們對這一部分又進行了劃分
a)第三部分第一小節:
if (p.hash == hash
&&((k = p.key) == key || (key != null && key.equals(k))))
e = p;
在這裏判斷table[i]中的元素是否與插入的key一樣,若相同那就直接使用插入的值p替換掉舊的值e。
b)第三部分第二小節:
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
判斷插入的數據結構是紅黑樹還是鏈表,在這裏表示如果是紅黑樹,那就直接putTreeVal到紅黑樹中。這就和流程圖裏面的第二個判斷框對應了。
c)第三部分第三小節:
//第三部分c
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;
}
}
如果數據結構是鏈表,首先要遍歷table數組是否存在,如果不存在直接newNode(hash, key, value, null)。如果存在了直接使用新的value替換掉舊的。
注意一點:不存在並且在鏈表末尾插入元素的時候,會判斷binCount >= TREEIFY_THRESHOLD - 1。也就是判斷當前鏈表的長度是否大於閾值8,如果大於那就會把當前鏈表轉變成紅黑樹,方法是treeifyBin。這也就和流程圖中第三個判斷框對應了。
(5)第四部分:
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
3、擴容
這個擴容就比較簡單了,HaspMap擴容就是就是先計算 新的hash表容量和新的容量閥值,然後初始化一個新的hash表,將舊的鍵值對重新映射在新的hash表裏。如果在舊的hash表裏涉及到紅黑樹,那麼在映射到新的hash表中還涉及到紅黑樹的拆分。整個流程也符合我們正常擴容一個容量的過程,我們根據流程圖結合代碼來分析:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
//第一部分:擴容
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//第二部分:設置閾值
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
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;
@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;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
//只有一個節點,通過索引位置直接映射
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
//如果是紅黑樹,需要進行樹拆分然後映射
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
//如果是多個節點的鏈表,將原鏈表拆分爲兩個鏈表
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);
//鏈表1存於原索引
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//鏈表2存於原索引加上原hash桶長度的偏移量
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
這代碼量同樣讓人噁心,不過我們還是分段來分析:
(1)第一部分:
//第一部分:擴容
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
根據代碼也能看明白:首先如果超過了數組的最大容量,那麼就直接將閾值設置爲整數最大值,然後如果沒有超過,那就擴容爲原來的2倍,這裏要注意是oldThr << 1,移位操作來實現的。
(2)第二部分:
//第二部分:設置閾值
else if (oldThr > 0) //閾值已經初始化了,就直接使用
newCap = oldThr;
else { // 沒有初始化閾值那就初始化一個默認的容量和閾值
newCap = DEFAULT_INITIAL_CAPACITY;
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;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
首先第一個else if表示如果閾值已經初始化過了,那就直接使用舊的閾值。然後第二個else表示如果沒有初始化,那就初始化一個新的數組容量和新的閾值。
(3)第三部分
第三部分同樣也很複雜,就是把舊數據複製到新數組裏面。這裏面需要注意的有下面幾種情況:
A:擴容後,若hash值新增參與運算的位=0,那麼元素在擴容後的位置=原始位置
B:擴容後,若hash值新增參與運算的位=1,那麼元素在擴容後的位置=原始位置+擴容後的舊位置。
hash值新增參與運算的位是什麼呢?我們把hash值轉變成二進制數字,新增參與運算的位就是倒數第五位。
這裏面有一個非常好的設計理念,擴容後長度爲原hash表的2倍,於是把hash表分爲兩半,分爲低位和高位,如果能把原鏈表的鍵值對, 一半放在低位,一半放在高位,而且是通過e.hash & oldCap == 0來判斷,這個判斷有什麼優點呢?
舉個例子:n = 16,二進制爲10000,第5位爲1,e.hash & oldCap 是否等於0就取決於e.hash第5 位是0還是1,這就相當於有50%的概率放在新hash表低位,50%的概率放在新hash表高位。
OK,到這一步基本上就算是把擴容這一部分講完了,還有一個問題沒有解決,也就是說存儲的原理講明白了,存儲的元素多瞭如何擴容也明白了,擴容之後出現了地址衝突怎麼辦呢?
4、解決hash衝突
解決地址衝突的前提是計算的hash值出現了重複,我們就先來看看HashMap中,是如何計算hash值的。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
代碼是超級簡單,hash值其實就是通過hashcode與16異或計算的來的,爲什麼要使用異或運算呢?畫一張圖你就明白了:
也就是說,通過異或運算能夠是的計算出來的hash比較均勻,不容易出現衝突。但是偏偏出現了衝突現象,這時候該如何去解決呢?
在數據結構中,我們處理hash衝突常使用的方法有:開發定址法、再哈希法、鏈地址法、建立公共溢出區。而hashMap中處理hash衝突的方法就是鏈地址法。
這種方法的基本思想是將所有哈希地址爲i的元素構成一個稱爲同義詞鏈的單鏈表,並將單鏈表的頭指針存在哈希表的第i個單元中,因而查找、插入和刪除主要在同義詞鏈中進行。鏈地址法適用於經常進行插入和刪除的情況。
相信大家都能看明白,出現地址衝突的時候,一個接一個排成一條鏈就OK了。正好與HashMap底層的數據結構相呼應。
5、構造一個HashMap
上面可能出現的問題,我們都已經說明了,關於他的構造方法卻姍姍來遲。下面我們好好說一下他的構造方法:
他的構造方法一共有四個:
第一個:
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
第二個:
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
第三個:
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
第四個:
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
這四個構造方法很明顯第四個最麻煩,我們就來分析一下第四個構造方法,其他三個自然而然也就明白了。上面出現了兩個新的名詞:loadFactor和initialCapacity。我們一個一個來分析:
(1)initialCapacity初始容量
官方要求我們要輸入一個2的N次冪的值,比如說2、4、8、16等等這些,但是我們忽然一個不小心,輸入了一個20怎麼辦?沒關係,虛擬機會根據你輸入的值,找一個離20最近的2的N次冪的值,比如說16離他最近,就取16爲初始容量。
(2)loadFactor負載因子
負載因子,默認值是0.75。負載因子表示一個散列表的空間的使用程度,有這樣一個公式:initailCapacity*loadFactor=HashMap的容量。
所以負載因子越大則散列表的裝填程度越高,也就是能容納更多的元素,元素多了,鏈表大了,所以此時索引效率就會降低。反之,負載因子越小則鏈表中的數據量就越稀疏,此時會對空間造成爛費,但是此時索引效率高。
爲什麼默認值會是0.75呢?我們截取一段jdk文檔:
英語不好的人看的我真是一臉懵逼,不過好在大概意思還能明白。看第三行Poisson_distribution這不就是泊淞分佈嘛。而且最關鍵的就是
當桶中元素到達8個的時候,概率已經變得非常小,也就是說用0.75作爲加載因子,每個碰撞位置的鏈表長度超過8個是幾乎不可能的。當桶中元素到達8個的時候,概率已經變得非常小,也就是說用0.75作爲加載因子,每個碰撞位置的鏈表長度超過8個是幾乎不可能的。
6、HashMap爲什麼是非線程安全的?
想要解決這個問題,答案很簡單,因爲源碼裏面方法全部都是非線程安全的呀,你根本找不到synchronized這樣的關鍵字。保證不了線程安全。於是出現了ConcurrentHashMap。
寫到這裏終於算是把一些核心的內容寫完了。當然HashMap裏面涉及到的面試題這些很多,不能能面面俱到。如有遺漏問題,會在今後補充。歡迎批評指正。