經常使用的HashMap究竟是個什麼結構

0x00 前言

HashMap 是最經常使用的容器之一,應該沒什麼疑問了。可你到底瞭解他嗎?網上已經有不少文章來總結 HashMap 了,我來寫這篇,主要是爲了記錄本身閱讀以後的一點點小感悟,如如有錯誤的地方,請你們指正。下文分析基於 jdk1.8java

0x01 一句話介紹

HashMap 內部是一個 Node 類數組,每一個節點存放對應的數據。數組

0x02 概述

先來介紹下 HashMap ,主要依據來自 HashMap 的註釋(熟悉的同窗能夠直接跳過到0x03部分)。安全

一、HashMap 實現了 Map 接口,擁有 Map 的全部操做,具備如下特色:bash

  • 容許 null 的 key 和 value 。
  • 大體上,他和 HashTable 相同,可是 HashTable 是線程安全的,而 HashMap 非線程安全。出於此緣由,HashMap 在性能上明顯會優於 HashTable 。
  • 不保證順序,緣由在於其內在的原理,他是根據 key 的 hash 值來計算位置的,因此,順序天然是沒法保證的了。(到底怎麼算的,往下看。)

二、HashMap 的 get , put 在hash值比較均勻的狀況下,操做都是常數級別的時間複雜度。一個很是重要的點是,capacity 不能設置過高,load factor 不能設置的過低。(這兩個變量又是幹嗎的呢,這裏先賣個關子✧(≖ ◡ ≖✿)嘿嘿)。函數

三、由於他不是線程安全的,因此能夠經過 Collections.synchronizedMap 來包裝,從而變成一個線程安全的 Map。性能

四、擁有 fail-fast 特性。簡單來講,就是在遍歷的時候,發現元素被改變,就拋出異常。ui

0x03 解釋幾個變量

構造函數裏面的 initialCapacity

這個參數的意思比較明顯,就是初始的 Map 長度。默認是 16。this

Node<K,V>[] table

Map 中真正存放元素的地方,能夠看到他是一個 Node 數組。Node 結構比較簡單,就是一個 key-value 組成的一個鏈表,其中還有 hash變量,和 next 變量。spa

float loadFactor

顧名思義,負載因子。默認值是0.75,是一個空間和時間上的權衡。具體怎麼來的,多是一個複雜的邏輯推算。線程

int threshold

閾值,Map 所能容納的鍵值對數量。是根據 Map 中的數組長度*loadFactor計算出來的。看到這個,應該就能夠想到,若是 loadFactor設置的過小,會有什麼問題了。沒錯,若是設置過小,容量就會很小,致使空間上的一個浪費,大部分的位置都是空的,沒有被充分利用。反之,若是設置太大,就會致使元素放置很是擁擠,查詢起來效率就會變低。

0x04 方法分析

構造函數

HashMap 有好幾個構造函數,來看一個比較重要的吧。

public HashMap(int initialCapacity, float loadFactor) {
        // 若是傳遞進來的初始化數組的大小小於0,就是不合法,直接拋異常。
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        // 若是大於最大的值,就讓他等於最大值。
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        // 根據 tableSizeFor 方法進行數組長度的對齊。
        this.threshold = tableSizeFor(initialCapacity);
    }
    
    // 數組長度的對齊。
    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        // 通過如下的變化,數組的長度必定是2^n了。 
        n |= n >>> 1;  // 1
        n |= n >>> 2;  // 2
        n |= n >>> 4;  // 3
        n |= n >>> 8;  // 4
        n |= n >>> 16; // 5
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }
複製代碼

在這裏給方法tableSizeFor舉個🌰:

若是我設置cap爲13,則13-1的二進制是:
  0000 0000 0000 1100
此時進行第一步:
          0000 0000 0000 1100
    >>>1  0000 0000 0000 0110
    |=    0000 0000 0000 1110
第二步:
          0000 0000 0000 1110
    >>>2  0000 0000 0000 0011
    |=    0000 0000 0000 1111
第三步:
          0000 0000 0000 1111
    >>>4  0000 0000 0000 0000
    |=    0000 0000 0000 1111
……
能夠看出,後面應該全是 1111(2)了。最後加個1,就是16,2^4.
有的同窗可能不信,因此再舉個更大的🌰:
  0100 0110 0101 0110
此時進行第一步:
          0100 0110 0101 0110
    >>>1  0010 0011 0010 1011
    |=    0110 0111 0111 1111
第二步:
          0110 0111 0111 1111
    >>>2  0001 1001 1101 1111
    |=    0111 1111 1111 1111
第三步:
          0111 1111 1111 1111
    >>>4  0000 0111 1111 1111
    |=    0111 1111 1111 1111
……
能夠看到,最終結果仍是同樣。二進制有不少好玩的特性,若是能利用好,性能上的提高絕對不止一點半點。
複製代碼
Node節點類

包含一個 hash,key, value。

put
public V put(K key, V value) {
    // 實際調用 putVal 方法。此時可能有個疑問,key他本身不是有hashcode方法嗎?爲何還要本身寫一個?暫且按下,先看看 putVal 方法。 ①
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 若是 tab 尚未被初始化,或者是空,則先進行 resize .
    if ((tab = table) == null || (n = tab.length) == 0)
        // n 就是tab的長度。 ③
        n = (tab = resize()).length;
    // 這裏是重點,怎麼定位? (n-1)&hash 來定位的。 ②
    if ((p = tab[i = (n - 1) & hash]) == null)
        // 若是是null,則建立一個新的節點。
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 若是舊節點就是須要被put的節點,則將值直接進行替換。
        // 能夠看到他是根據 == 判斷是不是同一個對象,或者 equals 方法來判斷是否相等。
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 若是是 紅黑樹,則調用紅黑樹的putTreeVal。
        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;
                }
                // 找到則終結循環
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 若是找到了對應的節點,進行一個替換
        if (e != null) {
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                // 若是標誌檢查符合,或者原值爲null,則進行賦值。
                e.value = value;
            // 節點可用完成通知
            afterNodeAccess(e);
            return oldValue;
        }
    }
    // 修改變動標誌加一
    ++modCount;
    // 若是總的節點數量,大於了閾值,則進行擴容
    if (++size > threshold)
        resize();
    // 插入完畢通知
    afterNodeInsertion(evict);
    return null;
}
複製代碼

講講②處,爲何這樣定位呢? (n - 1) & hash 。n 是 table 的長度,是一個 2^n 的數字。通常狀況下,若是有一個大於數組長度的位置,咱們怎麼來將其放入數組中呢?很簡單,取模。對這個位置取模,獲得的值確定都是小於數組長度的。劃重點!因此這個 (n - 1) & hash 也是取模!咱們都知道,n-1 的二進制,都是高位0 + 低位多個1組成的。此時和 hash 值相與,與出來的值,確定是小於 n-1 的,這就達到了一個取模的效果。空說無憑,仍是舉個🌰。

假設 n = 16, n-1的二進制即爲 1111 。再隨便寫個32位的hash。
    0000 0000 0000 1111
&   1001 1101 1110 0110
=   0000 0000 0000 0110 = 6 < 16
複製代碼

方法很是巧妙,避免了取模,大大提高了索引的速度。

此時引出了①的緣由。先看看hash的尊容。

// hash 方法。
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
複製代碼

來講說這個 hash 方法。能夠看到若是是 null,則返回的是0.非null,則調用了key#hashCode方法,而且異或了hash值右移16位。分析②的時候,能夠看到定位是根據hash值和n-1相與來肯定位置的,這就是爲何要從新一個hash的緣由。哈?爲何?根據②的🌰,能夠看出來,定位和hash值的前幾位都沒有關係,只和 n-1 的二進制長度的位數有關。這就帶來一個問題,很是容易產生衝突,隨機性被下降了,畢竟高xx位都沒有參與運算,就那麼幾位,確定容易產生衝突。異或這個操做,將高位也拉了進來,大大提升了參與度,hash散列也會更好。仍是舉一個🌰。

1110 0010 0011 1101      1110 0000 0011 1101
>>16   0000 0000 1110 0010      0000 0000 1110 0000
^=     1110 0010 1101 1111      1110 0000 1101 1101
能夠看到,若是不進行這個操做,這兩個元素確定是在一個定位上的,若是加上高位操做,則被分散了。
複製代碼
resize

resize是用來對map進行擴容的方法。對應上面的註釋③。

final Node<K,V>[] resize() {
    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; // 閾值翻倍。
    }
    else if (oldThr > 0)
        // 若是閾值大於0&舊的數組長度小於1,則將新數組長度設置爲閾值的大小。
        newCap = oldThr;
    else {               // 上述條件都不符合,則使用初始值。。
        newCap = DEFAULT_INITIAL_CAPACITY;
        // 新的閾值是默認負載因子*默認數組長度的值。
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);  
    }
    // 若是新閾值爲0
    if (newThr == 0) {
        根據是否在範圍內,對新閾值賦值,方法同上。
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) { // 若是是擴容,不是初始化,則須要進行元素遷移。
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                // e 已經引用了就數據,因此將數組對應位置清空,利於垃圾回收。
                oldTab[j] = null;
                if (e.next == null)  // ① 若是鏈表只有一個元素,則將其放入新數組對應的位置,計算方法已經說過。
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode) //②  若是是一顆紅黑樹,則利用 split 方法來進行拆樹。
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // 若是仍是鏈表。 ③
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        // next 是 e 的下一個元素
                        next = e.next;
                        if ((e.hash & oldCap) == 0) { // e 的 hash 值和舊的數組長度相與爲0 ③-1
                            if (loTail == null)  // 若是低位尾部是null,則低位頭是 e.
                                loHead = e;
                            else  // 不然,低位尾部接着往下鏈。
                                loTail.next = e;
                            loTail = e; // 尾指向下一個。
                        }
                        else {  // 不然,對高位尾操做,操做和低位相似。
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) { // 將低位鏈表放置到 j 的位置上。
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) { // 將高位組成的鏈表放置到 j + oldCap 位置上。
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}
複製代碼

上面一部分都很好理解。重點看看①-③這幾個地方。先畫個圖吧。好比一個 map 的的長度是4,裏面如此放置元素(爲了舉例,假設能夠放置這麼多):

[0] -> (0)  //第①種,元素0的後面沒有任何元素了,因此直接進行放置
[1] -> (             1               
                5          25
              9   17    21    29
               13                33   ) // 這就屬於②了,鏈表已經被轉化爲一顆紅黑樹了。因此須要將樹給拆掉。
[2] -> (2 -> 6 -> 10 -> 14) // 這就屬於第③種狀況了,須要將此鏈表拆開,放置到新的數組當中。
[3] -> (3)
複製代碼

在此着重講講第三種狀況。仍是上圖。

[0]                                 [0]
[1]                                 [1]
[2]-> (2 -> 6 -> 10 -> 14) ----->   [2] -> (2 -> 10)
[3]                                 [3]
                                    [4]
                                    [5]
                                    [6] -> (6 -> 14)
                                    [7]
                                    [8]
此處看看怎麼遷移。先看看他們和舊長度4相與是否爲0。
            2       6       10      14 
二進制      0010    0110    1010    1110
&          0100    0100    0100    0100
=          0000    0100    0000    0100
列出4和8的二進制數:
            4       8
二進制      0100    1000
n-1        0011    0111
複製代碼

能夠看出一些規律,長度的二進制老是隻有一個1,其他位都是0。而位置計算是 hash&n-1,能夠發現,新的位置不過是hash&2n-1,用二進制來看,就是左移了一位補1.因此和原來位置惟一的差異在哪呢,就在這個左移出來的1身上。這就是爲何③-1中爲何判斷e.hash & oldCap。若是長度二進制爲1的那個位置是0的元素,就留在原地,反之,則放置到 j +oldCap 位置。由於擴容是兩倍,因此就是原來的位置加上一個原數組長度。

get

get 方法和 put 方法很是相似。只不過 get 是 get 返回,put 是 set 值進去。內部調用了 getNode 方法。

remove

remove 和 put 方法也很是相似,就是找到對應的元素,進行刪除而已。

containsKey
public boolean containsKey(Object key) {
    // 也是調用了get方法調用的內部方法,判斷返回的值是否爲null。因此和 get 方法只是一個用不用返回值的區別。
    return getNode(hash(key), key) != null;
}
複製代碼
size

直接返回了記錄元素個數的 size 變量。

clear

遍歷數組,挨個進行 null 賦值。

containsValue

遍歷數組和對應的鏈表,查看 value 是否相等。

紅黑樹相關的函數

這些函數是java8新增的,若是鏈表過長,一個個遍歷很是影響效率,因此 map 內部將他變成了一顆紅黑樹,此文就不進行詳解了。這部分放到 TreeMap 分析的時候再進行描述。

0x05 喝口水,來個總結

本文講解了 HashMap 中的一部分核心問題,沒有所有都講下來。還有resize線程安全問題,紅黑樹相關的部分沒有講解。線程安全這個,後面也會單獨來一篇進行講解。紅黑樹則放到 TreeMap 的分析當中。若是文中有誤,請你們指出,感激涕零。

相關文章
相關標籤/搜索