上一篇說到空HashMap的put函數(shù)執(zhí)行過程,今天我們繼續(xù)看下非空HashMap的put函數(shù)執(zhí)行過程。上一篇鏈接我貼出來,供大家快速跳轉(zhuǎn)過去。
一起精讀源代碼系列(1) HashMap(一) put函數(shù)的執(zhí)行過程
那么當HashMap中已經(jīng)存有元素的時候會發(fā)生什么呢?話不多說,直接上putVal函數(shù)的前幾行代碼
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);
上篇分析的HashMap為空所以會進這行if
(tab = table) == null || (n = tab.length) == 0
這次并不會進,那下一行的if就需要判斷一下了,因為計算出來的數(shù)組下標對應的節(jié)點p既可能為null,也可能不為null。打個比方
//代碼片段1
Map<Integer,String> map1 = new HashMap<>();
map1.put(1,"1");
map1.put(2,"2");
//代碼片段2
Map<Integer,String> map2 = new HashMap<>();
map2.put(1,"1");
map2.put(1,"2");
代碼片段1執(zhí)行的時候第二次put這里的p就是null,而代碼片段2執(zhí)行的時候第二次put這里的p就不是null。還是老規(guī)矩,分兩種情況一行行分析。
當節(jié)點p為null的時候,我們可以看到這里會執(zhí)行newNode函數(shù)。也就是這么一個玩意:
Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
return new Node<>(hash, key, value, next);
}
這個已經(jīng)分析過了,就是新建了一個Node節(jié)點。那么當p不為null的時候繼續(xù)往下看。先來看第一個if會不會進
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
按照從左到右依次執(zhí)行的順序,p.hash==hash這個肯定為true,因為key是一樣的(從代碼片段1可以看到,都是1),p.key==key這個也是true,所以這整個表達式一定是true,所以就會執(zhí)行e=p,把節(jié)點p賦值給e。這里暫時還看不出什么,我們繼續(xù)往后看。那進了這個if,后面的else都直接跳過即可。來到最后
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
很明顯會進到這里,e的value其實就是p的value,這時候會賦值給oldValue。下面又有一個if,其中的onlyIfAbsent這個參數(shù)傳進來是false。參考putVal函數(shù)的注釋
* @param onlyIfAbsent if true, don't change existing value
這意思就是如果這個參數(shù)傳了true,則表示put同一個key的鍵值對進HashMap的時候不替換原來的值。這個值默認為false,所以if的條件是true,就會用新value替換原來的value了。同樣的,后面也執(zhí)行了一個空函數(shù)afterNodeAccess(這個函數(shù)在linkedHashMap有實現(xiàn)),并且這種情況下會把老的value返回。這樣我們就發(fā)現(xiàn)了,put函數(shù)其實是有返回值的。
好的,這下我們明白了同樣key不同value的替換過程。那接下來我們分析如果發(fā)生了所謂的hash沖突,HashMap會如何處理。什么是Hash沖突呢?上一篇我們聊到,HashMap會根據(jù)key計算出hash值,然后再與n-1按位與操作計算出在數(shù)組中的下標,有了下標,就往數(shù)組中添加一個Node。那如果不同的key得到的hash值相同的時候就會發(fā)生hash沖突。我寫了一個簡單的類來模擬hash沖突的過程。
import java.util.Objects;
public class User {
private String name;
public User(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public String toString() {
return "User{" +
"name='" + name + '\'' +
'}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return Objects.equals(name, user.name);
}
@Override
public int hashCode() {
if (name.equals("wang") || name.equals("WANG")) {
return 0x6666;
}
return super.hashCode();
}
}
hashcode函數(shù)的意思就是當name為wang或者WANG的時候,hash值設置為同樣的0x6666。equals函數(shù)的意思就是當兩個對象的name字段相等時就認為兩個對象相等。
好,那接下來我們用這個類做一些事情。我們分別用wang和WANG作為HashMap的key,看看會發(fā)生什么。
User user1 = new User("wang");
User user2 = new User("WANG");
Map<User,String> map = new HashMap<>();
map.put(user1,"30");
map.put(user2,"32");
很顯然,這里的user1和user2用了wang和WANG作為key,根據(jù)我們上面的設定,兩個對象的hashCode值是一樣的。下面貼出這種情況下HashMap的執(zhí)行過程代碼
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,p.next為null,也就是說p是鏈表的尾結(jié)點,或者說鏈表中的最后一個節(jié)點。這段代碼其實就是在循環(huán)查找鏈表直到鏈表的尾部。我們的代碼片段剛好符合這個邏輯,所以會進到這個if。p.next=newNode,也就是說往鏈表的尾部又插入了一個元素,然后break跳出for循環(huán)。因此我們可以得出第一個結(jié)論,當HashMap中發(fā)生hash沖突的時候,會找到數(shù)組的下標,然后往數(shù)組的下標處對應的鏈表尾部插入一個節(jié)點。
再往下一段代碼就是同樣key的一個value替換過程,之前也分析過,就不再多做分析了。
到了這里,大家應該能理解數(shù)組和鏈表在HashMap中的應用了。那么我們也會思考一個問題,在某種場景下鏈表長度可能會越來越大,是不是就有可能影響到HashMap的查詢效率呢?甚至,在極端情況下,HashMap的查詢時間復雜度可能會由O(1)退化到O(n),這可是很嚴重的性能故障。為了解決這個問題,JDK1.8引入了紅黑樹。紅黑樹是一種平衡的二叉查找樹,查詢、插入、刪除節(jié)點的時間復雜度均為O(logn),是非常高效的一種數(shù)據(jù)結(jié)構(gòu)。putVal函數(shù)中涉及到紅黑樹的代碼如下:
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
也就是當binCount這個值大于等于7或者鏈表長度大于等于8的時候,會調(diào)用treeifyBin()把鏈表轉(zhuǎn)為紅黑樹。紅黑樹的代碼不太好寫,還涉及到數(shù)據(jù)結(jié)構(gòu)和算法相關(guān)的知識,這不在本文的討論范圍,所以我不做太多的擴展,有興趣的同學可以下去深入研究一下。
我們來總結(jié)一下整個put過程中數(shù)組、鏈表、紅黑樹之間的關(guān)系。首先數(shù)組的下標是通過key值計算出來的,數(shù)組的value就是Node節(jié)點,當沒有hash沖突的時候,Node節(jié)點的next是null,當發(fā)生了hash沖突,Node節(jié)點的尾部會新增一個節(jié)點,鏈表長度加1,而當鏈表長度大于等于7,會轉(zhuǎn)化為紅黑樹。
理解了這些之后,我們最后再來看一下put過程中另一個非常關(guān)鍵的點,HashMap擴容。隨著我們不斷往HashMap中添加元素,hashMap的容量不足以容納我們放進去的數(shù)據(jù)的時候,自然就需要擴容了。相關(guān)代碼:
if (++size > threshold)
resize();
很顯然,當hashMap的size大于閾值的時候,會執(zhí)行一次擴容。上一篇我們說到HashMap的默認閾值為12,也就是在默認情況下,當hashMap的容量到達最大默認容量16*0.75的時候,會進行擴容操作。0.75是指HashMap的負載因子。我先來解釋下這個負載因子的作用。在散列表這種數(shù)據(jù)結(jié)構(gòu)里面,有一個名詞叫做裝載因子,當散列表中空閑位置不多的時候,散列沖突的概率就會大大提高。為了盡可能保證散列表的操作效率,一般情況下,我們會盡可能保證散列表中有一定比例的空閑槽位。我們用裝載因子(load factor)來表示空位的多少。裝載因子的計算公式如下:
散列表的裝載因子=填入表中的元素個數(shù)/散列表的長度
在HashMap中,定義了一個默認的裝載因子0.75f。為了計算方便,又定義了閾值threshold,初始值為默認最大容量16乘以默認裝載因子0.75得到12。當HashMap中的容量第一次達到12以上的時候,就會執(zhí)行一次擴容,我們就從第一次擴容為入口往下看擴容過程的代碼。直接來到resize函數(shù)的第681行到689行
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
}
可以看出,當原來的容量大于0,會進到這里。看第一個if,原容量大于等于最大容量MAXIMUM_CAPACITY,查看源碼可知,這個參數(shù)為23o,這個場景不涉及,所以不會進。下面的else if,說的是原容量擴大一倍之后小于最大容量且原容量大于等于默認初始容量16,假如我們原容量為16,這個時候觸發(fā)擴容,擴大一倍是32,滿足這個else if的條件,所以得到newCap也就是新容量為32,newThr也就是新閾值為原閾值12擴大一倍得到24。由此可得,經(jīng)過一次擴容之后,容量和閾值都擴大了一倍。進了681行的if,所以后面的else都不會進,直接跳到701行以后。由于這是一個for循環(huán),沒辦法再縮短了,只能一起貼出來
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;
這個地方非常復雜,我會一行行解釋。首先是把新閾值賦值給了成員變量threshold,然后新建了一個容量是新容量大小的Node數(shù)組并且賦值給成員變量table。下面的過程就是把原數(shù)組搬遷到新數(shù)組的過程,我們都知道數(shù)組的搬遷其實性能很一般,那這里就可以看下HashMap的源碼是如何實現(xiàn)舊數(shù)組到新數(shù)組的數(shù)據(jù)搬遷的。
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
這里就不多說了,就是一個數(shù)組的遍歷,只不過把舊數(shù)組中的值賦值給到e之后,就把這個值置為null了。
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
這里就是往新數(shù)組中插入數(shù)據(jù)的過程,如果e.next為null,意思就是這個地方的Node沒有引申鏈表,也就是說沒有發(fā)生hash沖突。那e.hash&(newCap-1)計算出來的值會作為舊數(shù)據(jù)在新數(shù)組的下標,上一篇我們分析了,由于newCap是2的x次方,所以這里的表達式其實就是e.hash對newCap進行取模運算。數(shù)據(jù)從舊表搬移到新表之后,即便下標發(fā)生了變化,也不會影響到數(shù)據(jù)的查詢,因為在HashMap的get函數(shù)里,擴容之后,相應的取下標的計算也變了,這個我們后面會進行分析。
接下去繼續(xù),如果e.next不為null,這里我們先跳過紅黑樹直接進入到鏈表層面的分析。用do while對鏈表進行遍歷這里不多說,重點在于一個表達式:(e.hash & oldCap) == 0,相信看過HashMap源碼的同學很多人都無法理解這個表達式的意義。下面我?guī)Т蠹疫M行詳細的推導
- 我們已知容量肯定是2的整數(shù)次冪,所以oldCap轉(zhuǎn)為二進制就是類似10000這樣的值;
- 如果按位與得到的值為0,則表示e.hash轉(zhuǎn)為二進制之后在oldCap對應的1的位置的值一定是0,其他位置可以隨意,例如01101這個hash值,又例如1101101這個hash值。只有在oldCap對應1的位置的值是0的前提下,按位與得到的結(jié)果才會是0;
- 這種情況下,我們分別計算e.hash&(oldCap-1)和e.hash&(2*oldCap-1),也就是在(e.hash & oldCap) == 0的前提下計算擴容前后某個數(shù)據(jù)在數(shù)組中的下標。當2的x次方減1之后,轉(zhuǎn)為二進制會得到低位全是1的數(shù)據(jù),擴容其實就是二進制數(shù)據(jù)比擴容前多了一個1,而根據(jù)第2點的分析,在多的這個1的位置上e.hash對應的值是0,因此按位與操作擴容前后得到的結(jié)果是一樣的,也就可以推導出4的結(jié)論;
- 在(e.hash & oldCap) == 0前提下,e在新舊數(shù)組的位置不變。
在這種情況下,把原數(shù)組搬移到新數(shù)組的時候,下標都不需要改變。只需要取出當前節(jié)點的頭節(jié)點賦值到新節(jié)點即可。
接下來我們分析(e.hash & oldCap) != 0的時候。推導過程如下
- 我們已知容量肯定是2的整數(shù)次冪,所以oldCap轉(zhuǎn)為二進制就是類似10000這樣的值;
- 如果按位與得到的值不為0,則表示e.hash轉(zhuǎn)為二進制之后在oldCap對應的1的位置的值一定是1,其他位置可以隨意,例如11101這個hash值,又例如1111101這個hash值。只有在oldCap對應1的位置的值是1的前提下,按位與得到的結(jié)果才不會是0;
- 這種情況下,我們分別計算e.hash&(oldCap-1)和e.hash&(2*oldCap-1),也就是在(e.hash & oldCap) != 0的前提下計算擴容前后某個數(shù)據(jù)在數(shù)組中的下標。當2的x次方減1之后,轉(zhuǎn)為二進制會得到低位全是1的數(shù)據(jù),擴容其實就是二進制數(shù)據(jù)比擴容前多了一個1,而根據(jù)第2點的分析,在多的這個1的位置上e.hash對應的值是1,因此按位與操作擴容前后得到的結(jié)果就是高位上多了一位1。高位多了一個1也就意味著換算為10進制多了2的x次方,也就是多了一個oldCap的值,可以推導出4的結(jié)論。
- 在(e.hash & oldCap) != 0前提下,e在新舊數(shù)組的位置等于原位置加oldCap。
那到此為止,整個擴容過程就分析完畢,put函數(shù)也分析的差不多了。
下期預告,一起精讀源代碼系列(2) Android Handler系列源碼分析。基于Android8.0。