java集合原始碼分析(六):HashMap

Createsequence發表於2020-12-16

概述

HashMap 是 Map 介面下一個執行緒不安全的,基於雜湊表的實現類。由於他解決雜湊衝突的方式是分離連結串列法,也就是拉鍊法,因此他的資料結構是陣列+連結串列,在 JDK8 以後,當雜湊衝突嚴重時,HashMap 的連結串列會在一定條件下轉為紅黑樹以優化查詢效能,因此在 JDK8 以後,他的資料結構是陣列+連結串列+紅黑樹。

對於 HashMap ,作為集合容器,我們需要關注其資料的儲存結構,迭代方式,能否存放空值;作為使用了陣列作為底層結構的集合,我們還需要關注其擴容的實現;同時,針對雜湊表的特性,我們還需要關注它如何通過雜湊演算法取模快速定位下標。

這是關於 java 集合類原始碼的第六篇文章。往期文章:

  1. java集合原始碼分析(一):Collection 與 AbstractCollection
  2. java集合原始碼分析(二):List與AbstractList
  3. java集合原始碼分析(三):ArrayList
  4. java集合原始碼分析(四):LinkedList
  5. java集合原始碼分析(五):Map與AbstractMap

一、HashMap 的資料結構

HashMap的資料結構

在 JDK8 之前,HashMap 的資料結構是陣列+連結串列。在 JDK8 以後是陣列 + 連結串列 + 紅黑樹

在 HashMap 中,每一個 value 都被儲存在一個 Node 或 TreeNode 例項中,容器中有一個 Node[] table 陣列成員變數,陣列中的每一格稱為一個“桶”。當新增元素時,根據元素的 key 通過雜湊值計算得到對應下標,將 Node 類的形式存入“桶”中。如果 table 容量不足時,就會發生擴容,同時對容器內部的元素進行重雜湊。

當發生雜湊衝突,也就是不同元素計算得到了相同的下標時,會將節點接到“桶”的中的第一個元素後,後續操作亦同,最後就會形成連結串列。

在 JDK8 以後,由於考慮到雜湊衝突嚴重時,“桶”中的連結串列會影響查詢效率,因此在一定條件下,連結串列元素多到一定程度,Node 就會轉為 TreeNode,也就是把連結串列轉為紅黑樹

對於紅黑樹,可以簡單理解為不要求嚴格平衡的平衡二叉樹,他保證了查詢效率的同時,又保持了較低的的旋轉次數。通過這種資料結構,保證了雜湊衝突嚴重的情況下的查詢效率。

二、HashMap的成員變數

由於 HashMap 本身繼承了 AbstractMap 抽象類的成員變數,再加上自身的成員變數,以及由於擴容時的重雜湊需要的引數,因此 HashMap 的成員變數比較複雜。按照來源以及用途,我們將他的成員變數分為三類:

1.來自父類的變數

/**
 * 1.存放key的Set集合檢視,通過 keySet()方法獲取
 */
transient Set<K> keySet;

/**
 * 1.存放value的Collection集合檢視,通過values()方法獲取
 */
transient Collection<V> values;

2.自己的變數

/**
 * 1.結構更改次數。用於實現併發修改情況下的fast-fail機制,同AbstractList
 */
transient int modCount;

/**
 * 2.集合中的元素個數
 */
transient int size;

/**
 * 3.存放集合中鍵值對物件Entry的Set集合檢視,通過entrySet()獲取
 */
transient Set<Map.Entry<K,V>> entrySet;

/**
 * 4.集合中的桶陣列。桶即是當連結串列或者紅黑樹的容器
 */
transient Node<K,V>[] table;

3.擴容相關的變數和常量

/**
 * 1.預設初始容量。必須為2的冪,預設為16
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

/**
 * 2.最大容量。不能超過1073741824,即Integer.MAX_VALUE的一半
 */
static final int MAXIMUM_CAPACITY = 1 << 30;

/**
 * 3.擴容閾值。負載係數與容量的乘積,當元素個數超過該值則擴容。預設為0
 */
int threshold;

/**
 * 4.負載係數。當容器內元素數量/容器容量大於等於該值時發生擴容
 */
final float loadFactor;

/**
 * 5.預設負載係數。未在建構函式中指定則預設為0.75
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

/**
 * 6.容器中桶的最小樹化閾值。當容器中元素個數大於等於該值時,桶才會發生樹化。
 */
static final int MIN_TREEIFY_CAPACITY = 64;

/**
 * 7.桶的樹化閾值。當容器元素個數大於等於MIN_TREEIFY_CAPACITY,並且桶中元素個數大於等於該值以後,將連結串列轉為紅黑樹
 */
static final int TREEIFY_THRESHOLD = 8;

/**
 * 8.桶的鏈化閾值。當桶中元素個數,或者說連結串列長度小於等於該值以後,將紅黑樹轉為連結串列
 */
static final int UNTREEIFY_THRESHOLD = 6;

三、構造方法

HashMap 一共提供了四個構造方法:

1.指定容量和負載係數

public HashMap(int initialCapacity, float loadFactor) {
    // 指定初始容量是否小於0
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    // 若指定初始容量大於最大容量,則初始容量為最大容量
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    // 初始容量是否為小於0或未初始化
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    // 指定初始容量
    this.loadFactor = loadFactor;
    // 下一擴容大小為loadFactor或最接近的2的冪
    this.threshold = tableSizeFor(initialCapacity);
}

這裡涉及到一個取值的方法 tableSizeFor()

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

這個方法的用於得到指定容量最接近的2的冪,比如傳入1會得到2,傳入7會得到8。

2.只指定容量

public HashMap(int initialCapacity) {
    // 使用預設負載係數0.75
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

3.不指定任何係數

public HashMap() {
    // 下一擴容大小為預設大小16,其負載係數預設為0.75,初始容量預設為16
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

4.根據指定Map集合構建

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

這裡涉及到一個將合併集合的方法 putMapEntries()putAll()方法也是基於這個方法實現的。由於新增還涉及到擴容以及其他方法,這裡暫不介紹,等下面再詳細的瞭解。

四、HashMap的內部類

基於前文java集合原始碼分析(五):Map與AbstractMap中第五部分 “AbstractMap 的檢視”裡對 AbstractMap 的分析,我們知道,HashMap 作為繼承了 AbstractMap 的子類,因此它內部會擁有三個集合檢視

  • 存放 key 的 Set 集合:Set<K> keySet
  • 存放 value 的 Collection 集合:Collection<V> valuse
  • 存放 Entry 物件的 Set 集合:Set<Map.Entry<K,V>> entrySet

同時還需要一個實現了 Entry 介面的內部類作為 entrySet 的元素使用。

因此 HashMap 作為 AbstractMap 的子類,他最少需要 3種集合檢視 + 3種結合檢視的迭代器 + Entry 實現類 7種內部類。

實際上,由於 JDK8 以後紅黑樹和並行迭代的需求,他還需要新增 1種Entry紅黑樹節點實現 + 3種檢視容器對應的並行迭代器2種內部類。

由於針對迭代器和並行迭代器又各提取了一個抽象類,所以 HashMap 中一共會有 :

3種檢視容器 + 1種迭代器抽象類 + 3種檢視容器的迭代器 + 1種並行迭代器抽象類 + 3種檢視容器對應的並行迭代器 + 1種Entry實現類 + 1種Entry的紅黑樹節點實現類

總計 13 種內部類

1. Node / TreeNode

HashMap的資料結構

Node 是 HashMap 中的節點類,在 JDK8 之前對應的是 Entry 類。他是 Map 介面中 Entry 的實現類。

在 HashMap 中陣列的每一個位置都是一個“桶”,而“桶”中存放的就是帶有資料的節點物件 Node。當雜湊衝突時,多個 Node 會在同一個“桶”中形成連結串列。

static class Node<K,V> implements Map.Entry<K,V> {
    // 節點的hashcode
    final int hash;
    // key
    final K key;
    // value
    V value;
    // 下一節點
    Node<K,V> next;
}

在 JDK8,當容器中元素數量大於等於64,並且桶中節點大於等於8的時候,會在擴容前觸發紅黑樹化,Node 類會被轉變為 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;
    // 是否為紅色節點
    boolean red;
}

值得一提的是,TreeNode 繼承了 LinkedHashMap.Entry 類,但是 LinkedHashMap.Entry 類又繼承了 HashMap.Node,因此,實際上 TreeNode 也是 Node 類的子類,這是 Node 轉變為 TreeNode 的結構基礎。

另外,TreeNode 儘管是樹,但是他仍然通過 prev 維持了隱式的連結串列結構,理論上每一個節點都可以獲取他上一次插入的節點,這仍然可以理解為單向連結串列。

2. KeySet / KeyIterator

Set<K> keySet 是在 AbstractMap 中已經定義好了變數,它是一個存放 key 的集合,HashMap 的雜湊演算法保證了 key 的唯一性,這恰好也符合 Set 集合的特徵。在 HashMap 中,為其提供了實現類 KeySet

KeySet 繼承了 AbstractSet 抽象類,並且直接使用 HashMap 中的方法去實現了抽象類中的大多數抽象方法。值得一提的是,他實現的 iterator()返回的也是 HashMap 的一個內部類 KeyIterator

3. Values / ValueIterator

和 KeySet 類一樣,Values 也是給 AbstractMap 中的 Collection<V> values 提供的實現類,他繼承了 AbstractCollection 抽象類,並且使用 HashMap 的方法實現了大部分抽象方法。

同樣的,它的iterator()返回的也是 HashMap 的一個內部類 ValueIterator

4. EntrySet / EntryIterator

AbstractMap 中有一個留給子類去實現的核心抽象方法 entrySet(),而 EntrySet 就是為了實現該方法而建立的類。它繼承了 AbstractSet<Map.Entry<K,V>>表示的是容器中的一對鍵值對物件。在註釋中,作者將其稱為檢視。

通過 EntrySet 類,我們就可以像 Collection 的 toArray 一樣,將 Map 以 Set 集合檢視的形式表現出來

同樣的,作為一個 AbstractSet 的實現類,HashMap 也專門為其實現了一個內部迭代器類 EntryIterator 。EntrySet 的iterator()方法返回的就是該類。

5. HashIterator

HashIterator 類是一個用於迭代 Node 節點的迭代器抽象類,他也是上述 KeyIterator,ValueIterator,EntryIterator 三種內部迭代器類的父類

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

    public final boolean hasNext() {}

    final Node<K,V> nextNode() {}

    public final void remove() {}
}

雖然它叫 HashIterator ,但是它並沒有實現 Iterator介面,而是讓他的子類自己去實現介面。並且只值提供迭代和刪除兩種功能的三個方法。

此外,他的子類 KeyIterator,ValueIterator,EntryIterator 也非常樸素,只在它的基礎上重寫包裝了一下 nextNode()作為自己的 next()方法,這裡不妨也看成介面卡的一種。

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; }
}

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

6. Spliterator

跟 Iterator 一樣,HashMap 也提供了 HashMapSpliterator,KeySpliterator,ValueSpliterator,EntrySpliterator 四種並行迭代器。後面三者都是 HashMapSpliterator 的子類。

五、HashMap 獲取插入下標

HashMap 是基於雜湊表實現的,因此新增元素和擴容時通過雜湊演算法獲取 key 對應的陣列下標是整整個類進行新增操作的基礎。

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

1. 計算雜湊值

這裡涉及到兩個方法,一個是計算雜湊值的 hash()方法:

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

這是來自知乎大佬一個非常詳細的回答:JDK 原始碼中 HashMap 的 hash 方法原理是什麼? - 知乎

這裡我簡單的概括一下:

該方法實際上是一個“擾動函式”,作用是對Object.hashCode()獲取到的 hash 值進行高低位混淆

HashMap的雜湊演算法

我們可以看到,符號右移16位後,新二進位制數的前16位都為0,後16位就是原始 hashcode 的高16位

將原始 hashcode 與 位運算得到的二進位制數再進行異或運算以後,我們就得到的 hash 前16全部都為1,後16位則同時混淆了高16位和低16位的特徵,進一步增加了隨機性。

現在我們得到了 key 的 hashcode,這是計算下標的基礎。

2. 計算下標

接下來進入putVal()方法,實際上包括 putAll()在內,所有新增/替換元素的方法,都依賴於 putVal()實現。putVal()需要傳入 key 的 hash 作為引數,它將根據 hash 值和 key 進行進一步的計算,獲取實際 value 要插入的下標。

我們先忽略計算下標以外的其他方法:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    ... ...
    // n 即為當前陣列長度
    if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
    // 根據 n 與 hash 計算插入下標
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    
    ... ...
}

也就是說,當通過擾動函式hash()獲取到了已經混淆高低位的 key 的 hashcode 以後, 會將其與陣列長度-1進行與運算(n - 1) & hash

以預設是容量16為例,它轉換為二進位制數是 10000,而 (16-1)轉換為二進位制數就是 1111,補零以後它與 hash() 計算得到的 hashcode 進行與運算過程如下:

與運算取模

在這個過程,之前留下的兩個問題就得到了解答:

為什麼容量需要是2的冪?

我們可以看到,按位的與運算只有 1&1 = 1,由於陣列長度轉為二進位制只有4位,所有高於4位的位數都為0,因此運算結果高於4位的位置也都會是0,這裡巧妙的實現了取模的效果,陣列長度起到了低位掩碼的作用。這也整是為什麼 HashMap 的容量要是2的冪的原因。

為什麼要hash()要混淆高低位?

再回頭看看 hash()函式,他混合了原始 hashcode 的高位和低位的特徵,我們說他增加了隨機性,在點要怎麼理解呢?

我們舉個例子:

key hashCode 不混淆取後四位 混淆後取後四位
808321199 110000001011100000000010101111 1111 0001
7015199 11010110000101100011111 1111 0100
9999 10011100001111 1111 1111

實際上,由於取模運算最終只看陣列長度轉成的二進位制數的有效位數,也就是說,陣列有效位是4位,那麼 key 的 hash 就只看4位,如果是18位,那麼 hash 就只看18位。

在這種情況下,如果陣列夠長,那麼 hash 有效位夠多,雜湊度就會很好;但是如果有效位非常短,比如只有4位,那麼對於區分度在高位數字的值來說就無法區分開,比如表格所示的 808321199,7015199,461539999 三個低位相同的數字,最後取模的時候都會被看成 1111,而混合高低位以後就是 0001,0100,1111,這就可以區分開來了。

六、HashMap 新增元素

在之前獲取下標的例子中,我們知道 put()方法依賴於 putVal()方法,事實上,包括 putAll()在內,所有新增元素的方法都需要依賴於 putVal()

由於新增元素涉及到整個結構的改變,因而 putVal()中除了需要計算下標,還包含擴容,連結串列的樹化與樹的連結串列化在內的多個過程。

1. putVal

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    // 當前table陣列
    Node<K,V>[] tab; Node<K,V> p; 
    // 當前陣列長度,當前要插入陣列位置的下標
    int n, i;
    // 若集合未擴容,則進行第一次擴容
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    
    // 獲取key對應要插入的下標(即桶)
    if ((p = tab[i = (n - 1) & hash]) == null)
        // 若是桶中沒有元素,則新增第一個
        tab[i] = newNode(hash, key, value, null);
    else {
        // 若已經桶中已經存在元素
        Node<K,V> e; K k;
        // 1.插入元素與第一個元素是否有相同key
        if (p.hash == hash && ((k = p.key) == key ||
                               (key != null && key.equals(k))))
            e = p;
        // 2.桶中的連結串列是否已經轉換為紅黑樹
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 3.遍歷連結串列,新增到尾端
        else {
            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;
                }
                // 是否遇到了key相同的節點
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        
        // 如果key已經存在對應的值
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            // 是否要覆蓋value
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            
            // 空方法,用於LinkedHashMap插入後的回撥
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 是否需要擴容
    if (++size > threshold)
        resize();
    // 空方法,用於LinkedHashMap插入後的回撥
    afterNodeInsertion(evict);
    return null;
}

2.連結串列的樹化

在上述過程,涉及到了判斷桶中是否已經轉為紅黑樹的操作:

else if (p instanceof TreeNode)
    // 將Node轉為TreeNode,並且新增到紅黑樹
    e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

以及將連結串列轉為紅黑樹的操作:

if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
    treeifyBin(tab, hash);

其中,putTreeVal()是新增節點到紅黑樹的方法,而 treeifyBin()是一個將連結串列轉為紅黑樹的方法。我們暫且只看看 HashMap 連結串列是如何轉為紅黑樹的:

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // table是否小於最小樹化閾值64
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        // 如果不到64就直接擴容
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        // 否則看看桶中是否存在元素
        TreeNode<K,V> hd = null, tl = null;
        // 將桶中連結串列的所有節點Node轉為TreeNode
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        
        //  如果桶中不為空,就將連結串列轉為紅黑樹
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

上面過程實現了將連結串列的節點 Node 轉為 TreeNode 的過程,接下來 TreeNode.treeify()方法會真正將連結串列轉為紅黑樹:

final void treeify(Node<K,V>[] tab) {
    TreeNode<K,V> root = null;
    // 遍歷連結串列
    for (TreeNode<K,V> x = this, next; x != null; x = next) {
        // 獲取下一節點
        next = (TreeNode<K,V>)x.next;
        x.left = x.right = null;
        // 隊首元素為根節點
        if (root == null) {
            x.parent = null;
            x.red = false;
            root = x;
        } else {
            // 不是隊首元素,則構建子節點
            K k = x.key;
            int h = x.hash;
            Class<?> kc = null;
            for (TreeNode<K,V> p = root;;) {
                int dir, ph;
                K pk = p.key;
                // 向右
                if ((ph = p.hash) > h)
                    dir = -1;
                // 向左
                else if (ph < h)
                    dir = 1;
                // 使用比較器進行比較
                else if ((kc == null &&
                          (kc = comparableClassFor(k)) == null) ||
                         (dir = compareComparables(kc, k, pk)) == 0)
                    dir = tieBreakOrder(k, pk);

                // 構建子節點
                TreeNode<K,V> xp = p;
                if ((p = (dir <= 0) ? p.left : p.right) == null) {
                    x.parent = xp;
                    if (dir <= 0)
                        xp.left = x;
                    else
                        xp.right = x;
                    
                    // 再平衡
                    root = balanceInsertion(root, x);
                    break;
                }
            }
        }
    }
    // 再平衡,保證連結串列頭節點是樹的根節點
    moveRootToFront(tab, root);
}

以上是連結串列樹化的過程,雖然實現過程不簡單,但是流程很簡單:

  • 判斷是否連結串列是否大於8;
  • 判斷元素總數量是否大於最小樹化閾值64;
  • 將原本連結串列的Node節點轉為TreeNode節點;
  • 構建樹,新增每一個子節點的時候判斷是否需要再平衡;
  • 構建完後,若原本連結串列的頭結點不是樹的根節點,則再平衡確保頭節點變為根節點

連結串列轉為紅黑樹的條件

這裡我們也理清楚了連結串列樹化的條件:一個是連結串列新增完元素後是否大於8,並且當前總元素數量大於64

當不滿足這個條件的時候,再新增元素就會直接擴容,利用擴容過程中的重雜湊來緩解雜湊衝突,而不是轉為紅黑樹。

3.為什麼key可以為null

我們回顧一下 hash()方法:

(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

可以看到,這裡對 key == null的情況做了處理,當 key 是 null 的時候,雜湊值會直接被作為 hash 為 0 的元素看待,在 putVal()中新增元素的時候還會判斷:

if (p.hash == hash && ((k = p.key) == key ||
                       (key != null && key.equals(k))))

由於除了比較 hash 值,還會比較記憶體地址並呼叫 equals 比較,所以 null 會被篩出來,作為有且僅有一個的 key 使用。

七、HashMap 的擴容

現在我們知道了 HashMap 是如何計算下標的,也明白了 HashMap 是如何新增元素的,現在我們該瞭解新增元素過程中,擴容方法 resize()的原理了。

1. resize

resize()是 HashMap 的擴容方法:

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    // 當前容量
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 上一次的擴容閾值(在上一次擴容時指定)
    int oldThr = threshold;
    // 新容量,下一次擴容目標容量
    int newCap, newThr = 0;
    
    //======一、計算並獲取擴容目標大小======
    
    // 1.若當前容量大於0(即已經擴容過了)
    if (oldCap > 0) {
        // 是否大於理論允許最大值
        if (oldCap >= MAXIMUM_CAPACITY) {
            // 擴容閾值設定為Integer.MAX_VALUE,本次以後不會再觸發擴容
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 若未達到理論允許最大值,並且:
        // (1)本次擴容目標容量的兩邊小於理論允許最大值
        // (2)當前容量大於預設初始容量16
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            // 新擴容閾值為當前擴容閾值的兩倍
            newThr = oldThr << 1;
    }
    // 2.若本次擴容容量大於0(即還是初始狀態,指定了容量,但是是第一次擴容)
    else if (oldThr > 0)
        // 新容量為上一次指定的擴容閾值
        newCap = oldThr;
    // 3.若當前容量和上一次的擴容閾值都為0(即還是初始狀態,未指定容量而且也沒擴容過)
    else {
        // 使用預設值
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    
     //======二、根據指定大小擴容======
    
    // 根據負載係數檢驗新容量是否可用
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        // 如果乘上負載係數大於理論允許最大容量,則直接擴容到Integer.MAX_VALUE
        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 = 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)
                    // 重新計算節點在新HashMap桶陣列的下標
                    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;
                    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.確認擴容大小

擴容的時機

在瞭解 HashMap 如何確認擴容大小之前,我們需要明白 HashMap 是什麼時候會認為需要擴容。

我們在前面知道了,value 的下標由 key 的高低位混合後與陣列長度-1進行與運算獲得,也就是說,如果陣列長度不夠大——或者說容量不夠大,就會導致與運算後得到的隨機值範圍受限,因此更可能造成雜湊衝突

為此,HashMap 引入負載係數 loadFactor,當不指定時預設為0.75,則有擴容閾值 threshold = 容量*負載係數達到擴容閾值——而不是容量大小——的時候就會進行擴容

假如我們都使用初始值,即預設容量16,預設負載係數0.75,則第一次擴容後,當元素個數達到 0.75*16=12 時,就會進行一次擴容變為原來的兩倍,也就是32,並且將 threshold更新為32*0.75=24。如此反覆。

擴容的大小

擴容的時候,分為兩種情況:已經擴容過,還未擴容過。

我們僅針對獲取新容量 newCap 與新擴容閾值 newThr 這段程式碼邏輯,畫出大致流程圖:

未命名檔案

這裡比較需要注意的是,當 oldCap 已經大於等於理論最大值的時候,會在設定 newThr=Integer.MAX_VALUE後直接返回,不會執行後序擴容過程

另外,當新擴容閾值被設定為 Integer.MAX_VALUE以後,由於該值已經是最大的整數值了,所以設定為該值以後 HashMap 就不會再觸發擴容了

3.重雜湊過程

我們知道,如果桶陣列擴容了,那麼陣列長度也就變了,那麼根據長度與雜湊值進行與運算的時候計算出來的下標就不一樣。在 JDK7 中 HashMap 擴容移動舊容器的資料的時候,會直接進行重雜湊獲得新索引,並且打亂所有元素的排布。而在JDK8進行了優化,只移動部分元素

我們可以回去看看擴容部分的程式碼,其中有這兩處判斷:

// 判斷擴容後是否需要移動位置
if ((e.hash & oldCap) == 0) {
    //... ...
}else {
    if (hiTail == null)
        hiHead = e;
    else
        hiTail.next = e;
    hiTail = e;
}

// 擴容後移動位置
if (hiTail != null) {
    hiTail.next = null;
    newTab[j + oldCap] = hiHead;
}

前面有提到 HashMap 取下標,是通過將 key 的雜湊值與長度做與運算,也就是 (n-1) & hash,而這裡通過計算 n & hash是否為 0 判斷是否需要位移。

他的思路是這樣的:

假如從16擴容到32,擴容前通過(n-1) & hash 取模是取後4位,而擴容後取後5位,因為01111和1111沒區別,所以如果多出來這一位是0,那麼最後用新長度去與運算得到的座標是不變的,那麼就不用移動。否則,多出來這一位相當於多了10000,轉為十進位制就是在原基礎上加16,也就是加上了原桶陣列的長度,那麼直接在原基礎上移動原桶陣列長度就行了

以初始容量 oldCap = 16,newCap = 32 為例,我們先看看他的換算過程:

十進位制 Cap 二進位制 Cap 二進位制 Cap-1 十進位制 Cap-1
oldCap 16 10000 1111 15
newCap 32 100000 11111 31

以上述資料為基礎,我們模擬下面三個 key 在擴容過程中的計算:

key hash (oldCap-1) & hash oldCap & hash (newCap-1) & hash
808321199 110000001011100000000010101111 1111(15) 0 01111(15)
7015199 11010110000101100011111 1111(15) 10000 11111(31)
9999 10011100001111 1111(15) 0 01111(15)

不難看出,只有當 oldCap & hash > 0 的時候元素才需要移動,而由於容量必然是2的冥,每次擴容新容量都是舊容量的兩倍,換成二進位制,相同的 hash 值與運算算出來的座標總是多1,因此相當於每次需要移動的距離都是舊容量。

也就是說,如果 oldCap & hash > 0,那麼就有 新座標=原下標+oldCap,這個邏輯對應的程式碼就是 newTab[j + oldCap] = hiHead;這一行。

這樣做的好處顯而易見,少移動一些元素可以減少擴容的效能消耗,同時同一桶中的元素也有可能在重雜湊之後被移動,使得雜湊衝突得以在擴容後減緩,元素雜湊更均勻。

八、HashMap 獲取元素

put()方法和 putVal()的關係一樣,get()方法以及其他獲取元素的方法最終都依賴於 getNode()方法。

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

1. getNode

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // 如果桶陣列不為空,並且桶中不為null
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        // 如果桶中第一個元素的key與要查詢的key相同,返回第一個元素
        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<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;
}

2.為什麼元素要同時重寫equals和hashcode?

首先,不被原本的的hashCode和equals是這樣的

  • hashCode()是根據記憶體地址換算出來的一個值
  • equals() 方法是判斷兩個物件記憶體地址是否相等

我們回顧一下上文,可以看到無論put()還是get()都會有類似這樣的語句:

// putVal
p.hash == hash && ((k = p.key) == key ||
                   (key != null && key.equals(k)));
// getNode
p.hash == hash && (key != null && key.equals(k));

因為可能存在雜湊衝突,或者為 null 的 key,因此所以光判斷雜湊值是不夠的,事實上,當我們試圖新增或者找到一個 key 的時候,方法會根據三方面來確定一個唯一的 key:

  • 比較 hashCode()是否相等:程式碼是比較內部 hash()方法算出來的值 hash 是否相等,但是由於該方法內部還是呼叫 hashCode(),所以實際上是比較的仍然是hashCode()算出來的值;
  • 比較 equlas()是否相等:Object.equlas()方法在不重寫的時候,預設比較的是記憶體地址;
  • 比較 key 是否為 null;

為什麼要重寫equals和hashcode方法?

當我們使用 HashMap 提供的預設的流程時,這三處校驗已經足以保證 key 是唯一的。但是這也帶來了一些問題,當我們使用一些未重寫了 Object.hashCode()或者 Object.equlas()方法的類的例項作為 key 的時候,由於 Object 類中的方法預設比較的都是記憶體地址,因此必須持有當初作為 key 的例項才能拿到 value。

我們舉個例子:

假設我們有一個 Student 類

public class Student {
    String name;
    Integer age;

    public Student(String name, Integer age) {
        this.name = name;
        this.age = age;
    }
}

現在我們使用 Student 的例項作為 key:

Map<Object,Object> map = new HashMap<>(2);
map.put(new Student("xx",16), "a");
map.put(new Student("xx",16), "a");
for(Map.Entry<Object, Object> entry : map.entrySet()){
    System.out.print(entry.getValue());
};
// aa

因此,如果我們希望使用物件作為 key,那麼大多數時候都需要重寫equals()hashcode()的。

為什麼要同時重寫兩個方法?

這個也很好理解,判斷 key 需要把 equals()hashcode()兩個的返回值都判斷一遍,如果只重寫其中一個,那麼最後還是不會被認為是同一個 key

當我們為 Student 重寫 equals()hashcode()以後,結果執行以後輸出就是隻有一個 a 了。

@Override
public int hashCode() {
    return this.name.hashCode() + age;
}

@Override
public boolean equals(Object obj) {
    return obj instanceof Student &&
        this.name.equals(((Student) obj).name);
}

九、HashMap 的迭代

由於 Map 集合本質上表示的是一組鍵值對之間的對映關係,並且 HashMap 的資料結構是陣列+連結串列/樹,因此 HashMap 集合並無法直接像 Collection 介面的實現類那樣直接迭代。

而在本文的第四部分,我們瞭解了 HashMap 中的幾個主要內部類,其中四大檢視類就是其中三個集合檢視的 KeySet,Values,EntrySet,與一個鍵值對檢視 Entry。當我們要迭代 HashMap 的時候,就需要通過迭代三個集合檢視來實現,並且通過 key,value 或者 Entry 物件來接受迭代得到的物件。

值得一提的是,和 ArrayList 一樣,HashMap 也實現了 fast-fail 機制,因此最好不要在迭代的時候進行結構性操作。

1.迭代器迭代

所有集合都可以通過迭代器迭代器(集合的增強 for 迴圈在編譯以後也是迭代器跌迭代)。

所以,在 HashMap 中,三種檢視集合都可以通過迭代器或增強 for 迴圈迭代器,但是 HashMap 本身雖然有迭代器,但是由於沒有 iterator()方法,所以無法通過迭代器或者增強 for 直接迭代,必須通過三種檢視集合來實現迭代。

以 EntrySet 檢視為例:

Map<Object,Object> map = new HashMap<>();

// 迭代器
Iterator<Map.Entry<Object, Object>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
    Map.Entry<Object, Object> entry = iterator.next();
    System.out.print(entry.getValue());
}

// 增強for迭代,等價於迭代器迭代
for(Map.Entry<Object, Object> entry : map.entrySet()){
    System.out.print(entry.getValue());
};

// forEach迭代
map.entrySet().forEach(s -> {
    System.out.println(s.getValue());
});

KeySet 檢視和 values同理,但是 values 是 Collection 集合,所以寫法會稍微有點區別。

2.檢視集合中的資料從何處來

我們雖然通過 entrySet()values()keySet()三個方法獲取了檢視集合並且迭代成功了,但是回頭看原始碼,卻發現原始碼中返回的只是一個空集合,裡面並沒有任何裝填資料的操作,但是當我們直接拿到檢視集合的時候,卻能直接遍歷,原因在於他們的迭代器:

final class KeyIterator extends HashIterator
    implements Iterator<K> {
    // 獲取迭代器返回的node的key
    public final K next() { return nextNode().key; }
}

final class ValueIterator extends HashIterator
    implements Iterator<V> {
    // 獲取迭代器返回的node的value
    public final V next() { return nextNode().value; }
}

final class EntryIterator extends HashIterator
    implements Iterator<Map.Entry<K,V>> {
    // 獲取迭代器返回的node
    public final Map.Entry<K,V> next() { return nextNode(); }
}

而這個 nextNode()方法來自於他們的父類HashIterator,這裡需要連著它的構造方法一起看:

// 構造方法
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);
    }
}

// nextNode
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;
}

可以看到,這個 HashIterator 就是 HashMap 真正意義上的迭代器它會從桶陣列中第一個非空桶的第一個節點開始,迭代完全部桶陣列中的每一個節點。但是它並不直接使用,而是作為而三個檢視集合的迭代器的父類。

三個檢視集合自己的迭代器通過把HashIteratornextNode()方法的基礎重新適配為 next(),分別把它從返回 Node 節點類變為了返回節點、節點的 key、節點的 value,這就是集合檢視迭代的原理

由於 Node 本身就攜帶了 key,value和 hash,因此刪除或者新增就可以直接通過 HashMap 類的方法去操作,這就是迭代器增刪改的原理

3. forEach迭代

HashMap 重寫了 forEach() 方法,三個檢視集合也自己重寫了各自的 forEach()方法。

public void forEach(BiConsumer<? super K, ? super V> action) {
    Node<K,V>[] tab;
    if (action == null)
        throw new NullPointerException();
    // 若集合不為空
    if (size > 0 && (tab = table) != null) {
        int mc = modCount;
        // 遍歷桶
        for (int i = 0; i < tab.length; ++i) {
            // 遍歷連結串列
            for (Node<K,V> e = tab[i]; e != null; e = e.next)
                action.accept(e.key, e.value);
        }
        if (modCount != mc)
            throw new ConcurrentModificationException();
    }
}

forEach()邏輯與迭代器類似,但是寫法更直白,就是遍歷桶陣列然後遍歷桶陣列中的連結串列。三個檢視集合的 forEach()寫法與 HashMap 的基本一樣,這裡就不再贅述了。

十、總結

擴容與樹化

HashMap 底層結構是陣列+連結串列/紅黑樹。

HashMap 在不指定初始容量和負載係數的時候,預設容量為16,預設負載係數為0.75,擴容閾值為當前容量*負載係數,當容器中的元素數量大於等於擴容閾值的時候就會擴容為原來的兩倍。

當容器元素數量大於等於64,新增元素後桶中連結串列長度大於等於8時,不會優先擴容,而是先將該連結串列轉為紅黑樹。

雜湊演算法

HashMap 獲取下標的過程分兩步:

  • 位運算混淆 hashCode 的高低位:(h = key.hashCode()) ^ (h >>> 16),作用是保證取模後的隨機性;
  • 與運算計算下標:(n - 1) & hash,作用是取模獲取下標。

其中,長度之所以是2的冥,就是為了在此處將長度作為雜湊值的低位掩碼,巧妙實現取模效果。

擴容重雜湊

假如從16擴容到32,擴容前通過(n-1) & hash 取模是取後4位,而擴容後取後5位,因為01111和1111沒區別,所以如果多出來這一位是0,那麼最後用新長度去與運算得到的座標是不變的,那麼就不用移動。否則,多出來這一位相當於多了10000,轉為十進位制就是在原基礎上加16,也就是加上了原桶陣列的長度,那麼直接在原基礎上移動原桶陣列長度就行了

迭代

HashMap 本身有迭代器 HashIterator,但是沒有 iterator()方法,所以無法直接通過增強 for 迴圈或者獲取迭代器進行迭代,只能藉助三個檢視集合的迭代器或增強 for 來迭代器。但是檢視迭代器本身也是 HashIterator子類,因此檢視本身只是空集合,它的迭代能力來自於它們自己的迭代器的父類HashIterator

HashMap 和他的三個集合檢視都重寫了 forEach()方法,所以可以通過 forEach()迭代器。

HashMap 也實現了 fast-fail 機制,因此最好不要在迭代的時候進行結構性操作。

equals和hashCode方法

HashMap 在get()set()的時候都會通過 Object.equals()Object.hashCode() 方法來確定唯一 key。由於預設使用的 Object 的實現比較是記憶體地址,因此使用自建物件作為 key 會很不方便,因此需要重寫兩個方法。但是由於校驗唯一性的時候兩個方法都會用到,因此若要重寫equals()hashCode()必須同時重寫兩個方法,不能重寫其中一個。

相關文章