2. HashMap總結

Map綜述

Java爲數據結構中的映射提供了一個接口java.util.Map,這個接口有四個常用的實現類:HashMapLinkedHashMapTreeMap以及HashTable,繼承關係如下:

image

四個類的簡單說明

HashMap

  • 根據鍵的hashCode值來存儲數據,大多數情況下可以直接定位到它的值,因而具有很快的訪問速度,但是遍歷順序卻是不確定的
  • 最多允許一條記錄的鍵(key)爲null,允許多條記錄的值(value)爲null
  • 是線程不安全的,即任意時刻有多個線程可以同時操作HashMap,可能會導致數據不一致以及訪問數據時的無限循環
  • 如果需要滿足線程安全,可以使用Collections.synchronizedMap()來使HashMap成爲線程安全的類,或者使用ConcurrentHashMap

HashTable

  • HashTable是遺留類(源自JDK1.0),與此相同的還有VectorStack,目前不推薦使用
  • HashTable的大部分功能與HashMap相似,不同的是HashTable還繼承了Dictionary
  • HashTable不允許鍵和值爲null
  • 是線程安全的,所有的公有方法均是同步方法,同一時間只能有一個線程訪問HashTable,併發性不如ConcurrentHashMap。不需要線程安全的環境下可以使用HashMap替換,需要線程安全的環境可以使用ConcurrentHashMap替換

LinkedHashMap

  • LinkedHashMapHashMap的子類,底層使用雙向鏈表來維護插入順序
  • 在使用Iterator遍歷LinkedHashMap時,默認情況下先得到的記錄肯定是先插入的,也可以在構造時指定是使用LRU算法,將最近訪問的元素移動到鏈表的尾部
  • 是線程不安全的,目前JUC包下沒有對應的併發容器,可以採用Collections.synchronizedMap()來獲取一個線程安全的LinkedHashMap

TreeMap

  • TreeMap實現了SortedMap接口,能夠把它保存的記錄根據鍵排序,默認是升序,也可以指定Comparator來進行比較
  • TreeMap構造時未指定比較器,則不允許鍵(key)爲null。如果指定了比較器,則由比較器的實現決定
  • TreeMap是一個有序的key-value集合,通過紅黑樹實現的
  • 使用Iterator遍歷TreeMap時,得到的記錄是排過序的,與前面的三種Map一樣,都是fail-fast(快速失敗)的
  • TreeMap同樣是線程不安全的,除了使用Collections.synchronizedMap()以外,還可以使用ConcurrentSkipListMap來代替

總結

對於上述四種Map接口的實現類,要求映射中的key是在創建以後它的哈希值不會改變,如果哈希值發生變化,則很有可能在Map中定位不到對應的位置

HashMap

類圖

image

存儲實現

從存儲結構上來講,HashMap採用了鏈地址法實現,數組+鏈表+紅黑樹,如下圖所示

image

具體實現

從源碼可知,HashMap中定義了

transient Node<K,V>[] table;

即哈希桶數組,而具體對象則是Node,下面是Node對象的定義

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

    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }

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

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

NodeHashMap的一個內部類,實現了Map.Entry接口,本身就是一個映射(鍵值對)

存儲優勢

HashMap使用的是哈希表來存儲。哈希表爲了解決衝突,可以採用開放地址法和鏈地址法等來解決問題。而在HashMap中採用的是鏈地址法,簡單來說就是數組+鏈表的結合。在每個數組元素上都是一個鏈表,當數據插入時,通過哈希值計算出數據所在數組的下標,然後直接將數據置於對應鏈表的末尾

爲了減少哈希衝突以及將哈希桶數組控制在較少的情況下,HashMap實現了一套比較好的Hash算法和擴容機制

Hash算法和擴容機制

HashMap的構造方法來看,主要是對以下幾個字段進行初始化

transient Node<K,V>[] table;    // 哈希桶數組
int threshold;      // 當前哈希桶數組最多容納的key-value鍵值對的個數
final float loadFactor; // 負載因子,可以知道負載因子在HashMap創建以後就不能被修改
transient int size;
transient int modCount;
  • 在初始化table時,可以通過構造函數指定初始容量,如果不指定則使用默認的的長度(16)。此時除了傳入Map參數的構造函數以外,都不會進行Node數組的創建
  • loadFactor爲負載因子,默認值爲0.75
  • thresholdHashMap所能容納的最多Node的個數,threshold = table.length * loadFactor,也就是說,在數組定義好長度以後,負載因子越大,所能容納的鍵值對個數越多
  • size字段用於記錄HashMap中實際存在的鍵值對數量
  • modCount用於記錄HashMap內部結構變化的次數,主要用於迭代時的快速失敗。需要注意的是,內部結構變化是指結構發生變化,例如新增一個結點、刪除一個結點、改變哈希桶數組,但是在put()方法中,鍵對應的值被覆蓋則不屬於結構變化

具體分析

負載因子與threshold
  • 結合負載因子的計算公式可知,threshold是在此負載因子與當前數組對應下所允許的最大數目
  • Node結點的個數超過threshold時就需要resize(擴容),擴容後的容量是之前的兩倍
  • 默認的負載因子值爲0.75,這個值是對空間和時間效率的一個平衡選擇,一般來說不需要修改
    • 如果內存空間很多而又對時間效率要求很高,可以降低負載因子的值,這個值必須大於0
    • 如果內存空間緊張而對時間效率要求不高,可以增加負載因子的值,這個值可以大於1
哈希桶數組的長度
  • HashMap中,哈希桶數組table的長度必須爲$ 2^n $(即一定爲合數),這是一種非常規的設計
    • 常規的設計是把桶的個數設計爲素數,相對來說素數導致衝突的概率小於合數
    • HashTable中,初始化桶的個數爲11,這是桶個數設計爲素數的應用(當然,HashTable不保證擴容以後還是素數)
  • HashMap採用這種非常規設計,主要是爲了在取模和擴容時做優化
  • 爲了減少衝突,HashMap在定位哈希桶索引時,加入了高位參與運算的過程
紅黑樹的加入
  • 負載因子與Hash算法即使設計得再合理,也有可能出現鏈表過長的情況,一旦出現鏈表過長,則會嚴重影響HashMap的性能
  • 在JDK1.8中,引入了紅黑樹,在鏈表長度太長(默認超過8)時,會將鏈表轉換爲紅黑樹。利用紅黑樹插入、刪除、查找都爲$ logN $的時間複雜度來提升HashMap的性能

功能實現

主要從根據鍵獲取哈希桶數組的索引位置、put()方法的詳細執行以及如何擴容來分析

確定哈希桶數組的索引位置

在實現過程,HashMap採用了兩步來鍵映射到對應的哈希桶數組的索引上

  1. 對鍵的哈希值進行再哈希,將鍵的哈希值的高位參與運算

    static final int hash(Object key) {
        int h;
        // h = key.hashCode() 第一步獲取hashCode
        // h ^ (h >>> 16) 第二步將高位參與運算
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
  2. 取模運算定位索引

    // n爲哈希桶數組的長度
    // hash值爲再哈希的結果
    // (table.length - 1) & hash
    p = tab[i = (n - 1) & hash]);

對於第一步:

  • 對於任意對象,只要它的hashCode()方法返回的哈希值一樣,經過hash()這個方法,那麼再哈希的結果都是一樣的。
  • 在JDK1.8中,優化了高位運算的算法,通過hashCode()的高16位異或低16位實現的:h = key.hashCode()) ^ (h >>> 16,主要是從速度、功效、質量來考慮的,這麼做可以在哈希桶數組較小時,也能保證高低位都參與到哈希值的計算中,同時不會有太大開銷

對於第二步:
* 一般在計算哈希值對應的數組索引時,會採用取模運算,但是模運算的消耗是比較大
* 在HashMap中是通過(table.length - 1) & hash的方式來計算對應的數組索引。這是一個很巧妙的做法,前面提到過哈希桶數組的長度始終爲$ 2^n $,在優化了操作速度以外,(table.length - 1) & hash這個運算等同於對table.length取模,即hash % table.length,但是&%運算效率更高

舉例說明,n爲哈希桶數組的長度

image

put() 方法的實現

image

  1. 判斷哈希桶數組是否爲null或者長度爲0,否則執行resize()進行擴容
  2. 根據鍵值key計算得到的數組索引i,如果table[i] == null,直接新建結點進行添加,然後執行6。如果table[i] != null,繼續執行3
  3. 判斷table[i]的首個元素是否與key相等(指的hashCode、地址以及equals()),如果相等,直接覆蓋value,然後返回舊值。否則繼續執行4
  4. 判斷table[i]是否爲TreeNode,即table[i]是否是紅黑樹,如果是紅黑樹,則在樹中插入新的結點,同時如果樹中存在相應的key,也會直接覆蓋value,然後返回舊值。否則繼續執行5
  5. 遍歷table[i],判斷鏈表長度是否大於8,
    • 大於8的話會將鏈表轉換爲紅黑樹,在紅黑樹中執行插入操作
    • 否則進行鏈表的插入操作
    • 遍歷過程中若發現key已經存在,直接覆蓋value後,返回舊值即可
  6. 插入成功後,判斷實際存在的鍵值對數是否超過了最大容量threshold,如果超過進行擴容

put() 方法源碼

public V put(K key, V value) {
    // 對key的hashCode進行再哈希
    return putVal(hash(key), key, value, false, true);
}

/**
 * 插入時如果key已存在,對值進行替換,然後返回舊值
 * 如果onlyIfAbsent爲true,則不會對已存在的值進行替換
 * 如果不存在,直接插入,然後返回null
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 1. table爲空或者長度爲0則進行創建
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 2. 計算key在哈希桶數組中的下標,如果這個位置沒有節點則直接進行插入
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 3. 如果key存在,直接覆蓋value
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 4. 判斷鏈表是否是紅黑樹
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 5. 該鏈是鏈表
        else {
            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;
                }
                // 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;
    // 6. 超過最大容量就進行擴容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

擴容機制

擴容resize()就是重新計算容量,並將結點重新置於哈希桶數組。Java中的數組是無法自動擴容的,方法是使用各新數組代替已有的容量小的數組,然後將結點重新放入哈希桶中

final Node<K,V>[] resize() {
    // 1. 記錄擴容前的哈希桶數組
    Node<K,V>[] oldTab = table;
    // 2. 記錄舊哈希桶數組的長度,如果爲null就是0
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 3. 記錄舊的最大限制
    int oldThr = threshold;
    int newCap, newThr = 0;
    // 4. 舊長度大於0
    if (oldCap > 0) {
        // 判斷舊的長度是否已經達到最大的容量,
        // 如果是,則修改鍵值對的最大限制爲Integer.MAX_VALUE
        // 並在以後的操作,都不在會擴容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 新的數組長度爲舊長度的兩倍,同時threshold也會擴大兩倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // 5. 此時是首次創建哈希桶數組,但是在創建HashMap時指定了鍵值對的最大限制
    // 那麼哈希桶數組的長度爲這個最大限制(這個值是 2^n)
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    // 6. 此時是首次創建哈希桶數組,將容量與限制設置爲默認值
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 7. 如果新的限制是0,也就是還沒有計算過
    // 則通過新的長度與負載因子來計算出
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    // 8. 將新的限制保存到threshold
    threshold = newThr;
    // 9. 創建新的哈希桶數組並賦值給table
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    // 10. 舊的哈希桶數組裏面存有結點,需要將結點置於新的哈希桶數組
    if (oldTab != null) {
        // 從頭到尾遍歷舊的哈希桶數組
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            // 如果是空的就跳過
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                // 這個索引位置只有一個結點,直接再hash後置於新的哈希桶數組
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                // 這個鏈是紅黑樹,對紅黑樹進行再hash
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // 直接是一個單向鏈表,將單向鏈表的每個結點進行再hash存入新的哈希桶數組
                // 相比JDK1.7(1.7是會在擴容後將之前的鏈表倒置)會保留結點之前的順序
                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;
                    }
                    // 將舊索引+oldCap放置新的哈希桶數組中
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

在JDK1.8中,HashMap的哈希桶數組長度爲$ 2^n $(擴容是之前的2倍),所以元素的位置要麼是在原位置,要麼就是原位置+之前容量的位置

舉個例子來說明,n是哈希桶數組的長度,

  • 圖(a)表示擴容前的key1和key2兩種key確定索引位置的示例
  • 圖(b)表示擴容後的key1和key2兩種key確定索引位置的實例
  • hash1是key1對應的哈希值與高位的運算結果(hash2同理)

image

元素在重新計算哈希值以後,因爲哈希桶數組長度n變爲2倍,那麼n-1的mask範圍在高位多1bit,因此新的索引變化就如下:

image

因此,在將已有的哈希桶結點放入新的哈希桶數組時,就不需要每個結點都重新計算hash,只需要看原來的hash值新增的1bit是1還是0,是0的話索引不變,是1的話索引就變成“舊索引+oldCap”

線程安全性

HashMap是線程不安全的,在多線程場景下使用HashMap可能造成無限循環而導致CPU使用100%

無限循環的出現是因爲如果有多個線程在HashMap中插入新值,並同時觸發了resize()對哈希桶數組進行擴容,在對同一條鏈的所有結點進行再hash分配到新的哈希桶數組的過程中,可能會使鏈上的某個結點指向自身前面的結點,而不是後面的結點,那麼在後面的get()/put()操作中,對相應的鏈訪問時就會出現無限循環

總結

  1. 擴容是一個很消耗性能的操作,所以在使用HashMap時,最好能估算一下大致的容量,避免HashMap頻繁的擴容
  2. 負載因子是可以自己修改的,值可以大於1,但不能小於等於0。默認值0.75是一個權衡空間與時間效率的值,一般沒有特殊需求最好不要輕易修改
  3. HashMap是線程不安全的,在併發環境中使用HashMap可能會出現數據不一致、數據丟失以及無限循環等問題,建議使用ConcurrentHashMap
  4. 紅黑樹的引入優化了HashMap的性能,同時擴容機制也相比JDK1.7更爲優化

參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章