HashMap是我們日常開發中使用非常頻繁的集合框架,平時只會使用,卻不知道底層是如何實現的,今天在這裏對HashMap的底層實現做一系列的分析。如有不對的地方,歡迎指正。
我們知道HashMap是用來存儲數據的一個容器,使用key-value來存儲數據,當key重複的時會覆蓋前一次的value值。那麼HashMap是如何做到存儲數據和讀取數據的呢?我們知道1.8之後的HashMap底層使用了數組、鏈表+紅黑樹的數據結構,那麼具體的實現是什麼樣的呢?讓我一步一步介紹。
首先,HashMap是在什麼時候初始化的,是new HashMap()的時候嗎?我們來看一下源碼:
我們看到它又四個構造方法,這四個構造方法沒有對HashMap做初始化的操作(具體的代碼請自行查找,這裏就不貼出來了),而點到put("key","value");的方法裏它繼續調用了一個putVal(hash(key), key, value, false, true);方法,然後繼續跟進,在該方法裏有一個resize();方法,這個方法部分源碼如下:
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 { // 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;
}
我們具體關注兩句代碼,
1:newCap = DEFAULT_INITIAL_CAPACITY;
2:Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
我們先看一下DEFAULT_INITIAL_CAPACITY是什麼:
上圖可以見,就是一個int類型的變量,賦值16,然後第2句代碼就很明顯了,創建了一個長度爲16的Node數組,那麼Node又是什麼呢?我們繼續跟進去看一下:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
...略
}
上述可知,Node就是一個對象,有四個屬性,分別是hash值,key值,value和next,這就很明顯了,HashMap底層就是用這個Node對象來存儲數據的,hash值用作標識,next指向下一個對象,key和value就不用多說了。那麼這個hash是誰的hash呢,我們根據Node的構造方法往回找,找到一個putMapEntries(Map<? extends K, ? extends V> m, boolean evict)方法,我們來看一下:
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
if (table == null) { // pre-size
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold)
threshold = tableSizeFor(t);
}
else if (s > threshold)
resize();
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
在最後一句,我們終於找到hash是誰的了,那就是我們put值的時候的key的Hash,跟進hash(key),得到
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
也就是key調用看hashCode()方法,因爲所有對象都是Object的子類,所以這個key可以根據父類的hashCode()方法獲得哈希值。
我們知道,hashCode值就是一串的數字如:106079,說白了,hashCode是用來定位的,我們前面初始化了一個長度爲16的數字,那麼我們在插入元素的時候就需要知道下標,那麼如果是十進制的哈希值,定位這16個位置,我們可以通過取餘,但是這樣做的話衝突頻率太高了,於是乎,這裏將十進制的哈希值轉換爲了二進制,我們可以看到上面的這句代碼:return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);如果key爲null那麼哈希值直接返回0,否則,將哈希值的低16位與哈希值的高16位做異或運算,這樣,就將該key的哈希值全員多參與了運算,會大大降低hash衝突的機率,然後我們再看resize()方法:
其他的一堆判斷我們先不管他,我們看這裏,我們每插入一個數據,它這裏會做判斷,直到next爲null了,就往這個地方存放心的數據,上述代碼我們可以看到他是用e.hash & (newCap - 1)來定位下標索引值的,這裏用了&運算,通過將前面得到的key的哈希值再與當前數組容量的大小減1做與運算,這樣同樣可以獲得0-15的值(假設當前數組容量爲16,不懂&運算的同學可以自行百度一下)。下標定位的問題解決了,我們再看一下,如果出現衝突了他是怎麼處理的。
我們回到putVal();方法:
第一個if分支,大概就是如果這個插入的新值的key和之前存的數值相同,那麼直接用新的值替換老的值,第二個分支如果是樹節點,加入到樹,第三個分支,如果是鏈表遍歷,然後找到最後一個節點,往下加入新值。
接下來,我們先看一下,鏈表是怎麼使用的。如果key值相等就直接替代了,如果key值不相等而hash相等了怎麼辦,這個時候就會在這個數組的節點,延伸出一個鏈表,可以想象爲以這個數組節點爲頭結點,往下用鏈表來存儲,畫一個圖來說明一下:
就是這樣,往下延伸了一個鏈表,我們繼續看,在第三個分支有一個
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
這裏就是做了一個將鏈表轉換爲紅黑樹的一個方法,具體不展開,我們看一下條件binCount >= TREEIFY_THRESHOLD - 1,binCount就是循環的索引值,上面的代碼可以回去看一下,TREEIFY_THRESHOLD是什麼呢?點進去看得到:
那麼,也就是說當鏈表的長度大於7的時候就轉爲紅黑樹了,我們再看第二個分支
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
這個方法點進去有一個
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
UNTREEIFY_THRESHOLD是什麼呢
這裏就是說當紅黑樹的節點小於等於6,就又轉回鏈表了;
這種機制就防止了鏈表太長而導致搜索太慢,而紅黑樹的結構複雜,如果節點少,用紅黑樹可能會更耗資源,在這兩者之間做了平衡。
我們現在思考一個問題,如果這個數組的容量只有16,會出現什麼問題?隨着數據的不斷put,不斷的hash衝突,然後鏈表和紅黑樹的節點越來越多,那麼查找效率肯定是會越來越差的,所以,HashMap還有一個擴容。那麼,什麼情況下它纔會擴容呢?我們回到源碼找一下答案,我們在putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)的方法裏找到這樣一段代碼:
if (++size > threshold)
resize();
先說明一下這兩個參數:
size:
/**
* The number of key-value mappings contained in this map.
*/
transient int size;
從源碼註釋可以看出,這個size就是當前map中存在的K-V鍵值對的數量,也就是當前存了多少對鍵值對
threshold:
/**
* The next size value at which to resize (capacity * load factor).
*
* @serial
*/
// (The javadoc description is true upon serialization.
// Additionally, if the table array has not been allocated, this
// field holds the initial array capacity, or zero signifying
// DEFAULT_INITIAL_CAPACITY.)
int threshold;
先說明一個單詞threshold,百度翻譯得到的結果是:門檻;門口;閾;界;起始點;開端;起點;入門。根據threshold這個單詞的翻譯可以認爲threshold是一個閾值,噹噹前的map容量到達這個閾值就會做一次resize,重新計算容量,也就是擴容。
我們繼續跟進resize()方法,可以找到底下這段代碼:
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);
}
看一下這幾個變量:
oldThr:原起始點,可以理解爲前一次創建數組的起始點,比如:如果是第一次調用,也就是剛剛初始化,那麼他的閾值就是0,也就是當它的大小爲0的時候就
觸發一次數組創建,初始化階段創建一個容量爲16的數組。
newCap:新的數組的容量
newThr:新的起始點,可以理解爲將要創建的新的數組的起點,也就是當到達這個閾值的時候,就要新觸發一次數組容量的擴充。
然後我們看最後一句代碼:
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
DEFAULT_LOAD_FACTOR 是擴展因子,源碼中是這樣定義的:
static final float DEFAULT_LOAD_FACTOR = 0.75f;
DEFAULT_INITIAL_CAPACITY 前面已經介紹過了,就是默認的容量:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
那麼也就是說,當這個閾值到達16*0.75=12 就會觸發一次數組容量的擴充。
我們再回到putVal()方法看
if (++size > threshold)
resize();
這下就清楚了,當map當前的size(當前已經存放了多少鍵值對)大於這個threshold,假設當前容量是16的話,這個threshold就是12,當大於12,就會進行一次擴容操作。
於是乎,又出現一個新的疑問,擴容的容量是多少呢?我們接着在源碼中尋找答案,再一次回到resize()方法:
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
}
當oldCap(原數組的容量)> 0(也就是已經初始化過了),的時候有兩個分支,我們先看第一個
oldCap >= MAXIMUM_CAPACITY
/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
MAXIMUM_CAPACITY:最大的容量數。
這個分支就是判斷,是否超過了最大容量,當大於這個數值,就不再進行擴充了。
重點是第二個分支,
(newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY
這裏將原容量左位移1位賦值給了新的容量,左位移1位,也就是擴大了一倍。
現在我們找到答案了,每次擴容爲原來的一倍,但是,如果超過了最大的容量,就不進行擴容了。
關於擴容後的數據轉移,我們繼續看源碼,以下是resize()方法中的一段代碼:
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;
}
}
這段代碼不太好形容,我就概述一下它的邏輯,e是一個Node對象,拿e的hash和原數組容量做&運算,如果等於0,就在原數組節點下“掛上”一個鏈表節點,否則,拿當前的索引加上原數組的容量,比如當前索引爲2,原容量爲16,那麼這個Node就會在索引值爲18的數組節點爲頭節點,往下“掛”一個鏈表節點。紅黑樹的數據轉移與鏈表是相似的。
HashMap通過以這種方式處理了隨着容量的擴充,遷移原數組的數據,解決了原結構與新結構的數據分佈不均勻的問題。
總結:
1、HashMap在put元素的時候進行初始化,初始化一個容量大小爲16的Node數組。
2、通過對key的hash做一系列的運算,來儘可能的避免hash衝突。
3、由於hash衝突不可避免,當發生hash衝突的時候,有兩種選擇:
1)如果key相同,直接覆蓋;
2)key值不同,用鏈表(或紅黑樹)存儲;
4、發生hash衝突(忽略key相同的情況),存儲數值的時候,如果當前鏈表長度小於8則以鏈表形式存儲,如果大於等於8則以紅黑樹存儲;如果紅黑樹的葉子節點小於6,則又會變爲鏈表。
5、擴容因子爲0.75,當新加入的節點的下標超過了,當前數組容量8擴容因子的時候,就會觸發擴容。例如:假設當前數組容量爲16,那麼16*0.75=12,當新加入的值存在第13號索引的時候就會進行擴容操作。擴容主要是爲了避免數組容量太小,隨着hash衝突的不斷髮生,導致鏈表或紅黑樹過於龐大,而導致查找性能低下。
6、擴容之後,擴容前的結構還是相對擁擠,會通過相關算法,將數據進行轉移,一部分留在原地,一部分移到當前索引值加上原數組容量大小的地方。如:當前下標爲2,那麼會有一部分仍留在以數組索引值爲2並以其爲頭節點,往下繼續存儲,另一部分則轉移到數組索引爲18的位置,並以其作爲頭節點,以鏈表方式存儲。這樣就可以使存儲的數據分散開來,方便查詢。