HashMap實現原理

語言: CN / TW / HK

highlight: atom-one-dark theme: vuepress


小知識,大挑戰!本文正在參與“程式設計師必備小知識”創作活動 記錄Java HashMap底層資料結構、方法實現原理等,基於JDK 1.8。

底層資料結構

Java HashMap底層採用雜湊表結構(陣列+連結串列、JDK1.8後為陣列+連結串列或紅黑樹)實現,結合了陣列和連結串列的優點:

  1. 陣列優點:通過陣列下標可以快速實現對陣列元素的訪問,效率極高;
  2. 連結串列優點:插入或刪除資料不需要移動元素,只需修改節點引用,效率極高。

HashMap圖示如下所示:

hashmap01.png HashMap內部使用陣列儲存資料,陣列中的每個元素型別為Node<K,V>

```java static class Node implements Map.Entry { final int hash; final K key; V value; Node next;

Node(int hash, K key, V value, Node<K,V> next) {
    this.hash = hash;
    this.key = key;
    this.value = value;
    this.next = next;
}

public final K getKey()        { return key; }
public final V getValue()      { return value; }
public final String toString() { return key + "=" + value; }

public final int hashCode() {
    return Objects.hashCode(key) ^ Objects.hashCode(value);
}

public final V setValue(V newValue) {
    V oldValue = value;
    value = newValue;
    return oldValue;
}

public final boolean equals(Object o) {
    if (o == this)
        return true;
    if (o instanceof Map.Entry) {
        Map.Entry<?,?> e = (Map.Entry<?,?>)o;
        if (Objects.equals(key, e.getKey()) &&
            Objects.equals(value, e.getValue()))
            return true;
    }
    return false;
}

} ```

Node包含了四個欄位:hash、key、value、next,其中next表示連結串列的下一個節點。

HashMap通過hash方法計算key的雜湊碼,然後通過(n-1) & hash公式(n為陣列長度)得到key在陣列中存放的下標。當兩個key在陣列中存放的下標一致時,資料將以連結串列的方式儲存(雜湊衝突,雜湊碰撞)。我們知道,在連結串列中查詢資料必須從第一個元素開始一層一層往下找,直到找到為止,時間複雜度為O(N),所以當連結串列長度越來越長時,HashMap的效率越來越低。

為了解決這個問題,JDK1.8開始採用陣列+連結串列+紅黑樹的結構來實現HashMap。當連結串列中的元素超過8個(TREEIFY_THRESHOLD)並且陣列長度大於64(MIN_TREEIFY_CAPACITY)時,會將連結串列轉換為紅黑樹,轉換後資料查詢時間複雜度為O(logN)。

紅黑樹的節點使用TreeNode表示:

java static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> { TreeNode<K,V> parent; // red-black tree links TreeNode<K,V> left; TreeNode<K,V> right; TreeNode<K,V> prev; // needed to unlink next upon deletion boolean red; TreeNode(int hash, K key, V val, Node<K,V> next) { super(hash, key, val, next); } ... }

HashMap包含幾個重要的變數:

```java // 陣列預設的初始化長度16 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

// 陣列最大容量,2的30次冪,即1073741824 static final int MAXIMUM_CAPACITY = 1 << 30;

// 預設負載因子值 static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 連結串列轉換為紅黑樹的長度閾值 static final int TREEIFY_THRESHOLD = 8;

// 紅黑樹轉換為連結串列的長度閾值 static final int UNTREEIFY_THRESHOLD = 6;

// 連結串列轉換為紅黑樹時,陣列容量必須大於等於64 static final int MIN_TREEIFY_CAPACITY = 64;

// HashMap裡鍵值對個數 transient int size;

// 擴容閾值,計算方法為 陣列容量*負載因子 int threshold;

// HashMap使用陣列存放資料,陣列元素型別為Node transient Node[] table;

// 負載因子 final float loadFactor;

// 此雜湊對映在結構上被修改的次數 // 用於快速失敗,由於HashMap非執行緒安全,在對HashMap進行迭代時,如果期間其他執行緒的參與導致HashMap的 // 結構發生變化了(比如put,remove等操作),直接丟擲ConcurrentModificationException異常 transient int modCount; ```

上面這些欄位在下面原始碼解析的時候尤為重要,其中需要著重討論的是負載因子是什麼,為什麼預設值為0.75f。

負載因子也叫擴容因子,用於決定HashMap陣列何時進行擴容。比如陣列容量為16,負載因子為0.75,那麼擴容閾值為16*0.75=12,即HashMap資料量大於等於12時,陣列就會進行擴容。我們都知道,陣列容量的大小在建立的時候就確定了,所謂的擴容指的是重新建立一個指定容量的陣列,然後將舊值複製到新的數組裡。擴容這個過程非常耗時,會影響程式效能。所以負載因子是基於容量和效能之間平衡的結果:

  • 當負載因子過大時,擴容閾值也變大,也就是說擴容的門檻提高了,這樣容量的佔用就會降低。但這時雜湊碰撞的機率就會增加,效率下降;
  • 當負載因子過小時,擴容閾值變小,擴容門檻降低,容量佔用變大。這時候雜湊碰撞的機率下降,效率提高。

可以看到容量佔用和效能是此消彼長的關係,它們的平衡點由負載因子決定,0.75是一個即兼顧容量又兼顧效能的經驗值。

此外用於儲存資料的table欄位使用transient修飾,通過transient修飾的欄位在序列化的時候將被排除在外,那麼HashMap在序列化後進行反序列化時,是如何恢復資料的呢?HashMap通過自定義的readObject/writeObject方法自定義序列化和反序列化操作。這樣做主要是出於以下兩點考慮:

  1. table一般不會存滿,即容量大於實際鍵值對個數,序列化table未使用的部分不僅浪費時間也浪費空間;
  2. key對應的型別如果沒有重寫hashCode方法,那麼它將呼叫Object的hashCode方法,該方法為native方法,在不同JVM下實現可能不同;換句話說,同一個鍵值對在不同的JVM環境下,在table中儲存的位置可能不同,那麼在反序列化table操作時可能會出錯。

所以在HashXXX類中(如HashTable,HashSet,LinkedHashMap等等),我們可以看到,這些類用於儲存資料的欄位都用transient修飾,並且都自定義了readObject/writeObject方法。readObject/writeObject方法這節就不進行原始碼分析了,有興趣自己研究。

put原始碼

put方法原始碼如下:

java public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }

put方法通過hash函式計算key對應的雜湊值,hash函式原始碼如下:

java static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }

如果key為null,返回0,不為null,則通過(h = key.hashCode()) ^ (h >>> 16)公式計算得到雜湊值。該公式通過hashCode的高16位異或低16位得到雜湊值,主要從效能、雜湊碰撞角度考慮,減少系統開銷,不會造成因為高位沒有參與下標計算從而引起碰撞。

得到key對應的雜湊值後,再呼叫putVal(hash(key), key, value, false, true)方法插入元素:

java final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; // 如果陣列(雜湊表)為null或者長度為0,則進行陣列初始化操作 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 根據key的雜湊值計算出資料插入陣列的下標位置,公式為(n-1) & hash if ((p = tab[i = (n - 1) & hash]) == null) // 如果該下標位置還沒有元素,則直接建立Node物件,並插入 tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; // 如果目標位置key已經存在,則直接覆蓋 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; // 如果目標位置key不存在,並且節點為紅黑樹,則插入紅黑樹中 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { // 否則為連結串列結構,遍歷連結串列,尾部插入 for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); // 如果連結串列長度大於等於TREEIFY_THRESHOLD,則考慮轉換為紅黑樹 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); // 轉換為紅黑樹操作,內部還會判斷陣列長度是否小於MIN_TREEIFY_CAPACITY,如果是的話不轉換 break; } // 如果連結串列中已經存在該key的話,直接覆蓋替換 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } if (e != null) { // existing mapping for key // 返回被替換的值 V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } // 模數遞增 ++modCount; // 當鍵值對個數大於等於擴容閾值的時候,進行擴容操作 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }

put操作過程總結:

  1. 判斷HashMap陣列是否為空,是的話初始化陣列(由此可見,在建立HashMap物件的時候並不會直接初始化陣列);

  2. 通過(n-1) & hash計算key在陣列中的存放索引;

  3. 目標索引位置為空的話,直接建立Node儲存;

  4. 目標索引位置不為空的話,分下面三種情況:

    4.1. key相同,覆蓋舊值;

    4.2. 該節點型別是紅黑樹的話,執行紅黑樹插入操作;

    4.3. 該節點型別是連結串列的話,遍歷到最後一個元素尾插入,如果期間有遇到key相同的,則直接覆蓋。如果連結串列長度大於等於TREEIFY_THRESHOLD,並且陣列容量大於等於MIN_TREEIFY_CAPACITY,則將連結串列轉換為紅黑樹結構;

  5. 判斷HashMap元素個數是否大於等於threshold,是的話,進行擴容操作。

get原始碼

get和put相比,就簡單多了,下面是get操作原始碼:

```java public V get(Object key) { Node e; return (e = getNode(hash(key), key)) == null ? null : e.value; }

final Node getNode(int hash, Object key) { Node[] tab; Node first, e; int n; K k; // 判斷陣列是否為空,陣列長度是否大於0,目標索引位置下元素是否為空,是的話直接返回null 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)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; } ```

resize原始碼

由前面的put原始碼分析我們知道,陣列的初始化和擴容都是通過呼叫resize方法完成的,所以現在來關注下resize方法的原始碼:

java 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; } // 擴大容量為當前容量的兩倍,但不能超過 MAXIMUM_CAPACITY 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 { // 如果初始化的值為 0,則使用預設的初始化容量,預設值為16 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; // 原資料不為空,將原資料複製到新 table 中 if (oldTab != null) { // 根據容量迴圈陣列,複製非空元素到新 table 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 // 連結串列複製,JDK 1.8 擴容優化部分 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; } // 原索引 + oldCap 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; } // 將原索引 + oldCap 放到雜湊桶中 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }

JDK1.8在擴容時通過高位運算e.hash & oldCap結果是否為0來確定元素是否需要移動,主要有如下兩種情況:

情況一:

擴容前oldCap=16,hash=5,(n-1)&hash=15&5=5hash&oldCap=5&16=0

擴容後newCap=32,hash=5,(n-1)&hash=31&5=5hash&oldCap=5&16=0

這種情況下,擴容後元素索引位置不變,並且hash&oldCap==0。

情況二:

擴容前oldCap=16,hash=18,(n-1)&hash=15&18=2hash&oldCap=18&16=16

擴容後newCap=32,hash=18,(n-1)&hash=31&18=18hash&oldCap=18&16=16

這種情況下,擴容後元素索引位置為18,即舊索引2加16(oldCap),並且hash&oldCap!=0。

遍歷原理

我們通常使用下面兩種方式遍歷HashMap:

```java HashMap map = new HashMap<>(); map.put("1", "a"); map.put("4", "d"); map.put("2", "b"); map.put("9", "i"); map.put("3", "c");

Set> entries = map.entrySet(); for (Map.Entry entry : entries) { System.out.println(entry.getKey() + ": " + entry.getValue()); }

System.out.println("-------");

Set keySet = map.keySet(); for (String key : keySet) { System.out.println(key + ": " + map.get(key)); } ```

程式輸出:

```java 1: a 2: b 3: c 4: d 9: i


1: a 2: b 3: c 4: d 9: i ```

通過前面對put原始碼的分析,我們知道HashMap是無序的,輸出元素順序和插入元素順序一般都不一樣。但是多次執行上面的程式你會發現,每次遍歷的順序都是一樣的。那麼遍歷的原理是什麼,內部是如何操作的?

通過entrySet或者keySet遍歷,它們的內部原理是一樣的,這裡以entrySet為例。

通過檢視程式碼對應的class檔案,你會發現下面這段程式碼實際會被轉換為iterator遍歷:

java Set<Map.Entry<String, Object>> entries = map.entrySet(); for (Map.Entry<String, Object> entry : entries) { System.out.println(entry.getKey() + ": " + entry.getValue()); }

增強for迴圈會被編譯為:

```java Set> entries = map.entrySet(); Iterator var3 = entries.iterator();

while(var3.hasNext()) { Entry entry = (Entry)var3.next(); System.out.println((String)entry.getKey() + ": " + entry.getValue()); } ```

我們檢視entrySet,iterator,hasNext,next方法的原始碼就可以清楚的瞭解到HashMap遍歷原理了:

```java public Set> entrySet() { Set> es; // entrySet一開始為null,通過new EntrySet()建立 return (es = entrySet) == null ? (entrySet = new EntrySet()) : es; }

final class EntrySet extends AbstractSet> { public final int size(){ return size; } public final void clear(){ HashMap.this.clear(); } // EntrySet內部包含迭代器方法,方法內部通過new EntryIterator()建立Entry迭代器 public final Iterator> iterator() { return new EntryIterator(); } ...... }

// EntryIterator繼承自HashIterator,呼叫EntryIterator的hasNext方法實際呼叫的是 // 父類HashIterator的hashNext方法,呼叫EntryIterator的next方法,方法內部呼叫的是父類HashIterator // 的nextNode方法,所以我們主要關注HashIterator的原始碼 final class EntryIterator extends HashIterator implements Iterator> { public final Map.Entry next() { return nextNode(); } }

abstract class HashIterator { Node next; // 下一個節點 Node current; // 當前節點 int expectedModCount; // 期待的模數值,用於快速失敗 int index; // 當前遍歷的table index

HashIterator() {
    // 將當前模數值賦值給期待的模數值,所以在遍歷的時候,別的執行緒呼叫了當前hashMap例項的
    // 增刪改方法,模數值會改變,那麼expectedModCount和modCount就不相等了,遍歷操作直接
    // 丟擲ConcurrentModificationException
    expectedModCount = modCount;
    Node<K,V>[] t = table;
    current = next = null;
    // 從hashMap陣列頭部開始遍歷
    index = 0;
    if (t != null && size > 0) { // advance to first entry
        // 從陣列頭部開始找,index遞增,當index位置的節點不為空時,將其賦值給next
        // 也就是說,在建立hashMap迭代器的時候,內部就已經找到了hashMap陣列中第一個非空節點了
        do {} while (index < t.length && (next = t[index++]) == null);
    }
}

public final boolean hasNext() {
    // 邏輯很簡單,就是判斷next是否為空
    return next != null;
}

final Node<K,V> nextNode() {
    Node<K,V>[] t;
    Node<K,V> e = next;
    if (modCount != expectedModCount)
        // 模數判斷
        throw new ConcurrentModificationException();
    if (e == null)
        // 如果next為空了,還呼叫nextNode方法的話,將丟擲NoSuchElementException異常
        throw new NoSuchElementException();
    // 這段邏輯也很簡單,主要包含如下兩種情況:
    // 1. 如果當前節點的next節點為空的話,說明該節點無需進行連結串列遍歷了(就一個節點或者已經到了連結串列的末尾),那麼進行do while迴圈,直到找到hashMap陣列中下一個不為空的節點
    // 2. 如果當前節點的next節點不為空的話,說明該位置存在連結串列,那麼外界在迴圈呼叫iterator的next方法時,實際就是不斷呼叫nextNode方法遍歷連結串列操作
    if ((next = (current = e).next) == null && (t = table) != null) {
        do {} while (index < t.length && (next = t[index++]) == null);
    }
    return e;
}
......

} ```

總之,遍歷HashMap的過程就是從頭查詢HashMap陣列中的不為空的結點,如果該結點下存在連結串列,則遍歷該連結串列,遍歷完連結串列後再找HashMap陣列中下一個不為空的結點,以此進行下去直到遍歷結束。

那麼,如果某個結點下是紅黑樹結構的話,怎麼遍歷?其實當連結串列轉換為紅黑樹時,連結串列節點裡包含的next欄位資訊是保留的,所以我們依舊可以通過紅黑樹節點中的next欄位找到下一個節點。

與JDK1.7主要區別

陣列元素型別不同

JDK1.8 HashMap陣列元素型別為Node<K,V>,JDK1.7 HashMap陣列元素型別為Entry<K,V>

```java transient Entry[] table = (Entry[]) EMPTY_TABLE;

static class Entry implements Map.Entry { final K key; V value; Entry next; int hash;

......

} ```

實際就是換了個類名,並沒有什麼本質不同。

hash計算規則不同

JDK1.7 hash計算規則為:

```java final int hash(Object k) { int h = hashSeed; if (0 != h && k instanceof String) { return sun.misc.Hashing.stringHash32((String) k); }

h ^= k.hashCode();

// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);

} ```

相比於JDK1.8的hash方法,JDK1.7的hash方法的效能會稍差一點。

put操作不同

JDK1.7並沒有使用紅黑樹,如果雜湊衝突後,都用連結串列解決。區別於JDK1.8的尾部插入,JDK1.7採用頭部插入的方式:

```java public V put(K key, V value) {
// 鍵為null,將元素放置到table陣列的0下標處 if (key == null)
return putForNullKey(value); // 計算hash和陣列下標索引位置 int hash = hash(key.hashCode());
int i = indexFor(hash, table.length);
// 遍歷連結串列,當key一致時,說明該key已經存在,使用新值替換舊值並返回 for (Entry e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++; // 插入連結串列 addEntry(hash, key, value, i);
return null;
}

private V putForNullKey(V value) { // 一樣的,新舊值替換 for (Entry e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
// 插入到陣列下標為0位置 addEntry(0, null, value, 0);
return null;
}

void addEntry(int hash, K key, V value, int bucketIndex) { // 新值頭部插入,原先頭部變成新的頭部元素的next Entry e = table[bucketIndex]; table[bucketIndex] = new Entry(hash, key, value, e); // 計數,擴容 if (size++ >= threshold) resize(2 * table.length); } ```

擴容操作不同

JDK1.8在擴容時通過高位運算e.hash & oldCap結果是否為0來確定元素是否需要移動,JDK1.7重新計算了每個元素的雜湊值,按舊連結串列的正序遍歷連結串列、在新連結串列的頭部依次插入,即在轉移資料、擴容後,容易出現連結串列逆序的情況:

```java void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return; }

Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);

}

/* * Transfers all entries from current table to newTable. / void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; for (Entry e : table) { while(null != e) { Entry next = e.next; if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } } } ```