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

一、HashMap 概述

在前面的文章中,咱們以及介紹了 List 你們族的相關知識:html

在接下來的文章,則主要爲你們介紹一下Java 集合家庭中另外一小分隊 Map ,咱們先來看看 Map 家庭的總體架構:java

在這篇文章中,咱們主要介紹一下HashMap:程序員

HashMap 的依賴關係:算法

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable 
複製代碼
  • 一、AbstractMap:代表它是一個散列表,基於Key-Value 的存儲方式
  • 二、Cloneable:支持拷貝功能
  • 三、Seriablizable:重寫了write/readObject,支持序列化

從依賴關係上面來看,HashMap 並無 List 集合 那麼的複雜,主要是由於在迭代上面,HashMap 區別 key-value 進行迭代,而他們的迭代又依賴與keySet-valueSet 進行,所以,雖然依賴關係上面HashMap 看似簡單,可是內部的依賴關係更爲複雜。數組

二、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 的數據結構展現圖:bash

三、HashMap 構造函數

HashMap 提供了四種構造函數:數據結構

  • HashMap():默認構造函數,參數均使用默認大小
  • HashMap(int initialCapacity):指定初始數組大小
  • HashMap(int initialCapacity, float loadFactor):指定初始數組大小,加載因子
  • HashMap(Map<? extends K, ? extends V> m):建立新的HashMap,並將 m 中內容存入HashMap中

四、HashMap Put 過程

接下來咱們主要講解一下,HashMap 在JDK8中的添加數據過程(引用):架構

4.一、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值、高位運算、取模運算。 這裏引用一張圖,易於你們瞭解相關機制

這裏可能會比較疑惑,爲何須要對自身的hashCode 進行運算,這麼作能夠在數組table 比較小的時候,讓高位bit 也能參與到hash 運算中,同時不會又太大的開銷。

4.二、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 保存的是目標數組下標中的元素。如上圖所示(引用):

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 則進行擴容處理。

五、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 = 三、七、5,put順序依次爲 五、七、3。在mod 2之後都衝突在table[1]這裏了。這裏假設負載因子 loadFactor=1,即當鍵值對的實際大小size 大於 table的實際大小時進行擴容。接下來的三個步驟是哈希桶數組 resize成4,而後全部的Node從新rehash的過程。

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 是如何經過高位與運算確認存儲位置的:

六、小結

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
相關文章
相關標籤/搜索