HashMap源碼分析
hashmap一直都是面試的高頻問題。這次讓我們一起徹底幹掉它,以後再也不怕面試官問這個問題了。
首先我們先對這個方法的文檔閱讀一下,下面做個簡單大概的翻譯,先提前瞭解一下hashmap。
那麼,我們再來看一下hashmap中的一些成員變量
熟悉了這些變量之後,我們先大概瞭解一下在JDK8中,hashmap的結構是什麼樣子的。
在hashmap中,元素最小單位是entry,裏面存放的是一個key-value鍵值對,還有一種叫法是bucket(桶)。
在扣源碼的時候,我們先補點位運算知識:
符號 | 描述 | 運算規則 |
---|---|---|
& | 與 | 兩個位都爲1時,結果才爲1 |
| | 或 | 兩個位都爲0時,結果才爲0 |
^ | 異或 | 兩個位相同爲0,相異爲1 |
~ | 取反 | 0變1,1變0 |
<< | 左移 | 各二進位全部左移若干位,高位丟棄,低位補0 |
>> | 右移 | 各二進位全部右移若干位,對無符號數,高位補0,有符號數,各編譯器處理方法不一樣,有的補符號位(算術右移),有的補0(邏輯右移) |
那麼,我們從頭開始,先上一段代碼。
public static void main(String[] args) {
//實例化一個無參HashMap對象
Map map = new HashMap();
//put一個值
map.put("1","2");
}
首先,先new一個HashMap對象,不傳入任何參數,使用默認參數。
//無參構造只是設置了一個默認加載因子爲0.75.並沒有初始化容量,懶加載機制
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
其次,調用其put方法:
//1、該方法是有返回值的。返回啥呢?
//2、調用putVal方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
//onlyIfAbsent和evict兩個參數暫時忽略掉
//我們分析一下這個putVal方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//這個Node其實就是我們的entry,只是表示不一樣。
Node<K,V>[] tab; Node<K,V> p; int n, i;
//這個if條件其實就是用於初始化的。當數組tab爲空或者長度爲0時
if ((tab = table) == null || (n = tab.length) == 0)
//調用一下resize方法。在JDK8中,初始化容量和擴容都用resize方法進行.後面詳解
n = (tab = resize()).length;
//如果當前數組爲空,則直接put進去。(n-1)& hash涉及位運算,作用就是計算出tab數組下標。n-1是爲了避免
//剛好n是2的冪次方。出現數組下標越界的情況
//舉例:hash:0101 1010 n-1(15):0000 1111 進行&位運算,0000 1010(10)
//即i=10,此時,數組爲空
if ((p = tab[i = (n - 1) & hash]) == null)
//將k-v:"1"-"2"放到tab數組下標爲10的位置。newNode其實就是一個entry,不展開講
tab[i] = newNode(hash, key, value, null);
//這個地方是什麼情況呢?其實就是當hash衝突時,即數組tab上已經存在k-v時的情況
else {
Node<K,V> e; K k;
//先判斷hash衝突時,數組中Node是否是key相等,將e賦值爲p,即爲目標值
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//如果是key相等,將新建k-v對象e賦值爲p(當前數組中的k-v對象)
e = p;
//判斷是否是紅黑樹中節點
else if (p instanceof TreeNode)
//是的話,存放到紅黑樹中,能力有限,紅黑樹就不展開講。
//用紅黑樹是介於平衡二叉樹和單鏈表結構綜合考量,讀寫效率介於兩者之間
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//循環鏈表中的Node對象
for (int binCount = 0; ; ++binCount) {
//如果尾結點爲null,則將對象放置在尾部
//在JDK7中,是採用頭插法,多線程出現循環鏈表,JDK8中使用尾插法,避免了該情況
if ((e = p.next) == null) {
//循環鏈表
//剛好循環到尾部時。put這個新元素
p.next = newNode(hash, key, value, null);
//當鏈表大小超過8-1=7時,鏈表轉換成紅黑樹
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//這個其實就是判斷是否要覆蓋鏈表中有key相等的value
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//p爲當前對象e,循環下去
p = e;
}
}
//如果e不爲null,則表示存在key相同,使用傳入的value覆蓋當前value
if (e != null) { // existing mapping for key
//原值賦值給oldVlaue
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
//並返回這個oldValue值
return oldValue;
}
}
++modCount;
//如果當前元素個數大於16*0.75=12.觸發擴容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
//沒有重複key則返回null值
return null;
}
上面是HashMap的put方法,我們可以根據下面的流程圖進行一個簡單的理解。
在putVal方法中,我們可以看到,當map初始化或者Node個數超過閾值,默認也就是12時,會觸發擴容機制。其實還有一種情況會觸發擴容,後再說。
resize方法詳解
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
//初始化時,oldCap爲0,擴容時則爲原數組長度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//將參數threshold閾值設置爲當前map的閾值
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
//如果達到最大值,就不擴容了
if (oldCap >= MAXIMUM_CAPACITY) {
//閾值設爲int的最大值
threshold = Integer.MAX_VALUE;
return oldTab;
}
//擴容爲原來的2倍,並保證要小於最大容量,且原來容量大於默認初始容量
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//閾值也同樣翻倍
newThr = oldThr << 1; // double threshold
}
//這個地方注意,是給有參HashMap的初始化,利用閾值做參數,具體如果操作我們後面
//結合有參構造和tableSizeFor方法講一下。
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);
}
//給閾值賦值,針對有參構造。即新閾值爲0時,尚未進行賦值
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
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)
//將該節點放入到新數組,並重新計算下標。其實下標是有規律的
//前一個例子:hash:0101 1010 n-1(擴容後31):0001 1111 進行&位運算,0001 1010(26)
//轉移到新數組後要麼是原來的位置(10),要麼是原位置下標+擴容的大小(10+16).
newTab[e.hash & (newCap - 1)] = e;
//如果是紅黑樹節點
else if (e instanceof TreeNode)
//調用紅黑樹的方法
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
//鏈表拆分,這段很精彩同學們,注意聽
//定義了兩個鏈表,lo鏈表和hi鏈表。其中loHead,loTail,hiHead,hiTail可以分別看做頭結點和尾結點
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
//循環體
do {
next = e.next;
//hash衝突並不是hash值一模一樣,所以運算出結果會有兩個,要麼等於0,要麼不等於0;
//我們將運算結果爲0的看做是lo鏈表,就好理解了。可以去翻一下後面那張圖,比較形象
if ((e.hash & oldCap) == 0) {
//如果lo鏈表尾結點是空值,代表lo鏈表還沒有元素存在
if (loTail == null)
//將頭結點指向e元素
loHead = e;
else
//有頭之後就將loTail的下一個後移
loTail.next = e;
//同時移動loTail指針
loTail = e;
}
else {
//hi鏈表也一樣
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
//將鏈表遷移到新數組原下標位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//將鏈表遷移到新數組原下標+oldCap處
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
hash 衝突並不代表hash值是一模一樣的,只能表示hash值的低位是相同的,比如說:
//hashA:1010 1111 hashB:0011 1111 hashC:1001 1111 當數組大小都爲16時
//三者對應數組下標爲hashA:0000 1111(15)
//但是e.hash&oldCap分別爲:0000 0000;0001 0000;0001 0000
以上,就完成了一個HashMap的初始化以及擴容過程。
那麼,我們之前提到,出了初始化和size大於閾值時會觸發resize擴容,還有一個地方會觸發擴容,我們看代碼:
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//當前數組爲空,或者數組的大小小於最小樹容量
//也就是說當數組比較小時,不會出現轉化爲紅黑樹,會進行擴容
//儘可能將元素存放在數組中,數組log(1)的效率還是要高一些
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
那麼,我們再來看看他的get方法:
//其實,弄清楚hashmap的底層結構和put方法,這個就比較簡單了
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
//最終調用的方法
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//首先去數組中找
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//必須先檢查頭結點
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;
}
get方法其實不難理解。就是一個查找加比較的一個過程。
後面一些方法這裏也不再詳解了,有興趣的小夥伴自己可以去源碼看看。話說寫這個JDK的老哥是真的大佬,讓人膜拜了。
總結
1、JDK8採用的是數組+鏈表+紅黑樹的結構
2、JDK8採用尾插法,有效避免了多線程情況下JDK7出現的循環鏈表的情況
3、JDK8簡化hash算法。
4、擴容機制有區別。JDK7會rehash,JDK8不會,只是根據數組大小重新計算位置。
ps:以上內容難免有不正確的地方,希望您能夠及時指出,謝謝!