1. 程式人生 > >HashMap 原始碼詳細解析 (JDK1.8)

HashMap 原始碼詳細解析 (JDK1.8)

概要

HashMap 最早出現在 JDK 1.2 中,底層基於雜湊演算法實現。HashMap 允許 null 鍵和 null 值,在計算哈鍵的雜湊值時,null 鍵雜湊值為 0。HashMap 並不保證鍵值對的順序,這意味著在進行某些操作後,鍵值對的順序可能會發生變化。另外,需要注意的是,HashMap 是非執行緒安全類,在多執行緒環境下可能會存在問題。

HashMap 底層是基於雜湊演算法實現,雜湊演算法分為雜湊再探測和拉鍊式。HashMap 則使用了拉鍊式的雜湊演算法,並在 JDK 1.8 中引入了紅黑樹優化過長的連結串列。資料結構示意圖如下:

對於拉鍊式的雜湊演算法,其資料結構是由陣列和連結串列(或樹形結構)組成。在進行增刪查等操作時,首先要定位到元素的所在桶的位置,之後再從連結串列中定位該元素。比如我們要查詢上圖結構中是否包含元素 35

,步驟如下:

  1. 定位元素35所處桶的位置,index = 35 % 16 = 3

  2. 3號桶所指向的連結串列中繼續查詢,發現35在連結串列中。

上面就是 HashMap 底層資料結構的原理,HashMap 基本操作就是對拉鍊式雜湊演算法基本操作的一層包裝。不同的地方在於 JDK 1.8 中引入了紅黑樹,底層資料結構由陣列+連結串列變為了陣列+連結串列+紅黑樹,不過本質並未變。

JDK版本實現方式節點數>=8節點數<=6
1.8以前 陣列+單向連結串列 陣列+單向連結串列 陣列+單向連結串列
1.8以後 陣列+單向連結串列+紅黑樹 陣列+紅黑樹 陣列+單向連結串列

原始碼分析

下面開始分析 HashMap 原始碼實現。

1. Node 節點物件

在分析具體程式碼前,先看 Node 節點物件,這是 HashMap 裡面的一個內部類,也是 HashMap 的資料儲存物件。具體原始碼如下:

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

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }
     // 返回:key的hashCode值和value的hashCode值進行異或運算結果
        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

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

     // 判斷相等的依據是,要麼是同一個 Node 物件,要麼是Map.Entry的一個例項,並且鍵鍵、值值都相等就返回True
     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;
        }
    }

該類繼承自 Map.Entry<K, V>,每一個 Entry 就是一個鍵值對。該類還定一個 next 節點,用於指向下一個節點,也是單鏈的構成基礎。

2. HashMap 繼承關係

下面將直接進入原始碼分析,首先來看 HashMap 的繼承關係:

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

繼承自 AbstractMap,同時也實現了 Map, Cloneable, Serializable 三個介面。也說明了 HashMap 是可序列化,可克隆的的。Map 介面則是定義了一些常用的增刪改查的方法,這樣只要是實現該介面的都有相同的方法,方便大家記憶和使用。

2.1 Cloneable 介面

Java 中一個類要實現 clone 功能 必須實現 Cloneable 介面,否則在呼叫 clone() 時會報 CloneNotSupportedException 異常,也就是說, Cloneable 介面只是個合法呼叫 clone() 的標識(marker-interface):

  // Object   
  protected Object clone() throws CloneNotSupportedException {
        if (!(this instanceof Cloneable)) {
            throw new CloneNotSupportedException("Class " + getClass().getName() +
                                                 " doesn't implement Cloneable");
        }
        return internalClone();
    }

Object 類的 internalClone() 方法是一個 native 方法,native 方法的效率一般來說都是遠高於 Java 中的非 native 方法。這也解釋了為什麼要用 Object 中 clone() 方法而不是先 new 一個物件,然後把原始物件中的資訊賦到新物件中,雖然這也實現了 clone 功能,但效率較低。

Object 類中的 clone() 方法還是一個 protected 屬性的方法。為了讓其它類能呼叫這個 clone() 方法,過載之後要把 clone() 方法的屬性設定為public。

3. 變數定義

    // 這兩個是限定值 當節點數大於 8 時會轉為紅黑樹儲存
    static final int TREEIFY_THRESHOLD = 8;
    // 當節點數小於6 時會轉為單向連結串列儲存
    static final int UNTREEIFY_THRESHOLD = 6;
    // 紅黑樹最小長度為 64
    static final int MIN_TREEIFY_CAPACITY = 64;
    // HashMap容量初始大小
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    // HashMap容量極限
    static final int MAXIMUM_CAPACITY = 1 << 30;
    // 負載因子預設大小
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    // Node是 Map.Entry介面的實現類
    // 在此儲存資料的 Node 陣列容量是2次冪
    // 每一個 Node 本質都是一個單向連結串列
    transient Node<K,V>[] table;
    // HashMap 大小,它代表 HashMap 儲存的鍵值對的多少
    transient int size;
    // HashMap 被改變的次數
    transient int modCount;
    // 下一次HashMap擴容的大小
    int threshold;
    // 儲存負載因子的常量
    final float loadFactor;

上面定義了當中會用到的一些變數,熟悉了就好了。

3.1 transient

在這裡細心的小夥伴會發現桶陣列 table 被申明為 transient。transient 表示易變的意思,在 Java 中,被該關鍵字修飾的變數不會被預設的序列化機制序列化。

考慮一個問題:桶陣列 table 是 HashMap 底層重要的資料結構,不序列化的話,別人還怎麼還原呢?

HashMap 並沒有使用預設的序列化機制,而是通過實現 readObject/writeObject 兩個方法自定義了序列化的內容。HashMap 中儲存的內容是鍵值對,只要把鍵值對序列化了,就可以根據鍵值對資料重建 HashMap。

也有的人可能會想,序列化 table 不是可以一步到位,後面直接還原不就行了嗎?但序列化 table 存在著兩個問題:

  1. table 多數情況下是無法被存滿的,序列化未使用的部分,浪費空間
  2. 同一個鍵值對在不同 JVM 下,所處的桶位置可能是不同的,在不同的 JVM 下反序列化 table 可能會發生錯誤。

以上兩個問題中,第一個問題比較好理解,第二個問題解釋一下。HashMap 的 get/put/remove 等方法第一步就是根據 hash 找到鍵所在的桶位置,但如果鍵沒有覆寫 hashCode 方法,計算 hash 時最終呼叫 Object 中的 hashCode 方法。但 Object 中的 hashCode 方法是 native 型的,不同的 JVM 下,可能會有不同的實現,產生的 hash 可能也是不一樣的。也就是說同一個鍵在不同平臺下可能會產生不同的 hash,此時再對在同一個 table 繼續操作,就會出現問題。

3.2 loadFactor 負載因子

loadFactor 指的是負載因子 HashMap 能夠承受住自身負載(大小或容量)的因子,loadFactor 的預設值為 0.75 認情況下,陣列大小為 16,那麼當 HashMap 中元素個數超過 16*0.75=12 的時候,就把陣列的大小擴充套件為 2*16=32,即擴大一倍,然後重新計算每個元素在陣列中的位置,而這是一個非常消耗效能的操作,所以如果我們已經預知 HashMap 中元素的個數,那麼預設元素的個數能夠有效的提高 HashMap 的效能

負載因子越大表示散列表的裝填程度越高,反之愈小。對於使用連結串列法的散列表來說,查詢一個元素的平均時間是 O(1+a),因此如果負載因子越大,對空間的利用更充分,然而後果是查詢效率的降低;如果負載因子太小,那麼散列表的資料將過於稀疏,對空間造成嚴重浪費

4. 建構函式

HashMap 中主要有四個建構函式,具體如下:
    // 1 預設的建構函式
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

    // 2 傳入一個Map集合,將Map集合中元素Map.Entry全部新增進HashMap例項中
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

    // 3 指定容量大小
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    // 4 指定容量大小和負載因子大小
    public HashMap(int initialCapacity, float loadFactor) {
        //指定的容量大小不可以小於0,否則將丟擲IllegalArgumentException異常
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        // 判定指定的容量大小是否大於HashMap的容量極限
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        // 指定的負載因子不可以小於0或為Null,若判定成立則丟擲IllegalArgumentException異常
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                     loadFactor);

        this.loadFactor = loadFactor;
        // 設定“HashMap閾值”,當HashMap中儲存資料的數量達到threshold時,就需要將HashMap的容量加倍。
        this.threshold = tableSizeFor(initialCapacity);
    }

可以根據具體場景和需求,使用不同的建構函式。一般對於第1個建構函式,大家用的比較多。不過從建構函式可以看出來的一點是,負載因子 loadFactor 是一個非常重要的引數,預設值是 DEFAULT_LOAD_FACTOR = 0.75f 。當負載因子確定後,會根據負載因子的值給 HashMap 計算一個閾值 threshold ;一旦超過閾值就會呼叫 resize () 方法擴容。

5. tableSizeFor 計算閾值

先看看閾值的計算方法,需要指出的一點是:HashMap 要求容量必須是 2 的冪 。閾值具體計算方式如下:

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

tableSizeFor()的主要功能是返回一個比給定整數大且最接近的 2 的冪次方整數。如給定 10,返回 2 的 4 次方 16.

下面分析這個演算法: 

首先,要注意的是這個操作是無符號右移後,再或上原來的值。

為什麼要對 cap 做減 1 操作:int n = cap - 1 ?這是為了防止,cap 已經是 2 的冪。如果 cap 已經是 2 的冪, 又沒有執行這個減 1 操作,則執行完後面的幾條無符號右移操作之後,返回的 capacity 將是這個 cap 的 2 倍。如果不懂,要看完後面的幾個無符號右移之後再回來看看。 

下面看看這幾個無符號右移操作: 

如果 n 這時為 0 了(經過了 cap-1 之後),則經過後面的幾次無符號右移依然是 0,最後返回的 capacity 是 1(最後有個 n+1 的操作)。 

這裡只討論 n 不等於 0 的情況。 

第一次右移

n |= n >>> 1;

由於 n 不等於 0,則 n 的二進位制表示中總會有一bit為 1,這時考慮最高位的 1。通過無符號右移 1 位,則將最高位的 1 右移了 1 位,再做或操作,使得 n 的二進位制表示中與最高位的 1 緊鄰的右邊一位也為 1,如 000011xxxxxx。 

第二次右移

n |= n >>> 2;

注意,這個 n 已經經過了 n |= n >>> 1; 操作。假設此時 n 為 000011xxxxxx ,則 n 無符號右移兩位,會將最高位兩個連續的 1 右移兩位,然後再與原來的 n 做或操作,這樣 n 的二進位制表示的高位中會有 4 個連續的 1。如 00001111xxxxxx 。 

第三次右移

n |= n >>> 4;

這次把已經有的高位中的連續的 4 個 1,右移 4 位,再做或操作,這樣 n 的二進位制表示的高位中會有8個連續的 1。如 00001111 1111xxxxxx 。 

以此類推 

注意,容量最大也就是 32bit 的正數,因此最後 n |= n >>> 16; ,最多也就 32 個 1,但是這時已經大於了 MAXIMUM_CAPACITY ,所以取值到 MAXIMUM_CAPACITY 。 
舉一個例子說明下吧。 

注意,得到的這個 capacity 賦值給了 threshold,因此 threshold 就是所說的容量。當 HashMap 的 size 到達 threshold 這個閾值時會擴容。 

但是,請注意,在構造方法中,並沒有對 table 這個成員變數進行初始化,table 的初始化被推遲到了 put 方法中,在 put 方法中會對 threshold 重新計算。

上述這段計算邏輯引自 : HashMap方法hash()、tableSizeFor()

6. put 新增元素

6.1 putMapEntries 新增 map 物件

該方法是在建構函式中直接傳入一個 map 物件,下面看具體實現程式碼:

  final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
        int s = m.size();
        //當 m 中有元素時,則需將map中元素放入本HashMap例項。
        if (s > 0) {
            // 判斷table是否已經初始化,如果未初始化,則先初始化一些變數。(table初始化是在put時)
            if (table == null) { // pre-size
                // 根據待插入的map 的 size 計算要建立的 HashMap 的容量。
                float ft = ((float)s / loadFactor) + 1.0F;
                int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                         (int)ft : MAXIMUM_CAPACITY);
                // 把要建立的 HashMap 的容量存在 threshold 中
                if (t > threshold)
                    threshold = tableSizeFor(t);
            }
            // 如果table初始化過,因為別的函式也會呼叫它,所以有可能HashMap已經被初始化過了。
            // 判斷待插入的 map 的 size,若 size 大於 threshold,則先進行 resize(),進行擴容
            else if (s > threshold)
                resize();
            //然後就開始遍歷 帶插入的 map ,將每一個 <Key ,Value> 插入到本HashMap例項。
            for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
                K key = e.getKey();
                V value = e.getValue();
                // put(K,V)也是呼叫 putVal 函式進行元素的插入
                putVal(hash(key), key, value, false, evict);
            }
        }
    }    

主要是在判斷一些初始化工作是否已經做了,包括容量,table,確保都可以使用後,再將資料新增到 Map 中。在這裡用到了遍歷,這裡也簡單說下。可以發現這裡 table 為 null 也沒有開始賦值,只是計算了閾值。會在 putVal 中初始化。

6.2 遍歷

和查詢查詢一樣,遍歷操作也是大家使用頻率比較高的一個操作。對於遍歷 HashMap,我們一般都會用下面的方式:

for(Object key : map.keySet()) {
    // do something
}

for(HashMap.Entry entry : map.entrySet()) {
    // do something
}

Set keys = map.keySet();
Iterator ite = keys.iterator();
while (ite.hasNext()) {
    Object key = ite.next();
    // do something
}

要麼是通過獲得 keyset 來遍歷,或者就是拿到 entrySet,最後也可以使用迭代器。具體遍歷流程可以參看下圖:

遍歷上圖的最終結果是 19 -> 3 -> 35 -> 7 -> 11 -> 43 -> 59。HashIterator 在初始化時,會先遍歷桶陣列,找到包含連結串列節點引用的桶,對應圖中就是 3 號桶。隨後由 nextNode 方法遍歷該桶所指向的連結串列。遍歷完 3 號桶後,nextNode 方法繼續尋找下一個不為空的桶,對應圖中的 7 號桶。之後流程和上面類似,直至遍歷完最後一個桶。

6.3 put 新增元素

下面將分析如何新增一個新的元素:

 public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
 //HashMap.put的具體實現
 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不為空並且table長度不可為0,否則將從resize函式中獲取
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
         //這樣寫法有點繞,其實這裡就是通過索引獲取table陣列中的一個元素看是否為Nul
        if ((p = tab[i = (n - 1) & hash]) == null)
            //若判斷成立,則New一個Node出來賦給table中指定索引下的這個元素
            tab[i] = newNode(hash, key, value, null);
        else {  //若判斷不成立
            Node<K,V> e; K k;
             //對這個元素進行Hash和key值匹配,相等則取出該節點
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode) //如果陣列中德這個元素P是TreeNode型別
                //判定成功則在紅黑樹中查詢符合的條件的節點並返回此節點
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else { //若以上條件均判斷失敗,則執行以下程式碼
                //向Node單向連結串列中新增資料
                for (int binCount = 0; ; ++binCount) {
                    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;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e; //p記錄下一個節點
                }
            }
            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;
    }

通過以上的原始碼,我們可以看出。在新增新節點時,通過 hash 確定其位置。

  • 1.首先獲取 Node 陣列 table 物件和長度,若 table 為 null 或長度為 0,則呼叫 resize() 擴容方法獲取 table 最新物件,並通過此物件獲取長度大小

  • 2.判定陣列中指定索引下的節點是否為 Null,若為 Null 則 new 出一個單向連結串列賦給 table 中索引下的這個節點

  • 3.若判定不為 Null,我們的判斷再做分支

           3.1 首先對 hash 和key進行匹配,若判定成功直接賦予 e

       3.2 若匹配判定失敗,則進行型別匹配是否為 TreeNode 若判定成功則在紅黑樹中查詢符合條件的節點並將其回傳賦給 e

     3.3 若以上判定全部失敗則進行最後操作,向單向連結串列中新增資料若單向連結串列的長度大於等於 8,則將其轉為紅黑樹儲存,記錄下一個節點,對 e 進行判定若成功則返回舊值

  • 4.最後判定陣列大小需不需要擴容

查詢過程是首先得確定它的索引:

// index = (n - 1) & hash
first = tab[(n - 1) & hash]

這裡通過 (n - 1)& hash 即可算出桶的在桶陣列中的位置,可能有的朋友不太明白這裡為什麼這麼做,這裡簡單解釋一下。HashMap 中桶陣列的大小 length 總是 2 的冪,此時,(n - 1) & hash 等價於對 length 取餘。但取餘的計算效率沒有位運算高,所以 (n - 1) & hash 也是一個小的優化。舉個例子說明一下吧,假設 hash = 185,n = 16。計算過程示意圖如下:

上面的計算並不複雜,這裡就不多說了。這裡的 hash 值是 key.hashCode() 得到的。但是在 HashMap 這裡,通過位運算重新計算了 hash 值的值。為什麼要重新計算?

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

主要是因為 n (HashMap 的容量) 值比較小,hash 只參與了低位運算,高位運算沒有用上。這就增大了 hash 值的碰撞概率。而通過這種位運算的計算方式,使得高位運算參與其中,減小了 hash 的碰撞概率,使 hash 值儘可能散開。如何理解呢?把前面舉的例子  hash = 185,n = 16,按照 HashMap 的計算方法咱們再來走一遍。

圖中的 hash 是由鍵的 hashCode 產生。計算餘數時,由於 n 比較小,hash 只有低 4 位參與了計算,高位的計算可以認為是無效的。這樣導致了計算結果只與低位資訊有關,高位資料沒發揮作用。為了處理這個缺陷,我們可以上圖中的 hash 高 4 位資料與低 4 位資料進行異或運算,即 hash ^ (hash >>> 4)。通過這種方式,讓高位資料與低位資料進行異或,以此加大低位資訊的隨機性,變相的讓高位資料參與到計算中。此時的計算過程如下:

經過這次計算以後,發現最後的結果已經不一樣了,hash 的高位值對結果產生了影響。這裡為了舉例子,使用了 8 位資料做講解。在 Java 中,hashCode 方法產生的 hash 是 int 型別,32 位寬。前 16 位為高位,後16位為低位,所以要右移 16 位。

7. resize() 擴容

在 Java 中,陣列的長度是固定的,這意味著陣列只能儲存固定量的資料。但在開發的過程中,很多時候我們無法知道該建多大的數組合適。建小了不夠用,建大了用不完,造成浪費。如果我們能實現一種變長的陣列,並按需分配空間就好了。好在,我們不用自己實現變長陣列,Java 集合框架已經實現了變長的資料結構。比如 ArrayList 和 HashMap。對於這類基於陣列的變長資料結構,擴容是一個非常重要的操作。

首先 resize() ,先看一下哪些函式呼叫了 resize(),從而在整體上有個概念:

接下來上原始碼:
    final Node<K,V>[] resize() {
        // 儲存當前table
        Node<K,V>[] oldTab = table;
        // 儲存當前table的容量
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        // 儲存當前閾值
        int oldThr = threshold;
        // 初始化新的table容量和閾值 
        int newCap, newThr = 0;
        /*
        1. resize()函式在size > threshold時被呼叫。oldCap大於 0 代表原來的 table 表非空,
           oldCap 為原表的大小,oldThr(threshold) 為 oldCap × load_factor
        */
        if (oldCap > 0) {
            // 若舊table容量已超過最大容量,更新閾值為Integer.MAX_VALUE(最大整形值),這樣以後就不會自動擴容了。
            if (oldCap >= MAXIMUM_CAPACITY) {
          // 也就是說閾值和容量最大值是不相等的,最終還是得看閾值的界限 threshold = Integer.MAX_VALUE; return oldTab; } // 容量翻倍,使用左移,效率更高 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) // 閾值翻倍 newThr = oldThr << 1; // double threshold } /* 2. resize()函式在table為空被呼叫。oldCap 小於等於 0 且 oldThr 大於0,代表使用者建立了一個 HashMap,但是使用的建構函式為 HashMap(int initialCapacity, float loadFactor) 或 HashMap(int initialCapacity) 或 HashMap(Map<? extends K, ? extends V> m),導致 oldTab 為 null,oldCap 為0, oldThr 為使用者指定的 HashMap的初始容量。   */ else if (oldThr > 0) // initial capacity was placed in threshold //當table沒初始化時,threshold持有初始容量。還記得threshold = tableSizeFor(t)麼; newCap = oldThr; /* 3. resize()函式在table為空被呼叫。oldCap 小於等於 0 且 oldThr 等於0,使用者呼叫 HashMap()建構函式建立的 HashMap,所有值均採用預設值,oldTab(Table)表為空,oldCap為0,oldThr等於0, */ else { // zero initial threshold signifies using defaults 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"}) // 初始化table Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; if (oldTab != null) { // 把 oldTab 中的節點 reHash 到 newTab 中去 for (int j = 0; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null) { oldTab[j] = null; // 若節點是單個節點,直接在 newTab 中進行重定位 if (e.next == null) newTab[e.hash & (newCap - 1)] = e; // 若節點是 TreeNode 節點,要進行 紅黑樹的 rehash 操作 else if (e instanceof TreeNode) ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); // 若是連結串列,進行連結串列的 rehash 操作 else { // preserve order Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; // 將同一桶中的元素根據(e.hash & oldCap)是否為0進行分割(程式碼後有圖解,可以回過頭再來看),分成兩個不同的連結串列,完成rehash do { next = e.next; // 根據演算法 e.hash & oldCap 判斷節點位置rehash 後是否發生改變 //最高位==0,這是索引不變的連結串列。 if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } //最高位==1 (這是索引發生改變的連結串列) else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); if (loTail != null) { // 原bucket位置的尾指標不為空(即還有node) loTail.next = null; // 連結串列最後得有個null newTab[j] = loHead; // 連結串列頭指標放在新桶的相同下標(j)處 } if (hiTail != null) { hiTail.next = null; // rehash 後節點新的位置一定為原來基礎上加上 oldCap,具體解釋看下圖 newTab[j + oldCap] = hiHead; } } } } } return newTab; } }

使用的是 2 次冪的擴充套件(指長度擴為原來 2 倍),所以,元素的位置要麼是在原位置,要麼是在原位置再移動 oldCap 距離。這句話有些拗口,簡單來說的話,也可以這麼理解:


  首先,前面說了:(n - 1)& hash 可算出桶的在桶陣列中的位置,並且 (n - 1) & hash 等價於對 length 取餘。此處 n = length;

根據上述描述,對於 hash 值存就算如公式:hash = a*n + b;a 是因子,該公式中 b 是餘數。

擴容後容量為m: m = 2n;

如果 a>0,且是奇數,那麼表示式變為:hash = (a-1)/2*m+b+n; 再做一個變換就是:hash = a1*m+b1; 其中餘數 b1 = b+n;表示索引位置移動了 n 距離。

如果  a>0,且是偶數,那麼表示式變為:hash = a/2*m+b+n; 再做一個變換就是:hash = a2*m+b2; 其中餘數 b2 = b;位置沒有變動。

如果  a=0,那麼位置也不變。


下面採用圖例再來解釋一遍。n 為 table 的長度,圖(a)表示擴容前的 key1 和 key2 兩種 key 確定索引位置的示例,圖(b)表示擴容後 key1 和 key2 兩種 key 確定索引位置的示例,其中 hash1 是 key1 對應的雜湊與高位運算結果。

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

因此,我們在擴充 HashMap 的時候,只需要看看原來的 hash 值新增的那個 bit 是 1 還是 0 就好了,是 0 的話索引沒變,是 1 的話索引變成 原索引+oldCap,可以看看下圖為 16 擴充為 32 的 resize 示意圖 : 

擴容後,需要進行鍵值對節點重新對映的過程。在 JDK 1.8 中,重新對映節點需要考慮節點型別。對於樹形節點,需先拆分紅黑樹再對映。對於連結串列型別節點,則需先對連結串列進行分組,然後再對映。需要的注意的是,分組後,組內節點相對位置保持不變。關於紅黑樹拆分的邏輯將會放在下一小節說明,先來看看連結串列是怎樣進行分組對映的。

什麼時候擴容:通過 HashMap 原始碼可以看到是在 put 操作時,即向容器中新增元素時,判斷當前容器中元素的個數是否達到閾值(當前陣列長度乘以載入因子的值)的時候,就要自動擴容了。此外,HashMap 準備樹形化但又發現數組太短,也會發生擴容。

擴容(resize):其實就是重新計算容量;而這個擴容是計算出所需容器的大小之後重新定義一個新的容器,將原來容器中的元素放入其中。

8. 連結串列樹化、紅黑樹鏈化與拆分

下面這部分內容摘自  HashMap 原始碼詳細分析(JDK1.8) 因為,覺得他這部分寫得很好,所以就直接摘過來了。

JDK 1.8 對 HashMap 實現進行了改進。最大的改進莫過於在引入了紅黑樹處理頻繁的碰撞,程式碼複雜度也隨之上升。比如,以前只需實現一套針對連結串列操作的方法即可。而引入紅黑樹後,需要另外實現紅黑樹相關的操作。紅黑樹是一種自平衡的二叉查詢樹,本身就比較複雜。本篇文章中並不打算對紅黑樹展開介紹,本文僅會介紹連結串列樹化需要注意的地方。至於紅黑樹詳細的介紹,如果大家有興趣,可以參考他的另一篇文章 - 紅黑樹詳細分析。

在展開說明之前,先把樹化的相關程式碼貼出來,如下:

static final int TREEIFY_THRESHOLD = 8;

/**
 * 當桶陣列容量小於該值時,優先進行擴容,而不是樹化
 */
static final int MIN_TREEIFY_CAPACITY = 64;

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

/**
 * 將普通節點連結串列轉換成樹形節點連結串列
 */
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // 桶陣列容量小於 MIN_TREEIFY_CAPACITY,優先進行擴容而不是樹化
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        // hd 為頭節點(head),tl 為尾節點(tail)
        TreeNode<K,V> hd = null, tl = null;
        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);
    }
}

TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
    return new TreeNode<>(p.hash, p.key, p.value, next);
}

在擴容過程中,樹化要滿足兩個條件:

  1. 連結串列長度大於等於 TREEIFY_THRESHOLD

  2. 桶陣列容量大於等於 MIN_TREEIFY_CAPACITY

第一個條件比較好理解,這裡就不說了。這裡來說說加入第二個條件的原因,個人覺得原因如下:

當桶陣列容量比較小時,鍵值對節點 hash 的碰撞率可能會比較高,進而導致連結串列長度較長。這個時候應該優先擴容,而不是立馬樹化。畢竟高碰撞率是因為桶陣列容量較小引起的,這個是主因。容量小時,優先擴容可以避免一些列的不必要的樹化過程。同時,桶容量較小時,擴容會比較頻繁,擴容時需要拆分紅黑樹並重新對映。所以在桶容量比較小的情況下,將長連結串列轉成紅黑樹是一件吃力不討好的事。

回到上面的原始碼中,我們繼續看一下 treeifyBin 方法。該方法主要的作用是將普通連結串列轉成為由 TreeNode 型節點組成的連結串列,並在最後呼叫 treeify 是將該連結串列轉為紅黑樹。TreeNode 繼承自 Node 類,所以 TreeNode 仍然包含 next 引用,原連結串列的節點順序最終通過 next 引用被儲存下來。我們假設樹化前,連結串列結構如下:

HashMap 在設計之初,並沒有考慮到以後會引入紅黑樹進行優化。所以並沒有像 TreeMap 那樣,要求鍵類實現 comparable 介面或提供相應的比較器。但由於樹化過程需要比較兩個鍵物件的大小,在鍵類沒有實現 comparable 介面的情況下,怎麼比較鍵與鍵之間的大小了就成了一個棘手的問題。為了解決這個問題,HashMap 是做了三步處理,確保可以比較出兩個鍵的大小,如下:

  1. 比較鍵與鍵之間 hash 的大小,如果 hash 相同,繼續往下比較

  2. 檢測鍵類是否實現了 Comparable 介面,如果實現呼叫 compareTo 方法進行比較

  3. 如果仍未比較出大小,就需要進行仲裁了,仲裁方法為 tieBreakOrder(大家自己看原始碼吧)

tie break 是網球術語,可以理解為加時賽的意思,起這個名字還是挺有意思的。

通過上面三次比較,最終就可以比較出孰大孰小。比較出大小後就可以構造紅黑樹了,最終構造出的紅黑樹如下:

橙色的箭頭表示 TreeNode 的 next 引用。由於空間有限,prev 引用未畫出。可以看出,連結串列轉成紅黑樹後,原連結串列的順序仍然會被引用仍被保留了(紅黑樹的根節點會被移動到連結串列的第一位),我們仍然可以按遍歷連結串列的方式去遍歷上面的紅黑樹。這樣的結構為後面紅黑樹的切分以及紅黑樹轉成連結串列做好了鋪墊,我們繼續往下分析。

8.1 split 紅黑樹拆分

擴容後,普通節點需要重新對映,紅黑樹節點也不例外。按照一般的思路,我們可以先把紅黑樹轉成連結串列,之後再重新對映連結串列即可。這種處理方式是大家比較容易想到的,但這樣做會損失一定的效率。不同於上面的處理方式,HashMap 實現的思路則是上好佳(上好佳請把廣告費打給我)。如上節所說,在將普通連結串列轉成紅黑樹時,HashMap 通過兩個額外的引用 next 和 prev 保留了原連結串列的節點順序。這樣再對紅黑樹進行重新對映時,完全可以按照對映連結串列的方式進行。這樣就避免了將紅黑樹轉成連結串列後再進行對映,無形中提高了效率。

以上就是紅黑樹拆分的邏輯,下面看一下具體實現吧:

// 紅黑樹轉連結串列閾值
static final int UNTREEIFY_THRESHOLD = 6;

final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
    TreeNode<K,V> b = this;
    // Relink into lo and hi lists, preserving order
    TreeNode<K,V> loHead = null, loTail = null;
    TreeNode<K,V> hiHead = null, hiTail = null;
    int lc = 0, hc = 0;
    /* 
     * 紅黑樹節點仍然保留了 next 引用,故仍可以按連結串列方式遍歷紅黑樹。
     * 下面的迴圈是對紅黑樹節點進行分組,與上面類似
     */
    for (TreeNode<K,V> e = b, next; e != null; e = next) {
        next = (TreeNode<K,V>)e.next;
        e.next = null;
        if ((e.hash & bit) == 0) {
            if ((e.prev = loTail) == null)
                loHead = e;
            else
                loTail.next = e;
            loTail = e;
            ++lc;
        }
        else {
            if ((e.prev = hiTail) == null)
                hiHead = e;
            else
                hiTail.next = e;
            hiTail = e;
            ++hc;
        }
    }

    if (loHead != null) {
        // 如果 loHead 不為空,且連結串列長度小於等於 6,則將紅黑樹轉成連結串列
        if (lc <= UNTREEIFY_THRESHOLD)
            tab[index] = loHead.untreeify(map);
        else {
            tab[index] = loHead;
            /* 
             * hiHead == null 時,表明擴容後,
             * 所有節點仍在原位置,樹結構不變,無需重新樹化
             */
            if (hiHead != null) 
                loHead.treeify(tab);
        }
    }
    // 與上面類似
    if (hiHead != null) {
        if (hc <= UNTREEIFY_THRESHOLD)
            tab[index + bit] = hiHead.untreeify(map);
        else {
            tab[index + bit] = hiHead;
            if (loHead != null)
                hiHead.treeify(tab);
        }
    }
}

 從原始碼上可以看得出,重新對映紅黑樹的邏輯和重新對映連結串列的邏輯基本一致。不同的地方在於,重新對映後,會將紅黑樹拆分成兩條由 TreeNode 組成的連結串列。如果連結串列長度小於 UNTREEIFY_THRESHOLD,則將連結串列轉換成普通連結串列。否則根據條件重新將 TreeNode 連結串列樹化。舉個例子說明一下,假設擴容後,重新對映上圖的紅黑樹,對映結果如下:

 8.2 untreeify 紅黑樹鏈化

前面說過,紅黑樹中仍然保留了原連結串列節點順序。有了這個前提,再將紅黑樹轉成連結串列就簡單多了,僅需將 TreeNode 連結串列轉成 Node 型別的連結串列即可。相關程式碼如下:

final Node<K,V> untreeify(HashMap<K,V> map) {
    Node<K,V> hd = null, tl = null;
    // 遍歷 TreeNode 連結串列,並用 Node 替換
    for (Node<K,V> q = this; q != null; q = q.next) {
        // 替換節點型別
        Node<K,V> p = map.replacementNode(q, null);
        if (tl == null)
            hd = p;
        else
            tl.next = p;
        tl = p;
    }
    return hd;
}

Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next) {
    return new Node<>(p.hash, p.key, p.value, next);
}

上面的程式碼並不複雜,不難理解,這裡就不多說了。

9. get 新增元素

  //這裡直接呼叫getNode函式實現方法
  public V get(Object key) {
        Node<K,V> e;
        //經過hash函式運算 獲取key的hash值
        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;
        //判定三個條件 table不為Null & table的長度大於0 & table指定的索引值不為Null
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            //判定 匹配hash值 & 匹配key值 成功則返回 該值
            if (first.hash == hash && 
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
             //若 first節點的下一個節點不為Null
            if ((e = first.next) != null) {
                if (first instanceof TreeNode) //若first的型別為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;
    }

梳理以下 get 函式的執行過程

  1. 判定三個條件 table 不為 Null & table 的長度大於 0 & table 指定的索引值不為 Null,否則直接返回 null,這也是可以儲存 null 

  2. 判定匹配 hash 值 & 匹配 key 值,成功則返回該值,這裡用了 == 和 equals 兩種方式,對於 int,string,同一個例項物件等可以適用。

  3. 若 first 節點的下一個節點不為 Null

    1. 若下一個節點型別為 TreeNode 紅黑樹,通過紅黑樹查詢匹配值,並返回查詢值

    2. 否則就是單鏈表,還是通過匹配 hash 值 & 匹配 key 值來獲取資料。

10. remove 刪除元素

當你看到了 get 獲取元素的細節,在來看刪除原理,其實大同小異。

HashMap 的刪除操作並不複雜,僅需三個步驟即可完成。第一步是定位桶位置,第二步遍歷連結串列並找到鍵值相等的節點,第三步刪除節點。相關原始碼如下:

public V remove(Object key) {
    Node<K,V> e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}

final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        // 1. 定位桶位置
        (p = tab[index = (n - 1) & hash]) != null) {
        Node<K,V> node = null, e; K k; V v;
        // 如果鍵的值與連結串列第一個節點相等,則將 node 指向該節點
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {  
            // 如果是 TreeNode 型別,呼叫紅黑樹的查詢邏輯定位待刪除節點
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                // 2. 遍歷連結串列,找到待刪除節點
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        
        // 3. 到這裡,已經找到了,刪除節點,並修復連結串列或紅黑樹
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)
                tab[index] = node.next;
            else
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
   // 找不到,或者沒有資料,返回 null return null; }

刪除操作本身並不複雜,有了前面的基礎,理解起來也就不難了,這裡就不多說了。

11. 其他

11.1 HashMap 和 HashTable 的區別

HashMap和Hashtable都實現了Map介面,但決定用哪一個之前先要弄清楚它們之間的分別。主要的區別有:執行緒安全性,同步(synchronization),以及速度。

  1. HashMap 幾乎可以等價於 Hashtable,除了 HashMap 是非 synchronized 的,並可以接受 null -> null 鍵值對,而 Hashtable 則不行)。

  2. Hashtable 是執行緒安全的,多個執行緒可以共享一個Hashtable;。Java 5提供了ConcurrentHashMap,它是 HashTable 的替代,比 HashTable 的擴充套件性更好。

  3. 由於 Hashtable 是執行緒安全的,在單執行緒環境下它比 HashMap 要慢。在單一執行緒下,使用 HashMap 效能要好過 Hashtable。

  4. HashMap 不能保證隨著時間的推移 Map 中的元素次序是不變的。

  5. HashMap 的迭代器 (Iterator) 是 fail-fast 迭代器,而 Hashtable 的 enumerator 迭代器不是 fail-fast 的。所以當有其它執行緒改變了 HashMap 的結構(增加或者移除元素),將會丟擲 ConcurrentModificationException,但迭代器本身的 remove() 方法移除元素則不會丟擲 ConcurrentModificationException 異常。但這並不是一個一定發生的行為,要看 JVM。這條同樣也是 Enumeration 和 Iterator 的區別。

11.2 JDK 1.7 和 1.8 的 HashMap 的不同點

(1)JDK1.7 用的是頭插法,而 JDK1.8 及之後使用的都是尾插法,那麼為什麼要這樣做呢?因為 JDK1.7 是用單鏈表進行的縱向延伸,當採用頭插法就是能夠提高插入的效率,但是也會容易出現逆序且環形連結串列死迴圈問題。但是在 JDK1.8 之後是因為加入了紅黑樹使用尾插法,能夠避免出現逆序且連結串列死迴圈的問題。

(2)擴容後資料儲存位置的計算方式也不一樣:

  1. 在 JDK1.7 的時候是直接用 hash 值和需要擴容的二進位制數進行 &(這裡就是為什麼擴容的時候為啥一定必須是 2 的多少次冪的原因所在,因為如果只有 2 的 n 次冪的情況時最後一位二進位制數才一定是 1,這樣能最大程度減少 hash 碰撞)(hash 值 & length-1) 。

  2. 而在 JDK1.8 的時候直接用了 JDK1.7 的時候計算的規律,也就是擴容前的原始位置+擴容的大小值 = JDK1.8 的計算方式,而不再是 JDK1.7 的那種異或的方法。但是這種方式就相當於只需要判斷 hash 值的新增參與運算的位是 0 還是 1 就直接迅速計算出了擴容後的儲存方式。

(3)JDK1.7 的時候使用的是陣列+ 單鏈表的資料結構。但是在 JDK1.8 及之後時,使用的是陣列+連結串列+紅黑樹的資料結構(當連結串列的深度達到 8 的時候,也就是預設閾值,就會自動擴容把連結串列轉成紅黑樹的資料結構來把時間複雜度從 O(N) 變成 O(logN) 提高了效率)。

11.3 當兩個物件的 hashcode 相同會發生什麼?獲取元素的時候,如何區分?

hashcode 相同,說明兩個物件 HashMap 陣列的同一位置上,接著 HashMap 會遍歷連結串列中的每個元素,通過 key 的 equals 方法來判斷是否為同一個 key,如果是同一個key,則新的 value 會覆蓋舊的 value,並且返回舊的 value。如果不是同一個 key,則儲存在該位置上的連結串列的鏈尾。

獲取元素的時候遍歷 HashMap 連結串列中的每個元素,並對每個 key 進行 hash 計算,只有 hash 和 key 都相等,才返回對應的值物件。

    參考文章;

資料結構解析-HashMap

HashMap原始碼分析

HashMap 原始碼詳細分析(JDK1.8)

一文讀懂HashMap