Java 集合系列4、家喻戶曉之HashMap(上)

JayceKon發表於2018-05-27

1、HashMap 概述

在前面的文章中,我們以及介紹了 List 大家族的相關知識:

在接下來的文章,則主要為大家介紹一下Java 集合家庭中另一小分隊 Map ,我們先來看看 Map 家庭的整體架構:

Java 集合系列4、家喻戶曉之HashMap(上)

在這篇文章中,我們主要介紹一下HashMap:

Java 集合系列4、家喻戶曉之HashMap(上)

HashMap 的依賴關係:

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable 
複製程式碼
  • 1、AbstractMap:表明它是一個雜湊表,基於Key-Value 的儲存方式
  • 2、Cloneable:支援拷貝功能
  • 3、Seriablizable:重寫了write/readObject,支援序列化

從依賴關係上面來看,HashMap 並沒有 List 集合 那麼的複雜,主要是因為在迭代上面,HashMap 區別 key-value 進行迭代,而他們的迭代又依賴與keySet-valueSet 進行,因此,雖然依賴關係上面HashMap 看似簡單,但是內部的依賴關係更為複雜。

2、HashMap 成員變數

預設 桶(陣列) 容量 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

最大容量
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;

最小轉紅黑樹容量
static final int MIN_TREEIFY_CAPACITY = 64;

儲存資料節點
static class Node<K,V> implements Map.Entry<K,V> 

節點陣列
transient Node<K,V>[] table;

資料容量
transient int size;

操作次數
transient int modCount;

擴容大小
int threshold;
複製程式碼

對比於JDK8之前的HashMap ,成員變數主要的區別在於多了紅黑樹的相關變數,用於標示我們在什麼時候進行 list -> Tree 的轉換。

附上Jdk8 中HashMap 的資料結構展示圖:

Java 集合系列4、家喻戶曉之HashMap(上)

3、HashMap 建構函式

HashMap 提供了四種建構函式:

  • HashMap():預設建構函式,引數均使用預設大小
  • HashMap(int initialCapacity):指定初始陣列大小
  • HashMap(int initialCapacity, float loadFactor):指定初始陣列大小,載入因子
  • HashMap(Map<? extends K, ? extends V> m):建立新的HashMap,並將 m 中內容存入HashMap中

4、HashMap Put 過程

接下來我們主要講解一下,HashMap 在JDK8中的新增資料過程(引用):

Java 集合系列4、家喻戶曉之HashMap(上)

4.1、put(K key, V value)

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
複製程式碼

上述方法是我們在開發過程中最常使用到的方法,但是卻很少人知道,其實內部真正呼叫的方法是這個putVal(hash(key), key, value, false, true) 方法。這裡稍微介紹一下這幾個引數:

  • hash 值,用於確定儲存位置
  • key:存入鍵值
  • value:存入資料
  • onlyIfAbsent:是否覆蓋原本資料,如果為true 則不覆蓋
  • onlyIfAbsent:table 是否處於建立模式

4.1.1 hash(Object key)

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

這裡的Hash演算法本質上就是三步:取key的hashCode值、高位運算、取模運算。 這裡引用一張圖,易於大家瞭解相關機制

Java 集合系列4、家喻戶曉之HashMap(上)
這裡可能會比較疑惑,為什麼需要對自身的hashCode 進行運算,這麼做可以在陣列table 比較小的時候,讓高位bit 也能參與到hash 運算中,同時不會又太大的開銷。

4.2、putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)

由於原始碼篇幅過長,這裡我進行分開講解,同學們可以對照原始碼進行閱讀

4.2.1 宣告成員變數(第一步)

Node<K,V>[] tab; Node<K,V> p; int n, i;
複製程式碼

第一部分主要縣宣告幾個需要使用到的成員變數:

  • tab:對應table 用於儲存資料
  • p:我們需要儲存的資料,將轉化為該物件
  • n:陣列(table) 長度
  • i:陣列下標

4.2.2 Table 為 null,初始化Table(第二步)

table 為空說明當前操作為第一次操作,通過上面建構函式的閱讀,我們可以瞭解到,我們並沒有對table 進行初始化,因此在第一次put 操作的時候,我們需要先將table 進行初始化。

        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
複製程式碼

從上述程式碼可以看到,table 的初始化和擴容,都依賴於 resize() 方法,在後面我們會對該方法進行詳細分析。

4.2.3 Hash碰撞確認下標(True)

 if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
複製程式碼

在上一步我們以及確認當前table不為空,然後我們需要計算我們物件需要儲存的下標了。

如果該下標中並沒有資料,我們只需建立一個新的節點,然後將其存入 tab[] 即可。

4.2.4 Hash碰撞確認下標(False)

與上述過程相反,Hash碰撞結果後,發現該下標有儲存元素,將其儲存到變數 p = tab[i = (n - 1) & hash] ,現在 p 儲存的是目標陣列下標中的元素。如上圖所示(引用):

Java 集合系列4、家喻戶曉之HashMap(上)

4.2.4.1 key 值相同覆蓋

在獲取到 p 後,我們首先判斷它的 key 是否與我們這次插入的key 相同,如果相同,我們將其引用傳遞給 e

if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
複製程式碼
4.2.4.2 紅黑樹節點處理
else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
複製程式碼

由於在JDK 8後,會對過長的連結串列進行處理,即 連結串列 -> 紅黑樹,因此對應的節點也會進行相關的處理。紅黑樹的節點則為TreeNode,因此在獲取到p後,如果他跟首位元素不匹配,那麼他就有可能為紅黑樹的內容。所以進行putTreeVal(this, tab, hash, key, value) 操作。該操作的原始碼,將會在後續進行細述。

4.2.4.3 連結串列節點處理
        else {
            //for 迴圈遍歷連結串列,binCount 用於記錄長度,如果過長則進行樹的轉化
                for (int binCount = 0; ; ++binCount) {
                // 如果發現p.next 為空,說明下一個節點為插入節點
                    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
                    p = e;
                }
            }
複製程式碼

連結串列遍歷處理,整個過程就是,遍歷所有節點,當發現如果存在key 與插入的key 相同,那麼退出遍歷,否則在最後插入新的節點。判斷連結串列長度是否大於8,大於8的話把連結串列轉換為紅黑樹,在紅黑樹中執行插入操作,否則進行連結串列的插入操作;遍歷過程中若發現key已經存在直接覆蓋value即可;

4.2.4.3 判斷是否覆蓋

        if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
複製程式碼

如果 e 不為空,說明在校驗 key 的hash 值,發現存在相同的 key,那麼將會在這裡進行判斷是否對其進行覆蓋。

4.2.5 容量判斷

        if (++size > threshold)
            resize();
複製程式碼

如果 size 大於 threshold 則進行擴容處理。

5、Resize()擴容

在上面的建構函式,和 put過程都有呼叫過resize() 方法,那麼,我們接下來將會分析一下 resize()過程。由於JDK 8引入了紅黑樹,我們先從JDK 7開始閱讀 resize() 過程。下面部分內容參考:傳送門

5.1 JDK 7 resize()

JDK 7 中,擴容主要分為了兩個步驟:

  • 容器擴充套件
  • 內容拷貝

5.1.1 容器擴充套件

 1 void resize(int newCapacity) {   //傳入新的容量
 2     Entry[] oldTable = table;    //引用擴容前的Entry陣列
 3     int oldCapacity = oldTable.length;         
 4     if (oldCapacity == MAXIMUM_CAPACITY) {  //擴容前的陣列大小如果已經達到最大(2^30)了
 5         threshold = Integer.MAX_VALUE; //修改閾值為int的最大值(2^31-1),這樣以後就不會擴容了
 6         return;
 7     }
 8  
 9     Entry[] newTable = new Entry[newCapacity];  //初始化一個新的Entry陣列
10     transfer(newTable);                         //!!將資料轉移到新的Entry陣列裡
11     table = newTable;                           //HashMap的table屬性引用新的Entry陣列
12     threshold = (int)(newCapacity * loadFactor);//修改閾值
13 }
複製程式碼

5.1.2 內容拷貝

 1 void transfer(Entry[] newTable) {
 2     Entry[] src = table;                   //src引用了舊的Entry陣列
 3     int newCapacity = newTable.length;
 4     for (int j = 0; j < src.length; j++) { //遍歷舊的Entry陣列
 5         Entry<K,V> e = src[j];             //取得舊Entry陣列的每個元素
 6         if (e != null) {
 7             src[j] = null;//釋放舊Entry陣列的物件引用(for迴圈後,舊的Entry陣列不再引用任何物件)
 8             do {
 9                 Entry<K,V> next = e.next;
10                 int i = indexFor(e.hash, newCapacity); //!!重新計算每個元素在陣列中的位置
11                 e.next = newTable[i]; //標記[1]
12                 newTable[i] = e;      //將元素放在陣列上
13                 e = next;             //訪問下一個Entry鏈上的元素
14             } while (e != null);
15         }
16     }
17 }
複製程式碼

5.1.3 擴容過程展示(引用)

下面舉個例子說明下擴容過程。假設了我們的hash演算法就是簡單的用key mod 一下表的大小(也就是陣列的長度)。其中的雜湊桶陣列table的size=2, 所以key = 3、7、5,put順序依次為 5、7、3。在mod 2以後都衝突在table[1]這裡了。這裡假設負載因子 loadFactor=1,即當鍵值對的實際大小size 大於 table的實際大小時進行擴容。接下來的三個步驟是雜湊桶陣列 resize成4,然後所有的Node重新rehash的過程。

Java 集合系列4、家喻戶曉之HashMap(上)

5.2 JDK 8 resize()

由於擴容部分程式碼篇幅比較長,童鞋們可以對比著部落格與原始碼進行閱讀。 與上述流程相似,JDK 8 中擴容過程主要分成兩個部分:

  • 容器擴充套件
  • 內容拷貝

5.2.1 容器擴充套件

        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;
            }
            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 {               // zero initial threshold signifies using defaults
            //如果陣列長度與閥值為空,建立一個預設長度的陣列長度
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        
        // 第二步,建立新陣列
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
複製程式碼

從上面的流程分析,我們可以看到在 JDK 8 HashMap 中,開始使用位運算進行擴容計算,主要優點將會在後續資料拷貝中具體表現。

5.2.2 內容拷貝

在上述容器擴容結束後,如果發現 oldTab 不為空,那麼接下來將會進行內容拷貝:

    if (oldTab != null) {
            //對舊陣列進行遍歷
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                //
                if ((e = oldTab[j]) != null) {
                    //將舊陣列中的內容清空
                    oldTab[j] = null;
                    //如果 e 沒有後續內容,只處理當前值即可
                    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
                        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;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
複製程式碼

內容拷貝,在JDK 8 中優化,主要是:

  • 通過高位與運算確認儲存地址
  • 連結串列不會出現導致,JDK 8 通過建立新連結串列方式進行轉移

我們來看一下 JDK 8 是如何通過高位與運算確認儲存位置的:

Java 集合系列4、家喻戶曉之HashMap(上)

6、小結

HashMap中,如果key經過hash演算法得出的陣列索引位置全部不相同,即Hash演算法非常好,那樣的話,getKey方法的時間複雜度就是O(1),如果Hash演算法技術的結果碰撞非常多,假如Hash算極其差,所有的Hash演算法結果得出的索引位置一樣,那樣所有的鍵值對都集中到一個桶中,或者在一個連結串列中,或者在一個紅黑樹中,時間複雜度分別為O(n)和O(lgn)。

(1) 擴容是一個特別耗效能的操作,所以當程式設計師在使用HashMap的時候,估算map的大小,初始化的時候給一個大致的數值,避免map進行頻繁的擴容。

(2) 負載因子是可以修改的,也可以大於1,但是建議不要輕易修改,除非情況非常特殊。

(3) HashMap是執行緒不安全的,不要在併發的環境中同時操作HashMap,建議使用ConcurrentHashMap。

(4) JDK1.8引入紅黑樹大程度優化了HashMap的效能。

(5) 還沒升級JDK1.8的,現在開始升級吧。HashMap的效能提升僅僅是JDK1.8的冰山一角。

參考

  • https://tech.meituan.com/java-hashmap.html
  • https://www.2cto.com/kf/201505/401433.html

相關文章