Java集合(6)之 HashMap 原始碼解析

TimberLiu發表於2019-02-23

HashMap 在日常開發中非常常用,它基於雜湊表實現,以 key-value 形式儲存。本文通過 JDK1.8 的原始碼,分析一下 HashMap 的內部結構和實現原理。

HashMap 概述

JDK1.7 之前,HashMap 底層由陣列 + 連結串列實現,也就是連結串列雜湊。當向 HashMap 中新增一個鍵值對時,首先計算 keyhash 值,以此確定插入陣列中的位置,但可能會碰撞衝突,將其轉換為連結串列儲存。

而從 JDK1.8 開始,增加了紅黑樹,由陣列 + 連結串列 + 紅黑樹實現,當連結串列長度超過 8 時,連結串列轉換為紅黑樹以提高效能。它的儲存方式如下:

Java集合(6)之 HashMap 原始碼解析

定義屬性

靜態常量

HashMap 的幾個靜態常量如下:

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {

    // 預設初始容量為 16
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;  
    
    // 最大容量為 2^30
    static final int MAXIMUM_CAPACITY = 1 << 30;        
    
    // 預設負載因子為 0.75
    static final float DEFAULT_LOAD_FACTOR = 0.75f;     
    
    // 預設連結串列中元素大於 8 時轉為紅黑樹
    static final int TREEIFY_THRESHOLD = 8;             
    
    // 擴容時,連結串列中元素小於這個值就會還原為連結串列
    static final int UNTREEIFY_THRESHOLD = 6;    
    
    // 陣列的容量大於 64 時才允許被樹形化
    static final int MIN_TREEIFY_CAPACITY = 64;
    ···
}
複製程式碼

重要變數

下面是 HashMap 中幾個重要的變數:

transient Node<K,V>[] table; // 儲存元素陣列
transient Set<Map.Entry<K,V>> entrySet; // 快取 entry 返回的 Set 
transient int size; // 鍵值對個數
transient int modCount; // 內部結構修改次數
int threshold; // 臨界值
final float loadFactor; // 負載因子
複製程式碼

Node<K,V>[] table

Node<K,V>[] table 陣列用來儲存具體的元素,是 HashMap 底層陣列和連結串列的組成元素。在第一次使用時初始化(預設初始化容量為 16),並在必要的時候進行擴容。

一般來說,由於素數導致衝突的概率較小,所以雜湊表陣列大小為素數。但 JavaHashMap 中採用非常規設計,陣列的長度總是 2n 次方,這樣做可以在取模和擴容時做優化,同時也能減少碰撞衝突。

NodeHashMap 的一個內部類,實現了 Map.Entry 介面,本質上就是一個對映(鍵值對)。它的實現如下:

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;   // 用來定位陣列索引位置
    final K key;      // 鍵
    V value;          // 值
    Node<K,V> next;   // 指向連結串列的下一個結點

    Node(int hash, K key, V value, Node<K,V> next) { ··· }
    public final K getKey()        { ··· }
    public final V getValue()      { ··· }
    public final String toString() { ··· }
    
    // 重寫了 hashCode 和 equals 方法
    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }
    public final V setValue(V newValue) { ··· }
    
    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;
    }
}
複製程式碼

entrySet

entrySet 用於快取 entrySet() 方法返回的 Set。後面會詳細分析。

size

sizeHashMap 中鍵值對的數量。注意,鍵值對的數量 size 和雜湊表陣列的長度 capacity不同。

modCount

modCount 用於記錄 HashMap 內部結構發生變化的次數,用於使用迭代器遍歷集合時修改內部結構,而快速失敗。需要注意的是,這裡指的是結構發生變化,例如增加或刪除一個鍵值對或者擴容,但是修改鍵值對的值不屬於結構變化。

threshold 和 loadFactor

thresholdHashMap 能容納的最大鍵值對個數,loadFactor 是負載因子,預設為 0.75。有如下等式(capacity 是陣列容量):

threshold = capacity * loadFactor;
複製程式碼

可以得出,在陣列長度定義好之後,負載因子越大,所能容納鍵值對越多。如果儲存元素個數大於 threshold,就要進行擴容,擴容後的容量是之前的兩倍。

TreeNode

當連結串列長度超過 8(閾值)時,將連結串列轉換為紅黑樹儲存,以提高查詢的效率。下面是 TreeNode 的定義:

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;  // 父節點
    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);
    }
    // 返回當前節點的根節點
    final TreeNode<K,V> root() {
        for (TreeNode<K,V> r = this, p;;) {
            if ((p = r.parent) == null)
                return r;
            r = p;
        }
    }
    ······
}
複製程式碼

構造方法

HashMap 主要提供了四種構造方法:

1). 構造一個預設初始容量 16 和預設載入因子 0.75 的空 HashMap

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; 
}
複製程式碼

2). 構造一個指定的初始容量和預設載入因子 0.75 的空 HashMap

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
複製程式碼

3). 構造一個指定的初始容量和載入因子的空 HashMap

public HashMap(int initialCapacity, float loadFactor) {
    // check
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}
複製程式碼

4). 使用給定的 map 構造一個新 HashMap

public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}
複製程式碼

基本方法

HashMap 內部功能實現很多,這裡主要從 hash 方法、put 方法、get 方法、resize 方法和 entrySet 方法進行分析。

hash 方法

HashMap 中,增刪改查都需要用 hash 演算法來計算元素在陣列中的位置,所以 hash 演算法是否均勻高效,對效能影響很大。看一下它的實現:

static final int hash(Object key) {
    int h;
    // 優化了高位運算演算法
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

// tab[i = (n - 1) & hash] 取模
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                boolean evict) {
    ···
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    ···
}
複製程式碼

hash 演算法計算物件的儲存位置,分為三步:取 keyhashCode 值、高位運算、取模運算。

由於取模元素消耗較大,HashMap 中用了一個很巧妙的方法,利用的就是底層陣列長度總是 2n 次方。通過 hash & (table.length - 1) 就可以得到物件的儲存位置,相較於對 length 取模效率更高。

JDK1.8 中優化了高位運算的演算法,通過 hashCode 的高 16 位異或低 16 位實現。下面舉例說明,ntable 的長度:

Java集合(6)之 HashMap 原始碼解析

put 方法

來看一下 HashMapput 方法:

public V put(K key, V value) {
    // 呼叫 hash 計算 key 的雜湊值
    return putVal(hash(key), key, value, false, true);
}
複製程式碼
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 如果 table 為空或長度為 0,則呼叫 resize 進行擴容
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 根據 key 的 hash 計算陣列索引值,如果當前位置為 null,則直接建立新節點插入
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        // table[i] 不為空
        Node<K,V> e; K k;
        // 如果 table[i] 的首元素和傳入的 key 相等(hashCode 和 equals),則直接覆蓋,這裡容許 key 和 value 為 null
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 判斷 table[i] 是否為 treeNode,即 table[i] 是否為紅黑樹,如果是則在樹中插入
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 否則遍歷連結串列
        else {
            for (int binCount = 0; ; ++binCount) {
                // 如果 key 不存在
                if ((e = p.next) == null) {
                    // 則新建一個結點
                    p.next = newNode(hash, key, value, null);
                    // 如果長度大於8,則轉為紅黑樹處理
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    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 方法的幾個步驟::

  • 判斷雜湊表陣列 table[] 為空或者長度為 0,如果是則呼叫 resize() 進行擴容;
  • 通過 hash & (table.length - 1) 計算插入的陣列索引值,如果當前位置為 null,則直接建立節點插入
  • 判斷 table[i] 的首個元素是否和 key 相等(hashCodeequals),如果相等則直接覆蓋 value
  • 判斷 table[i] 是否為 treeNode,即 table[i] 是否是紅黑樹,如果是紅黑樹,則直接在樹中插入鍵值對;
  • 否則遍歷連結串列,如果 key 不存在,則直接建立節點插入,並判斷連結串列長度是否大於 8,如果是紅黑樹則轉為紅黑樹處理;如果遍歷中發現 key 已經存在,則直接覆蓋即可;
  • 插入成功後,判斷實際存在鍵值對是否超過了最大容量,如果是則進行擴容;

HashMapput 方法可以通過下圖理解:

Java集合(6)之 HashMap 原始碼解析

get 方法

來看一下 HashMapget 方法:

public V get(Object key) {
    Node<K,V> e;
    // 呼叫 getNode 方法,如果通過 key 獲取的 Node 為 null,則返回 null;否則返回 node.value
    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;
    // 如果陣列不為空,陣列長度大於 0
    // 通過 hash & (length - 1) 計算陣列的索引值,並且對應的位置不為 null
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        // 如果桶中第一個元素與 key 相等,則直接返回
        if (first.hash == hash && 
            ((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;
}
複製程式碼

resize 方法

下面來分析一下 resize 方法的原始碼:

final Node<K,V>[] resize() {
    // 儲存原先的陣列、容量、臨界值
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    // 如果擴容前容量 > 0
    if (oldCap > 0) {
        // 如果陣列大小已經達到最大 2^30,則修改閾值為最大值 2^31-1,以後也就不會再擴容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 如果沒有超過最大值,就擴充為原來的 2 倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; 
    } 
    else if (oldThr > 0) // 如果擴容前容量 <= 0,舊臨界值 > 0
        // 將陣列的新容量設定為 舊陣列擴容的臨界值
        newCap = oldThr;
    else { // 容量 <= 0,舊臨界值 <= 0          
        // 否則設定為預設值
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 計算新的臨界值
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    
    // 建立新的 table,容量為 newCap
    @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;
                // 如果舊桶中只有一個 node
                if (e.next == null)
                    // 則將 oldTab[j] 放入新雜湊表中 e.hash & (newCap - 1) 的位置
                    newTab[e.hash & (newCap - 1)] = e;
                // 如果舊桶中為紅黑樹,則轉換處理
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { 
                    Node<K,V> loHead = null, loTail = null; // 將下標不變的節點組織成一條連結串列
                    Node<K,V> hiHead = null, hiTail = null; // 將下標增加 oldCapaciry 的節點組織成另一條連結串列
                    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 {
                            // 原索引 + oldCap
                            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;
}
複製程式碼

resize 方法在擴容時,由於每次陣列的長度變為原先的 2 倍,所以元素要麼在原位置,要麼在“原始位置 + 原陣列長度”的位置。通過計算 e.hash & oldCap 來判斷是否需要移動。

看下圖,ntable 的長度,圖 (a) 為擴容前的 key1key2 確定索引位置的示例,圖 (b) 為擴容後的 key1key2 確定索引位置的示例,其中 key1(hash1)key1 對應的雜湊與高位運算的結果:

Java集合(6)之 HashMap 原始碼解析

元素在重新計算 hash 後,因為 n 變為 2 倍,那麼 n - 1mask 的範圍(紅色)在高位多 1bit,因此新的 index 就會這樣變化:

Java集合(6)之 HashMap 原始碼解析

因此,在擴容時,只需看看原來的 hash 值新增的 bit 位是 1 還是 0,如果是 0,索引不變,否則變成 "原索引 + oldCapacity",可以看看下圖 16 擴充為 32 的示意圖:

Java集合(6)之 HashMap 原始碼解析

entrySet 方法

HashMap 的一種遍歷方式就是使用 entrySet 方法返回的迭代器進行遍歷。先來看一下 entrySet 方法:

public Set<Map.Entry<K,V>> entrySet() {
    Set<Map.Entry<K,V>> es;
    return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}
複製程式碼

可以看到,如果快取 map 中鍵值對的 Set 不為 null,則直接返回,否則會建立一個 EntrySet 物件。

EntrySet 類的 iterator 方法會返回一個 EntryIterator 迭代器物件,另外還有兩個迭代器 KeyIteratorValueIterator

final class EntryIterator extends HashIterator
        implements Iterator<Map.Entry<K,V>> {
    public final Map.Entry<K,V> next() { return nextNode(); }
}

final class KeyIterator extends HashIterator
        implements Iterator<K> {
    public final K next() { return nextNode().key; }
}
final class ValueIterator extends HashIterator
    implements Iterator<V> {
    public final V next() { return nextNode().value; }
}
複製程式碼

它們三個都繼承自 HashIterator,分別用於鍵遍歷、值遍歷、鍵值對遍歷,它們都重寫了 Iteratornext 方法,其中呼叫了 HashIteratornextNode 方法。

HashIterator 是一個抽象類,實現了迭代器的大部分方法:

abstract class HashIterator {
    Node<K,V> next;        // next entry to return
    Node<K,V> current;     // current entry
    int expectedModCount;  // for fast-fail
    int index;             // current slot

    HashIterator() {
        expectedModCount = modCount;
        Node<K,V>[] t = table;
        current = next = null;
        index = 0;
        if (t != null && size > 0) { // advance to first entry
            do {} while (index < t.length && (next = t[index++]) == null);
        }
    }

    public final boolean hasNext() {
        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)
            throw new NoSuchElementException();
        if ((next = (current = e).next) == null && (t = table) != null) {
            do {} while (index < t.length && (next = t[index++]) == null);
        }
        return e;
    }

    public final void remove() { ··· }
}
複製程式碼

可以看出 HashIterator 迭代器的預設構造器中,將 current 設定為 null,然後迴圈在陣列中查詢不為 null 的桶, 讓 next 指向第一個桶中的第一個節點 Node

在遍歷時,next 方法會呼叫 nextNode() 方法,這個方法首先把 next 賦給 e 以稍後返回,並把 e 賦給 current。然後判斷 next 是否為空,如果不為空,返回 e 即可。

如果為空,就在陣列中繼續查詢不為空的桶,找到後退出迴圈,最後返回 e。這樣就能都遍歷出來了。

小結

HashMap 的特點主要有:

  • HashMap 根據鍵的 hashCode 值來儲存資料,大多數情況下可以直接定位它的值,因而訪問速度很快。
  • HashMap 不保證插入的順序。
  • 擴容是一個特別耗能的操作,在使用 HashMap 時,最好估算 map 的大小,初始化時給定一個大致的數值,避免進行頻繁的擴容。
  • threshold = capacity * loadFactor; 如果儲存元素個數大於 threshold,就要進行擴容,擴容後的容量是之前的兩倍。
  • 預設的負載因子 0.75 是時間和空間之間的一個平衡,一般不建議修改。
  • HashMapkeyvalue 允許為 null,最多允許一條記錄的鍵為 null,允許多條記錄的值為 null
  • 它是非執行緒安全的。如果需要執行緒安全,可以使用 CollectionssynchronizedMap 方法使 HashMap 具有執行緒安全的能力,或使用 ConcurrentHashMap

參考資料

相關文章