1. 程式人生 > >從JDK原始碼學習Hashmap

從JDK原始碼學習Hashmap

這篇文章記錄一下hashmap的學習過程,文章並沒有涉及hashmap整個原始碼,只學習一些重要部分,如有表述錯誤還請在評論區指出~

基本概念

Hashmap採用key算hash對映到具體的value,因此查詢效率為o(1),為防止hash衝突,在陣列的基礎上加入連結串列、紅黑樹,為無序非執行緒安全的儲存結構

jdk1.8之前採用以下方式儲存資料:

左邊實際上就是一個數組,右邊則是key值相同的元素放到同一個連結串列中(圖片侵刪)

但是這種陣列加單鏈表也存在問題,即單鏈表長度過長時,搜尋值將耗費時間複雜度為o(n),因此jdk1.8中提出陣列+連結串列+紅黑樹的方法

 

 

原始碼解析

該類是實現map介面的,並且也支援序列化、支援淺拷貝

 

構造方法

第一種可以自己指定容量大小與負載因子,那麼此時閾值已經確定,使用tableSizefor來找到大於等於指定容量的最小2的次方數作為閾值,其中輸入的值先-1,保證返回的值要大於等於輸入值

 

第二種可以僅指定容量,使用預設的負載因子,此時也會初始化閾值

 

第三種使用預設的容量16以及預設的負載因子0.75

 

第四種是由map來建立一個hashmap,使用預設的負載因子,以及能夠將map放進hashmap的容量建立(不常用)

 

預設容量1左移4位位16,這裡容量大小必須為2的次方,很有講究 ,後面解釋原因

最大容量為2的30次方

 

預設的負載因子0.75,和擴容相關,主要表示當前hashmap的填充度

 

node表,真正儲存元素的表,為2的次方,其為hashmap的一個內部類

 

    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;  //key的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;
        }

<key,value>元素的個數,包括陣列中的和連結串列中的元素

 

關鍵方法

 put方法,放入鍵值對:

首先將放入的鍵計算hash,然後呼叫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;
     //如果當前hash表為空,即還沒有放入任何元素,則進行擴容操作,相當於初始化 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;
     //根據當前key的hash算出當前元素應該放到hash表中的下標,如果改位置為null,則放入 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; //否則發生hash衝突,並且如果當前位置元素的hash和要放入元素的hash相同並且當前元素的key和要放入的key一樣,則暫時儲存當前衝突的node節點 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p;
//若僅僅鍵的hash一樣,但是key並不一樣則首先判斷是否是紅黑樹節點,如果是的話則將當前的鍵放進紅黑樹中,更新當前的hash表的衝突節點 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); //否則當前節點為連結串列
        else {
//遍歷連結串列(因為我們之前已經知道每個node節點都儲存了下一個節點的地址,所以P.next變數即代表相對於當前node的下一個node,那麼遍歷到一個連結串列的尾部放入新的節點即可) 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 //放入後判斷,如果當前hash表的長度>=7,則將當前hash位置處轉為紅黑樹表示從而替換連結串列表示 treeifyBin(tab, hash); break; }
//如果遍歷過程中發現連結串列中存在相同的key則break退出 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; //否則更新p節點為e,從而實現迴圈遍歷連結串列 } }
       //如果儲存衝突節點的e變數不為null,則取衝突的值,根據onlyIfAbsent沒有設定或者當前value為null,都將 if (e != null) { // existing mapping for key V oldValue = e.value; //取到衝突節點的value if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; //hashmap修改次數,防止多執行緒衝突的 if (++size > threshold) //判斷當前node節點的多少有沒有到擴容的閾值 resize(); afterNodeInsertion(evict); return null; }

所以整個put的流程為:

①.首先根據要放入的key計算hash,然後根據hash獲取table中的放入位置,如果當前table為空,則進行初始化

②.判斷放入位置是否為空,為空則直接放入,否則判斷是否為紅黑樹節點,不是則為連結串列,則遍歷連結串列查詢是否存在相同的key,沒找到則放入連結串列尾部並判斷是否需要轉為紅黑樹(TREEIFY_THRESHOLD)

③.若查詢連結串列找到相同key則替換,放入後要判斷node節點數是否超過threshold,判斷是否需要resize

 

resize方法,擴充當前容量:

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table; //儲存舊的hash表
        int oldCap = (oldTab == null) ? 0 : oldTab.length; //判斷hash表的長度,若是第一次初始化則為0
        int oldThr = threshold; //取舊的閾值
        int newCap, newThr = 0; //定義新的長度和閾值
        if (oldCap > 0) { //如果之前長度大於零
            if (oldCap >= MAXIMUM_CAPACITY) { //如果之前的長度大於等於2的30次
                threshold = Integer.MAX_VALUE; //則將node節點閾值設定為2的31次-1
                return oldTab; //返回舊的hash表,不再擴容
            }
        //否則滿足擴容條件,進行擴容 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && //如果舊的容量擴大一倍小於2的30次並且舊的容量大於預設的初始化容量大小16,閾值也變為原來的2倍 oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold 則容量擴大一倍 }
//如果舊的容量為0,但是舊的閾值大於零,則可能是初始化hashmap時指定了容量,則直接將新的容量設定為舊的閾值 else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr;
//對於沒有設定初始容量的情況 else { // zero initial threshold signifies using defaults //如果是第一次初始化,則設定容量為16,閾值為16*0.75=12,即hashmap可以放12個node節點 newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); }
//如果新的閾值為0,則進行修正,令新的閾值為新的hash表容量長*負載因子 if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); }
//設定完新的容量和新的閾值後,則開始進項node節點元素轉移 threshold = newThr; //先將新生成的閾值賦值給成員變數threshold @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; //然後宣告一個新的節點陣列,容量即為擴充後的大小 table = newTab; //替換成員標量table為新表 if (oldTab != null) { //遍歷舊的容量大小,取其每個node節點 for (int j = 0; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null) { //如果該節點不為null oldTab[j] = null; //則讓舊錶的該位置為null,進行垃圾回收 if (e.next == null) //如果當前遍歷的節點下一個為null,說明為尾節點(單個node節點,無連結串列,無紅黑樹) newTab[e.hash & (newCap - 1)] = e; //則直接將該節點放到新的hash表中 //如果下一個節點不為null,則判斷當前節點是否是紅黑樹節點,若是,則將新標的該節點轉為紅黑樹節點
else if (e instanceof TreeNode) ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); //否則為單鏈表節點,則遍歷當前鏈中的節點決定要放入新hash表的位置
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; }

這裡設計很妙,原來的容量為2的次方,則只有1位為1,原來的下標是容量-1,則新增的一位bit,決定了節點hash新增的一位為1還是為0,來決定其存放位置,其也為隨機的,從而均勻地將節點放到新的hash表中,新增一位為0則放到低位中,即索引值不變,新增一位為1,則放到高位中,這樣原本在一條鏈中的節點就能夠分佈到兩條鏈上,也減少了搜尋的開銷

jdk1.7和1.8的Hashmap區別

1.jdk1.7中發生hash衝突新節點採用頭插法,1.8採用的為尾插法

2.1.7採用陣列+連結串列,1.8採用的是陣列+連結串列+紅黑樹

3.1.7在插入資料之前擴容,而1.8插入資料成功之後擴容

總結

1.在算key的hash時將key的hashcode和與hashcode的高16位做異或降低hash衝突概率

2.HashMap 的 bucket (陣列)大小一定是2的n次方,便於後面等效取模以及resize時定節點分佈(low或者high)

3.HashMap 在 put 的元素數量大於 Capacity * LoadFactor(預設16 * 0.75)=12 之後會進行擴容,負載因子大於0.75則會減小空間開銷,

4.影響hashmap效能的兩個引數就是負載因子和初始容量,擴容影響效能,因此最好能提前根據負載因此估算hashmap大小,擴容實際上是將當前node節點放入一個新的node陣列

5.tab[i = (n - 1) & hash] 實際上用與運算代替取模操作,效能更好,n即為容量大小,n為2的次方,則n-1則其二進位制位為全1,從而代替模運算,e.hash & oldCap 用與運算決定hash增加的一位為0或者為1

關於負載因子設定:

負載因子的大小決定了HashMap的資料密度。
負載因子越大密度越大,發生碰撞的機率越高,陣列中的連結串列越容易長,造成查詢或插入時的比較次數增多,效能會下降。
負載因子越小,就越容易觸發擴容,資料密度也越小,意味著發生碰撞的機率越小,陣列中的連結串列也就越短,查詢和插入時比較的次數也越小,效能會更高。但是會浪費一定的內容空間。而且經常擴容也會影響效能,建議初始化預設大一點的空間。
按照其他語言的參考及研究經驗,會考慮將負載因子設定為0.7~0.75,此時平均檢索長度接近於常數

參考

https://zhuanlan.zhihu.com/p/72296421

https://juejin.im/post/5aa47ef2f265da23a0492cc8#heading-4

https://blog.csdn.net/zxt0601/article/details/77429150

https://tech.meituan.com/2016/06/24/java-hashmap.html

 https://blog.csdn.net/wangyi1225/article/details/99705173

https://blog.csdn.net/qq_36520235/article/details/82417949 1.7和1.8區別