HashMap一直是Java面試官喜歡考察的題目,不管應聘者你處於哪一個級別,在多輪的技術面試中彷佛總有一次會被問到有關 HashMap 的問題。java
爲何在Java面試中必定會深刻考察HashMap?由於 HashMap 它的設計結構和原理的特色,它既能夠考初學者對 Java 集合的瞭解又能夠深度的發現應聘者的數據結構功底。node
圍繞着HashMap的問題,既能夠問的很淺可是又能夠深刻的聊的很細,聊到數據結構,甚至計算機底層。面試
咱們知道在 Jdk1.7 和 Jdk1.8(及之後)的版本中 HashMap 的內部實現有很大區別,因爲目前 Jdk1.8 是主流的一個版本,因此咱們在這裏只對 Jdk1.8的版本中HashMap 作個講解。算法
Jdk1.8 相較於 Jdk1.7 其實主要是在兩個方面作了一些優化,使得數據的存儲和查詢效率有了很好的提高。數組
由原來的數組+鏈表
的存儲結構變動爲數組+鏈表+紅黑數
的結構,從數據結構知識點中咱們知道鏈表的特色是:尋址(查詢)困難,插入和刪除容易。隨着存儲數據的增長,鏈表的長度會持續增加,查詢效率會愈來愈低,經過轉變成紅黑樹能夠提高查詢的效率。markdown
原來的 Jdk1.7 中經過對 key 值Hash取模
的方式定位 value 在數組中的下表位置而後存入對應下標中的鏈表中,查詢的時候經過一樣的方式獲取數據。在Jdk1.8 中對這塊作了一個優化,減小哈希碰撞率。數據結構
要了解 HashMap 咱們只須要重點關注它的3個API便可,分別是 put,get和 resize。接下來咱們跟蹤這3個方法的源碼分別進行詳細分析。app
咱們先來看下put方法作了些什麼,put方法的入參就兩個值:key和value,也就是咱們常用的,其源碼以下less
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
複製代碼
能夠看到具體實現不在這裏,裏面有個putVal的方法,全部邏輯處理都在putVal方法中。dom
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
//tab: 即table數組,n:數組的長度,i: 哈希取模後的下標,p: 數組下標i存儲的鏈表或者紅黑樹首節點,
Node<K,V>[] tab; Node<K,V> p; int n, i;
//table數組爲空或長度爲0,則調用resize方法初始化數組
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//若是哈希取模後對應的數組下標節點數據爲空,則新建立節點,當前k-v爲節點中第一條數據
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//哈希取模後對應下標節點不爲空時
else {
Node<K,V> e; K k;
//若是當前的k-v與首節點哈希值和key都相等,賦值p->e
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//當前節點爲紅黑樹,按照紅黑樹的方式添加k-v值
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {//到這一步,說明節點類型爲鏈表類型,循環遍歷鏈表,這裏只是添加新的而不處理同一個元素value的更新
for (int binCount = 0; ; ++binCount) {
//節點爲尾部節點,當前k-v做爲新節點並添加到鏈表尾部
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//當節點數>=8時,則鏈表轉紅黑樹(TREEIFY_THRESHOLD - 1 = 7,binCount從0開始)
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//當前遍歷到的節點e的哈希值和key與k-v的相等則退出循環,由於這裏只處理新增
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//當前節點e不爲尾結點,將e->p,繼續遍歷
p = e;
}
}
//處理更新操做,新值換舊值
if (e != null) { // existing mapping for key
V oldValue = e.value;
//onlyIfAbsent爲false或者舊值爲空時,賦新值value
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//空函數,能夠由用戶根據須要覆蓋回調
afterNodeAccess(e);
//返回舊值
return oldValue;
}
}
++modCount;
//若是當前map中包含的k-v鍵值數超過了閾值threshold則擴容
if (++size > threshold)
resize();
//空函數,能夠由用戶根據須要覆蓋回調
afterNodeInsertion(evict);
return null;
}
複製代碼
閱讀完putVal的源碼後,咱們獲得以下一些知識點:
(int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY)
即 16*0.75=12
。從putVal方法中能夠看到共有兩次調用resize()
,分別是初始化和擴容的時候。putVal方法有5個入參,第一個入參彷佛調用了一個hash方法傳參是key。咱們先看下這個 hash 方法。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
複製代碼
當key爲空時直接返回0,這個咱們能看懂。當key不爲空時,那一串是啥?幹啥的?它將key的hashCode值和hashCode值右移16位後進行異或運算。爲何要這樣運算呢?看着有點莫名其妙。
其實這裏就是 Jdk1.8 對尋址的優化,這樣作有什麼好處呢?
HashMap中是經過對 key 的哈希取模後的值定位到數組的下標位置的,可是hash(key) % length
的運算效率很低。在數學中hash(key) & (n - 1)
的結果是跟hash(key) % n
取模結果是同樣的,可是與運算的性能要比hash對n取模要高不少。所以在源碼中的tab[i = (n - 1) & hash]
就是對數組長度作哈希取模運算。
可是這裏哈希運算沒有直接用 key 的 hashCode 值,而是作了一個右移16位再異或的運算(h = key.hashCode()) ^ (h >>> 16)
,這樣作的目的又是什麼呢?
對象的 hashCode 是一個 int 類型的整數,假設 key 的 hashCode 值是 h=514287204853,將其轉爲二進制格式
二者進行異或運算,獲得哈希值hash,注意觀察hash值的特色
h右移16位意味着將高16的值放在了低16位上,高16位補0,這樣處理後再與h進行異或運算獲得一個運算後的hash值。
從結果中能夠得知,運算後的hash值和原來的hashCode值相比,高16位咱們能夠不關心,而低16位則是原來的高16位和低16的異或後的新值,這樣它就具有了原來高16位和低16的特徵。
將這樣的獲得的hash值再與(n-1)進行與運算,n即爲數組的長度,初始值是16,每次擴容的時候,都是2的倍數進行擴容,因此n的值必不會很大。它的高16位基本都爲0,只有低16位纔會有值。下面以 n=16 爲例講解。
因爲 (n-1) 的高16位都爲0,因此任何和它進行與運算的數據值,運算後的結果index的高16位都不會受影響必爲0,只有低16位的結果會受影響。這樣高16位至關於沒什麼做用。
這樣會形成什麼問題呢?若是兩個對象的hashCode值低16位相同而高16位不一樣,那麼它們運算後的結果必相同,從而致使哈希衝突,定位到了數組的同一個下標。
而經過右移16位異或運算後,至關因而將高16位和低16位進行了融合,運算結果的低16位具備了h的高16位和低16位的聯合特徵。這樣能夠下降哈希衝突從而在必定程度上保證了數據的均勻分佈。
看完 putVal 的源碼後,咱們瞭解到了存儲結構和哈希尋址的優化,可是還存在着一些疑惑沒有解開
爲何要鏈表和紅黑樹的轉換?
鏈表和紅黑樹的轉換是基於時間和空間的權衡,鏈表只有指向下一個節點的指針和數據值,而紅黑樹須要左右指針來分別指向左節點和右節點,TreeNodes 佔用空間是普通 Nodes 的兩倍,所以紅黑樹相較於鏈表須要更多的存儲空間,可是紅黑樹的查找效率要優於鏈表。
固然這些優點都是基於數據量的前提下的,只有當容器中的節點數量足夠多的時候纔會轉紅黑樹。數據量小的時候二者查詢效率不會相差不少,可是紅黑樹須要的存儲容量更多,所以須要設置一個轉換的閾值分別是8和6。
那爲何閾值分別就是8和6呢?
這個HashMap的設計者在源碼的註釋中給予說明了,其實不少的疑惑均可以從源碼的閱讀中獲得答案
/* Because TreeNodes are about twice the size of regular nodes, we * use them only when bins contain enough nodes to warrant use * (see TREEIFY_THRESHOLD). And when they become too small (due to * removal or resizing) they are converted back to plain bins. In * usages with well-distributed user hashCodes, tree bins are * rarely used. Ideally, under random hashCodes, the frequency of * nodes in bins follows a Poisson distribution * (http://en.wikipedia.org/wiki/Poisson_distribution) with a * parameter of about 0.5 on average for the default resizing * threshold of 0.75, although with a large variance because of * resizing granularity. Ignoring variance, the expected * occurrences of list size k are (exp(-0.5) * pow(0.5, k) / * factorial(k)). The first values are: * * 0: 0.60653066 * 1: 0.30326533 * 2: 0.07581633 * 3: 0.01263606 * 4: 0.00157952 * 5: 0.00015795 * 6: 0.00001316 * 7: 0.00000094 * 8: 0.00000006 * more: less than 1 in ten million */
複製代碼
當理想狀況下,即哈希值離散性很好、哈希碰撞率很低的時候,數據是均勻分佈在容器的各鏈表中,不會出現數據比較集中的狀況,這時候紅黑樹是不必的。可是現實中每一個對象的哈希算法隨機性高,所以就可能致使不均勻的數據分佈。
之因此選擇8是從機率的角度提出的,理想狀況下,在隨機哈希碼算法下容器中的節點遵循泊松分佈,在Map中一個鏈表長度達到8的機率微乎其微,能夠看到8的時候機率是0.00000006,若是這種低機率的事都發生了說明鏈表的長度確實比較長了。至於爲何不選擇同一個值做爲閾值是爲了緩衝,能夠有效防止鏈表和紅黑樹的頻繁轉換。
其實看懂了 putVal 再看 get 獲取值的時候就感受很簡單了,首先看 get(Object key)
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
複製代碼
這裏不是具體實現,獲取的邏輯在 getNode 方法中,這裏一樣的會調用 hash(key)方法,找到 key 對應的數組下標位置。
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//數組不爲空且數組長度大於0且定位到的下標位置節點不爲空
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//若是當前key存儲在首節點則直接返回
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//若是不是首節點則繼續遍歷
if ((e = first.next) != null) {
//若是是紅黑樹結構,則按照紅黑樹的方式獲取值
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//按照鏈表的方式獲取值
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
複製代碼
整個 hashMap 就擴容的這塊相對來講是最複雜的了,涉及到數據的遷移和從新尋址,代碼量也比較多,須要點耐心。
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;
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else {//oldCap=0或oldThr=0時,即初始化時的設置
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;
//若是舊的hash桶數組在j結點處不爲空,複製給e
if ((e = oldTab[j]) != null) {
oldTab[j] = null;//將舊的hash桶數組在j結點處設置爲空,方便gc
//若是e後面沒有Node結點,意味着當前數據下標處只有一條數據
if (e.next == null)
//將e根據新數組長度作哈希取模運算放到新的數組對應下標中
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
//若是e是紅黑樹的類型,那麼按照紅黑樹方式遷移數據,split裏面涉及到紅黑樹轉鏈表
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
//定義兩個新鏈表lower,higher
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
//將Node結點的next賦值給next
next = e.next;
//若是結點e的hash值與原數組的長度做與運算爲0,則將它放到新鏈表lower中
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;//將e結點賦值給loHead
else
loTail.next = e;//不然將e賦值給loTail.next
loTail = e;//而後將e複製給loTail
}
//若是結點e的hash值與原數組的長度做與運算不爲0,則將它放到新鏈表higher中
else {
if (hiTail == null)
hiHead = e;//將e賦值給hiHead
else
hiTail.next = e;//若是hiTail不爲空,將e複製給hiTail.next
hiTail = e;//將e複製個hiTail
}
} while ((e = next) != null);//直到e爲空結束循環,即鏈表尾部
if (loTail != null) {
loTail.next = null;//將loTail.next設置爲空
newTab[j] = loHead;//將loHead賦值給新的hash桶數組[j]處
}
if (hiTail != null) {
hiTail.next = null;//將hiTail.next賦值爲空
newTab[j + oldCap] = hiHead;//將hiHead賦值給新的數組[j+原數組長度]
}
}
}
}
}
return newTab;
}
複製代碼
跟着讀完一遍 resize 的代碼後,能夠看到代碼的前一部分是擴容的代碼,擴容的邏輯是新數組的長度是原數組的2倍,但也不是無限擴容,直到長度超過了最大容量值MAXIMUM_CAPACITY = 1 << 30
中止,這時候也不設置閾值了直接指定閾值爲threshold = Integer.MAX_VALUE
。
後一部分爲數據遷移的邏輯,經過for循環遍歷原數組,將原數組的數據遷移到新容器中。分爲3種狀況處理
hash&(newCap - 1)
定位到新的下標位置。split()
方法進行數據遷移,若是數據節點少於6的話,裏面會將紅黑樹轉鏈表。遷移後的數據位置會變化嗎?
紅黑樹和鏈表的數據遷移不是規規矩矩的按照原容器的樣式進行遷移的,它這裏定義了兩個新的節點,鏈表的時候是 Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null;
,而紅黑樹的時候是TreeNode<K,V> loHead = null, loTail = null;TreeNode<K,V> hiHead = null, hiTail = null;
,其中 lo 應是 lower 的縮寫,hi 應是 higher 的縮寫。這樣作的緣由按照源碼中的話就是because we are using power-of-two expansion
。
因此原數組某個下標處的節點鏈中的數據遷移的時候會被拆分紅兩部分,這裏以鏈表爲例來講明,它會將節點的hash和原數組長度作個與運算(e.hash & oldCap
),若是結果爲0,則放到鏈表 lower 中,不然放到鏈表higher中。
鏈表 lower 存放的下標在新數組中不變,即原來是oldTab[4]
,則新數組中是newTab[4]
。鏈表 higher 會在原下標的基礎上加上原數組的長度,即原來是oldTab[4]
,則新數組中是newTab[4+ oldCap]
。
最後再說一點,你知道爲何要擴容嗎?
其實很簡單的緣由,這得從數組的數據結構提及了,咱們常說數組的查詢快,這種說法是基於下標尋址來講的,因爲數組中的元素在內存中是連續存儲的,當咱們定義好數組的長度後這個數組就固定了不能再改變它,計算機會在內存中分配一整塊連續的空間給它,因爲是連續的因此咱們知道a[0]的地址,經過加n就知道a[n]的地址了。因爲這個特性因此若是原數組滿了,那麼必須在內存中開闢一個新的數組而後將數據從原數組中遷移過來。
HashMap 的源碼和重點的知識點咱們都已通過了一遍,能夠看到一個簡單的集合容器內部包含了設計者的豐富思想和技術能力。咱們閱讀源碼既能幫助咱們瞭解這個知識點並更好的使用它,又能夠從中學習到設計思想以便咱們工做中能夠借鑑使用,可見閱讀源碼的重要性。