HashMap是如何工做的

@node

1 HashMap在JAVA中的怎麼工做的?

基於Hash的原理算法

2 什麼是哈希?

最簡單形式的 hash,是一種在對任何變量/對象的屬性應用任何公式/算法後, 爲其分配惟一代碼的方法。數組

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

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

Java 中全部的對象都有 Hash 方法性能

Java中的全部對象都繼承 Object 類中定義的 hashCode() 函數的默認實現。 此函數一般經過將對象的內部地址轉換爲整數來生成哈希碼,從而爲全部不一樣的對象生成不一樣的哈希碼。測試

3 HashMap 中的 Node 類

Map的定義是: 將鍵映射到值的對象。優化

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

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<K,V>[] table;

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

該數組的長度始終是 2 的次冪, 經過如下的函數實現該過程

static final int tableSizeFor(int cap) {
    int n = cap - 1;// 若是不作該操做, 則如傳入的 cap 是 2 的整數冪, 則返回值是預想的 2 倍
    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;
}

其原理是將傳入參數 (cap) 的低二進制所有變爲 1, 最後加 1 便可得到對應的大於 cap 的 2 的次冪做爲數組長度。

爲何要使用 2 的次冪做爲數組的容量呢?

在此有涉及到 HashMap 的 hash 函數及數組下標的計算, 鍵(key)所計算出來的哈希碼有多是大於數組的容量的, 那怎麼辦? 能夠經過簡單的求餘運算來得到, 但此方法效率過低。HashMap 中經過如下的方法保證 hash 的值計算後都小於數組的容量。

(n - 1) & hash

這也正好解釋了爲何須要 2 的次冪做爲數組的容量。 因爲 n 是 2 的次冪, 所以, n - 1 相似於一個低位掩碼。 經過與操做, 高位的hash值所有歸零,保證低位纔有效, 從而保證得到的值都小於 n。 同時, 在下一次 resize() 操做時, 從新計算每一個 Node 的數組下標將會所以變得很簡單, 具體的後文講解。 以默認的初始值 16 爲例

01010011 00100101 01010100 00100101
&   00000000 00000000 00000000 00001111
----------------------------------
    00000000 00000000 00000000 00000101    //高位所有歸零,只保留末四位
    // 保證了計算出的值小於數組的長度 n

可是, 使用了該功能以後, 因爲只取了低位, 所以 hash 碰撞會也會相應的變得很嚴重。 這時候就須要使用 「擾動函數」

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

該函數經過將哈希碼的高 16 位的右移後與原哈希碼進行異或而獲得, 以以上的例子爲例

異或

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

使用了 「擾動函數」 以後, hash 碰撞的機率將會降低。 有人專門作過相似的測試, 雖然使用該 「擾動函數」 並無得到最大機率的避免 hash 碰撞, 但考慮其計算性能和碰撞的機率, JDK 中使用了該方法, 且只 hash 一次。

5 哈希碰撞及其處理

在理想的狀況下, 哈希函數將每個 key 都映射到一個惟一的 bucket, 然而, 這是不可能的。 哪怕是設計在良好的哈希函數, 也會產生哈希衝突。

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

哈希碰撞解決方法

在 Java 的 HashMap 中, 採用了第一種 Separate chaining 方法(大多數翻譯爲拉鍊法)+鏈表和紅黑樹來解決衝突。

JDK8中HashMap結果

HashMap 中, 哈希碰撞以後會經過 Node 類內部的成員變量 Node<K,V> next; 來造成一個鏈表(節點小於8)或紅黑樹(節點大於8, 在小於6時會重新轉換爲鏈表), 從而達到解決衝突的目的。

static final int TREEIFY_THRESHOLD = 8;

static final int UNTREEIFY_THRESHOLD = 6;

6 HashMap 的初始化

public HashMap();
 public HashMap(int initialCapacity);
 public HashMap(Map<? extends K, ? extends V> m);
 public HashMap(int initialCapacity, float loadFactor);

HashMap 中有四個構造函數, 大可能是初始化容量和負載因子的操做。以 public HashMap(int initialCapacity, float loadFactor) 爲例

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 的初始化, 該初始化要延遲到第一次使用時進行。

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

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

transient Node<K,V>[] 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 的有效位會比原來多一位, 而多的這一位與原容量二進制在同一個位置。 示例

擴容先後

這樣就能夠很快的計算出新的索引啦

7.3 步驟

  1. 先判斷是初始化仍是擴容, 二者在計算 newCap和newThr 時會不同
  2. 計算擴容後的容量,臨界值。
  3. 將hashMap的臨界值修改成擴容後的臨界值
  4. 根據擴容後的容量新建數組,而後將hashMap的table的引用指向新數組。
  5. 將舊數組的元素複製到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的哈希值, 經過必定的計算以後算出其在哈希表中的位置,將鍵值對放入該位置,若是有哈希碰撞則進行哈希碰撞處理。

而其工做時的原理以下(圖是我很早以前保存的, 忘了出處了)
putVal工做流程

源碼以下:

/* @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 函數。 其邏輯以下

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