Java HashMap原理及內部儲存結構

Mr羽墨青衫發表於2019-01-18

本文將通過如下簡單的程式碼來分析HashMap的內部資料結構的變化過程。

public static void main(String[] args) {
    Map<String, String> map = new HashMap<>();
    for (int i = 0; i < 50; i++) {
        map.put("key" + i, "value" + i);
    }
}
複製程式碼

1 資料結構說明

HashMap中本文需要用到的幾個欄位如下:

Java HashMap原理及內部儲存結構

下面說明一下幾個欄位的含義

1)table

// HashMap內部使用這個陣列儲存所有鍵值對
transient Node<K,V>[] table;
複製程式碼

Node的結構如下:

Java HashMap原理及內部儲存結構
可以發現,Node其實是一個連結串列,通過next指向下一個元素。

2)size

記錄了HashMap中鍵值對的數量

3)modCount

記錄了HashMap在結構上更改的次數,包括可以更改鍵值對數量的操作,例如put、remove,還有可以修改內部結構的操作,例如rehash。

4)threshold

記錄一個臨界值,當已儲存鍵值對的個數大於這個臨界值時,需要擴容。

5)loadFactor

負載因子,通常用於計算threshold,threshold=總容量*loadFactor。

2.new HashMap

new HashMap的原始碼如下:

/**
* The load factor used when none specified in constructor.
* 負載因子,當 已使用容量 > 總容量 * 負載因子 時,需要擴容
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
複製程式碼

此時,HashMap只初始化了負載因子(使用預設值0.75),並沒有初始化table陣列。 其實HashMap使用的是延遲初始化策略,當第一次put的時候,才初始化table(此時table是null)。

3.table陣列的初始化

當第一次put的時候,HashMap會判斷當前table是否為空,如果是空,會呼叫resize方法進行初始化。 resize方法會初始化一個容量大小為16 的陣列,並賦值給table。 並計算threshold=16*0.75=12。 此時table陣列的狀態如下:

Java HashMap原理及內部儲存結構

4.put過程

map.put("key0", "value0");
複製程式碼

首先計算key的hash值,hash("key0") = 3288451 計算這次put要存入陣列位置的索引值:index=(陣列大小 - 1) & hash = 3 判斷 if (table[index] == null) 就new一個Node放到這裡,此時為null,所以直接new Node放到3上,此時table如下:

Java HashMap原理及內部儲存結構
然後判斷當前已使用容量大小(size)是否已經超過臨界值threshold,此時size=1,小於12,不做任何操作,put方法結束(如果超過臨界值,需要resize擴容)。

繼續put。。。

map.put("key1", "value1");
複製程式碼

Java HashMap原理及內部儲存結構

map.put("key1", "value1");
map.put("key2", "value2");
map.put("key3", "value3");
map.put("key4", "value4");
map.put("key5", "value5");
map.put("key6", "value6");
map.put("key8", "value7");
map.put("key9", "value9");
map.put("key10", "value10");
map.put("key11", "value11");
複製程式碼

Java HashMap原理及內部儲存結構
此時size=12,下一次put後size為13,大於當前threshold,將觸發擴容(resize)

map.put("key12", "value12");
複製程式碼

計算Key的hash值,hash("key12")=101945043,計算要存入table位置的索引值 = (總大小 - 1) & hash = (16 - 1) & 101945043 = 3 從目前的table狀態可知,table[3] != null,但此時要put的key與table[3].key不相等,我們必須要把他存進去,此時就產生了雜湊衝突(雜湊碰撞)。

這時連結串列就派上用場了,HashMap就是通過連結串列解決雜湊衝突的。 HashMap會建立一個新的Node,並放到table[3]連結串列的最後面。 此時table狀態如下:

Java HashMap原理及內部儲存結構

5.resize擴容

此時table中一共有13個元素,已經超過了threshold(12),需要對table呼叫resize方法擴容。 HashMap會建立一個容量為之前兩倍(162=32)的table,並將舊的Node複製到新的table中,新的臨界值(threshold)為24(320.75)。

下面主要介紹一下複製的過程(並不是原封不動的複製,Node的位置可能發生變化)

先來看原始碼:

for (int j = 0; j < oldCap; ++j) { // oldCap:舊table的大小 =16
    Node<K,V> e;
    if ((e = oldTab[j]) != null) { // oldTab:舊table的備份
        oldTab[j] = null;
        // 如果陣列中的元素沒有後繼節點,直接計算新的索引值,並將Node放到新陣列中
        if (e.next == null)
            newTab[e.hash & (newCap - 1)] = e;
        // 忽略這個else if。其實,如果連結串列的長度超過8,HashMap會把這個連結串列變成一個樹結構,樹結構中的元素是TreeNode
        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;
                // 【說明1】
                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;
                //【說明2】
                newTab[j + oldCap] = hiHead;
            }
        }
    }
}
複製程式碼

【說明1】遍歷連結串列,計算連結串列每一個節點在新table中的位置。

計算位置的方式如下:

1)如果節點的 (hash & oldCap) == 0,那麼該節點還在原來的位置上,為什麼呢?

因為oldCap=16,二進位制的表現形式為0001 0000,任何數&16,如果等於0,那麼這個數的第五個二進位制位必然為0。

以當前狀態來說,新的容量是32,那麼table的最大index是31,31的二進位制表現形式是00011111。 計算index的方式是 hash & (容量 - 1),也就是說,新index的計算方式為 hash & (32 - 1) 假設Node的hash = 01101011,那麼

  01101011
& 00011111
----------
  00001011 = 11
複製程式碼

2)下面再對比(hash & oldCap) != 0的情況

如果節點的(hash & oldCap) != 0,那麼該節點的位置=舊index + 舊容量大小

假設Node的hash = 01111011,那麼

  01111011
& 00011111
----------
  00011011 = 27
複製程式碼

上一個例子的hash值01101011跟這個例子的hash值01111011只是在第5位二進位制上不同,可以發現,這兩個值在舊的table中,是在同一個index中的,如下:

  01101011
& 00001111
----------
  00001011 = 11
複製程式碼
  01111011
& 00001111
----------
  00001011 = 11
複製程式碼

由於擴容總是以2倍的方式進行,也就是:舊容量 << 1,這也就解釋了【說明2】,當(hash & oldCap) != 0時,這個Node的新index = 舊index + 舊容量大小。

擴容後,table狀態如下所示:

Java HashMap原理及內部儲存結構
最終,重新分配完所有的Node後,擴容結束。


歡迎關注我的微信公眾號

公眾號

相關文章