天天看點

HashMap 是如何工作的?圖文詳解,一起來看看!9 HashMap.get() 方法内部是如何工作的?

基于Hash的原理。

2 什麼是哈希?

最簡單形式的 hash,是一種在對任何變量/對象的屬性應用任何公式/算法後, 為其配置設定唯一代碼的方法。

一個真正的hash方法必須遵循下面的原則:

哈希函數每次在相同或相等的對象上應用哈希函數時, 應每次傳回相同的哈希碼。換句話說, 兩個相等的對象必須一緻地生成相同的哈希碼。

Java 中所有的對象都有 Hash 方法,Java中的所有對象都繼承 Object 類中定義的 hashCode() 函數的預設實作。此函數通常通過将對象的内部位址轉換為整數來生成哈希碼,進而為所有不同的對象生成不同的哈希碼。

3 HashMap 中的 Node 類

Map的定義是:将鍵映射到值的對象。

是以,HashMap 中必須有一些機制來存儲這個鍵值對。答案是肯定的。HashMap 有一個内部類 Node,如下所示:

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash; // 記錄hash值, 以便重hash時不需要再重新計算
    final K key;
    V value;
    Node<K,V> next;

    ...// 其餘的代碼
}      

當然,Node 類具有存儲為屬性的鍵和值的映射。

key 已被标記為 final,另外還有兩個字段:next 和 hash。

在下面中, 我們将會了解這些屬性的必須性。

4 鍵值對在 HashMap 中是如何存儲的

鍵值對在 HashMap 中是以 Node 内部類的數組存放的, 如下所示:

transient Node[] table;

哈希碼計算出來之後, 會轉換成該數組的下标, 在該下标中存儲對應哈希碼的鍵值對, 在此先不詳細講解hash碰撞的情況。

該數組的長度始終是 2 的次幂, 通過以下的函數實作該過程

HashMap 是如何工作的?圖文詳解,一起來看看!9 HashMap.get() 方法内部是如何工作的?

其原理是将傳入參數 (cap) 的低二進制全部變為 1, 最後加 1 即可獲得對應的大于 cap 的 2 的次幂作為數組長度。

為什麼要使用 2 的次幂作為數組的容量呢?

在此有涉及到 HashMap 的 hash 函數及數組下标的計算, 鍵(key)所計算出來的哈希碼有可能是大于數組的容量的, 那怎麼辦?

可以通過簡單的求餘運算來獲得, 但此方法效率太低。HashMap 中通過以下的方法保證 hash 的值計算後都小于數組的容量。

(n - 1) & hash

這也正好解釋了為什麼需要 2 的次幂作為數組的容量。由于 n 是 2 的次幂, 是以, n - 1 類似于一個低位掩碼。

通過與操作, 高位的hash值全部歸零,保證低位才有效, 進而保證獲得的值都小于 n。同時, 在下一次 resize() 操作時, 重新計算每個 Node 的數組下标将會是以變得很簡單, 具體的後文講解。

以預設的初始值 16 為例:

HashMap 是如何工作的?圖文詳解,一起來看看!9 HashMap.get() 方法内部是如何工作的?

該函數通過将哈希碼的高 16 位的右移後與原哈希碼進行異或而得到, 以以上的例子為例

HashMap 是如何工作的?圖文詳解,一起來看看!9 HashMap.get() 方法内部是如何工作的?

異或

此方法保證了高16位不變, 低16位根據異或後的結果改變。計算後的數組下标将會從原先的 5 變為 0。

使用了 「擾動函數」 之後, hash 碰撞的機率将會下降。有人專門做過類似的測試, 雖然使用該 「擾動函數」 并沒有獲得最大機率的避免 hash 碰撞, 但考慮其計算性能和碰撞的機率, JDK 中使用了該方法, 且隻 hash 一次。

5 哈希碰撞及其處理

在理想的情況下, 哈希函數将每一個 key 都映射到一個唯一的 bucket, 然而, 這是不可能的。哪怕是設計在良好的哈希函數, 也會産生哈希沖突。

前人研究了很多哈希沖突的解決方法, 在維基百科中, 總結出了四大類

HashMap 是如何工作的?圖文詳解,一起來看看!9 HashMap.get() 方法内部是如何工作的?

哈希碰撞解決方法

在 Java 的

HashMap

中, 采用了第一種 Separate chaining 方法(大多數翻譯為拉鍊法)+連結清單和紅黑樹來解決沖突。

HashMap 是如何工作的?圖文詳解,一起來看看!9 HashMap.get() 方法内部是如何工作的?

JDK8中HashMap結果

在 HashMap 中, 哈希碰撞之後會通過 Node 類内部的成員變量 Node next; 來形成一個連結清單(節點小于8)或紅黑樹(節點大于8, 在小于6時會從新轉換為連結清單), 進而達到解決沖突的目的。

HashMap 是如何工作的?圖文詳解,一起來看看!9 HashMap.get() 方法内部是如何工作的?
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;
    this.threshold = tableSizeFor(initialCapacity);
}      

通過該函數進行了容量和負載因子的初始化,如果是調用的其他的構造函數, 則相應的負載因子和容量會使用預設值(預設負載因子=0.75, 預設容量=16)。

在此時, 還沒有進行存儲容器 table 的初始化, 該初始化要延遲到第一次使用時進行。HashMap 面試 21 問!面試的推薦你看下。關注公衆号Java技術棧回複面試擷取更多面試資料。

7 HashMap 中哈希表的初始化或動态擴容

所謂的哈希表, 指的就是下面這個類型為内部類Node的 table 變量。

作為數組, 其在初始化時就需要指定長度。在實際使用過程中, 我們存儲的數量可能會大于該長度,是以 HashMap 中定義了一個門檻值參數(threshold), 在存儲的容量達到指定的門檻值時, 需要進行擴容。

我個人認為初始化也是動态擴容的一種, 隻不過其擴容是容量從 0 擴充到構造函數中的數值(預設16)。而且不需要進行元素的重hash.

7.1 擴容發生的條件

初始化的話隻要數值為空或者數組長度為 0 就會進行。而擴容是在元素的數量大于門檻值(threshold)時就會觸發。

threshold = loadFactor * capacity

比如 HashMap 中預設的 loadFactor=0.75, capacity=16, 則

threshold = loadFactor * capacity = 0.75 * 16 = 12

那麼在元素數量大于 12 時, 就會進行擴容。擴容後的 capacity 和 threshold 也會随之而改變。

負載因子影響觸發的門檻值, 是以, 它的值較小的時候, HashMap 中的 hash 碰撞就很少, 此時存取的性能都很高, 對應的缺點是需要較多的記憶體;而它的值較大時, HashMap 中的 hash 碰撞就很多, 此時存取的性能相對較低, 對應優點是需要較少的記憶體;不建議更改該預設值, 如果要更改, 建議進行相應的測試之後确定。

7.2 再談容量為2的整數次幂和數組索引計算

前面說過了數組的容量為 2 的整次幂, 同時, 數組的下标通過下面的代碼進行計算

index = (table.length - 1) & hash

該方法除了可以很快的計算出數組的索引之外, 在擴容之後, 進行重 hash 時也會很巧妙的就可以算出新的 hash 值。由于數組擴容之後, 容量是現在的 2 倍, 擴容之後 n-1 的有效位會比原來多一位, 而多的這一位與原容量二進制在同一個位置。示例

HashMap 是如何工作的?圖文詳解,一起來看看!9 HashMap.get() 方法内部是如何工作的?

擴容前後

這樣就可以很快的計算出新的索引啦

7.3 步驟

先判斷是初始化還是擴容, 兩者在計算 newCap和newThr 時會不一樣

計算擴容後的容量,臨界值。

将hashMap的臨界值修改為擴容後的臨界值

根據擴容後的容量建立數組,然後将hashMap的table的引用指向新數組。

将舊數組的元素複制到table中。在該過程中, 涉及到幾種情況, 需要分開進行處理(隻存有一個元素, 一般連結清單, 紅黑樹)

具體的看代碼吧

final Node<K, V>[] resize() {
        //建立oldTab數組儲存擴容前的數組table
        Node<K, V>[] oldTab = table;
        //擷取原來數組的長度
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        //原來數組擴容的臨界值
        int oldThr = threshold;
        int newCap, newThr = 0;
        //如果擴容前的容量 > 0
        if (oldCap > 0) {
            //如果原來的數組長度大于最大值(2^30)
            if (oldCap >= MAXIMUM_CAPACITY) {
                //擴容臨界值提高到正無窮
                threshold = Integer.MAX_VALUE;
                //無法進行擴容,傳回原來的數組
                return oldTab;
                //如果現在容量的兩倍小于MAXIMUM_CAPACITY且現在的容量大于DEFAULT_INITIAL_CAPACITY
            } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                    oldCap >= DEFAULT_INITIAL_CAPACITY)
                //臨界值變為原來的2倍
                newThr = oldThr << 1;
        } else if (oldThr > 0) //如果舊容量 <= 0,而且舊臨界值 > 0
            //數組的新容量設定為老數組擴容的臨界值
            newCap = oldThr;
        else { //如果舊容量 <= 0,且舊臨界值 <= 0,新容量擴充為預設初始化容量,新臨界值為DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY
            newCap = DEFAULT_INITIAL_CAPACITY;//新數組初始容量設定為預設值
            newThr = (int) (DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//計算預設容量下的門檻值
        }
        // 計算新的resize上限
        if (newThr == 0) {//在當上面的條件判斷中,隻有是初始化時(oldCap=0, oldThr > 0)時,newThr == 0
            //ft為臨時臨界值,下面會确定這個臨界值是否合法,如果合法,那就是真正的臨界值
            float ft = (float) newCap * loadFactor;
            //當新容量< MAXIMUM_CAPACITY且ft < (float)MAXIMUM_CAPACITY,新的臨界值為ft,否則為Integer.MAX_VALUE
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float) MAXIMUM_CAPACITY ?
                    (int) ft : Integer.MAX_VALUE);
        }
        //将擴容後hashMap的臨界值設定為newThr
        threshold = newThr;
        //建立新的table,初始化容量為newCap
        @SuppressWarnings({"rawtypes", "unchecked"})
        Node<K, V>[] newTab = (Node<K, V>[]) new Node[newCap];
        //修改hashMap的table為建立的newTab
        table = newTab;
        //如果舊table不為空,将舊table中的元素複制到新的table中
        if (oldTab != null) {
            //周遊舊哈希表的每個桶,将舊哈希表中的桶複制到新的哈希表中
            for (int j = 0; j < oldCap; ++j) {
                Node<K, V> e;
                //如果舊桶不為null,使用e記錄舊桶
                if ((e = oldTab[j]) != null) {
                    //将舊桶置為null
                    oldTab[j] = null;
                    //如果舊桶中隻有一個node
                    if (e.next == null)
                        //将e也就是oldTab[j]放入newTab中e.hash & (newCap - 1)的位置
                        newTab[e.hash & (newCap - 1)] = e;
                        //如果舊桶中的結構為紅黑樹
                    else if (e instanceof TreeNode)
                        //将樹中的node分離
                        ((TreeNode<K, V>) e).split(this, newTab, j, oldCap);
                    else {  //如果舊桶中的結構為連結清單,連結清單重排,jdk1.8做的一系列優化
                        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 {// 原索引+oldCap
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        // 原索引放到bucket裡
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        // 原索引+oldCap放到bucket裡
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
}
      

7.4 注意事項

雖然 HashMap 設計的非常優秀, 但是應該盡可能少的避免 resize(), 該過程會很耗費時間。

同時, 由于 hashmap 不能自動的縮小容量。是以, 如果你的 hashmap 容量很大, 但執行了很多 remove 操作時, 容量并不會減少。如果你覺得需要減少容量, 請重新建立一個 hashmap。

8 HashMap.put() 函數内部是如何工作的?

在使用多次 HashMap 之後, 大體也能說出其添加元素的原理:計算每一個key的哈希值, 通過一定的計算之後算出其在哈希表中的位置,将鍵值對放入該位置,如果有哈希碰撞則進行哈希碰撞處理。

而其工作時的原理如下(圖是我很早之前儲存的, 忘了出處了)

HashMap 是如何工作的?圖文詳解,一起來看看!9 HashMap.get() 方法内部是如何工作的?

源碼如下:

/* @param hash         指定參數key的哈希值
 * @param key          指定參數key
 * @param value        指定參數value
 * @param onlyIfAbsent 如果為true,即使指定參數key在map中已經存在,也不會替換value
 * @param evict        如果為false,數組table在建立模式中
 * @return 如果value被替換,則傳回舊的value,否則傳回null。當然,可能key對應的value就是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;
    //如果哈希表為空,調用resize()建立一個哈希表,并用變量n記錄哈希表長度
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    /**
     * 如果指定參數hash在表中沒有對應的桶,即為沒有碰撞
     * Hash函數,(n - 1) & hash 計算key将被放置的槽位
     * (n - 1) & hash 本質上是hash % n,位運算更快
     */
    if ((p = tab[i = (n - 1) & hash]) == null)
        //直接将鍵值對插入到map中即可
        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,用e來記錄
            e = p;
            // 目前桶中無該鍵值對,且桶是紅黑樹結構,按照紅黑樹結構插入
        else if (p instanceof TreeNode)
            e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
            // 目前桶中無該鍵值對,且桶是連結清單結構,按照連結清單結構插入到尾部
        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, value>與put操作<key, value>相同時,不做重複操作,跳出循環
                if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 找到或建立一個key和hashCode與插入元素相等的鍵值對,進行put操作
        if (e != null) { // existing mapping for key
            // 記錄e的value
            V oldValue = e.value;
            /**
             * onlyIfAbsent為false或舊值為null時,允許替換舊值
             * 否則無需替換
             */
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            // 通路後回調
            afterNodeAccess(e);
            // 傳回舊值
            return oldValue;
        }
    }
    // 更新結構化修改資訊
    ++modCount;
    // 鍵值對數目超過門檻值時,進行rehash
    if (++size > threshold)
        resize();
    // 插入後回調
    afterNodeInsertion(evict);
    return null;
}      

在此過程中, 會涉及到哈希碰撞的解決。

9 HashMap.get() 方法内部是如何工作的?

/**
 * 傳回指定的key映射的value,如果value為null,則傳回null
 * get可以分為三個步驟:
 * 1.通過hash(Object key)方法計算key的哈希值hash。
 * 2.通過getNode( int hash, Object key)方法擷取node。
 * 3.如果node為null,傳回null,否則傳回node.value。
 *
 * @see #put(Object, Object)
 */
public V get(Object key) {
    Node<K, V> e;
    //根據key及其hash值查詢node節點,如果存在,則傳回該節點的value值
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}      

其最終是調用了

getNode

函數。其邏輯如下

HashMap 是如何工作的?圖文詳解,一起來看看!9 HashMap.get() 方法内部是如何工作的?

getNode 工作邏輯

/**
 * @param hash 指定參數key的哈希值
 * @param key  指定參數key
 * @return 傳回node,如果沒有則傳回null
 */
final Node<K, V> getNode(int hash, Object key) {
    Node<K, V>[] tab;
    Node<K, V> first, e;
    int n;
    K k;
    //如果哈希表不為空,而且key對應的桶上不為空
    if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
        //如果桶中的第一個節點就和指定參數hash和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) {
            //如果目前的桶采用紅黑樹,則調用紅黑樹的get方法去擷取節點
            if (first instanceof TreeNode)
                return ((TreeNode<K, V>) first).getTreeNode(hash, key);
            //如果目前的桶不采用紅黑樹,即桶中節點結構為鍊式結構
            do {
                //周遊連結清單,直到key比對
                if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    //如果哈希表為空,或者沒有找到節點,傳回null
    return null;
}