深刻理解Java編程性能調優——深刻淺出HashMap的設計與優化

HashMap 做爲咱們平常使用最頻繁的容器之一,相信你必定不陌生了。今天咱們就從HashMap 的底層實現講起,深度瞭解下它的設計與優化。算法

經常使用的數據結構

我在 05 講分享 List 集合類的時候,講過 ArrayList 是基於數組的數據結構實現的,LinkedList 是基於鏈表的數據結構實現的,而我今天要講的 HashMap 是基於哈希表的數據結構實現的。咱們不妨一塊兒來溫習下經常使用的數據結構,這樣也有助於你更好地理解後面地內容。數據庫

  • 數組:採用一段連續的存儲單元來存儲數據。對於指定下標的查找,時間複雜度爲 O(1),但在數組中間以及頭部插入數據時,須要複製移動後面的元素。編程

  • 鏈表:一種在物理存儲單元上非連續、非順序的存儲結構,數據元素的邏輯順序是經過鏈表中的指針連接次序實現的。

鏈表由一系列結點(鏈表中每個元素)組成,結點能夠在運行時動態生成。每一個結點都包含「存儲數據單元的數據域」和「存儲下一個結點地址的指針域」這兩個部分。設計模式

因爲鏈表不用必須按順序存儲,因此鏈表在插入的時候能夠達到 O(1) 的複雜度,但查找一個結點或者訪問特定編號的結點須要 O(n) 的時間。數組

  • 哈希表:根據關鍵碼值(Key value)直接進行訪問的數據結構。經過把關鍵碼值映射到表中一個位置來訪問記錄,以加快查找的速度。這個映射函數叫作哈希函數,存放記錄的數組就叫作哈希表。數據結構

  • :由 n(n≥1)個有限結點組成的一個具備層次關係的集合,就像是一棵倒掛的樹。

HashMap 的實現結構

瞭解完數據結構後,咱們再來看下 HashMap 的實現結構。做爲最經常使用的 Map 類,它是基於哈希表實現的,繼承了 AbstractMap 而且實現了 Map 接口。多線程

哈希表將鍵的 Hash 值映射到內存地址,即根據鍵獲取對應的值,並將其存儲到內存地址。也就是說 HashMap 是根據鍵的 Hash 值來決定對應值的存儲位置。經過這種索引方式,HashMap 獲取數據的速度會很是快。app

例如,存儲鍵值對(x,「aa」)時,哈希表會經過哈希函數 f(x) 獲得"aa"的實現存儲位置。ide

但也會有新的問題。若是再來一個 (y,「bb」),哈希函數 f(y) 的哈希值跟以前 f(x) 是同樣的,這樣兩個對象的存儲地址就衝突了,這種現象就被稱爲哈希衝突。那麼哈希表是怎麼解決的呢?方式有不少,好比,開放定址法、再哈希函數法和鏈地址法。函數

開放定址法很簡單,當發生哈希衝突時,若是哈希表未被裝滿,說明在哈希表中必然還有空位置,那麼能夠把 key 存放到衝突位置的空位置上去。這種方法存在着不少缺點,例如,查找、擴容等,因此我不建議你做爲解決哈希衝突的首選。

再哈希法顧名思義就是在同義詞產生地址衝突時再計算另外一個哈希函數地址,直到衝突再也不發生,這種方法不易產生「彙集」,但卻增長了計算時間。若是咱們不考慮添加元素的時間成本,且對查詢元素的要求極高,就能夠考慮使用這種算法設計。

HashMap 則是綜合考慮了全部因素,採用鏈地址法解決哈希衝突問題。這種方法是採用了數組(哈希表)+ 鏈表的數據結構,當發生哈希衝突時,就用一個鏈表結構存儲相同 Hash值的數據。

HashMap 的重要屬性

從 HashMap 的源碼中,咱們能夠發現,HashMap 是由一個 Node 數組構成,每一個Node 包含了一個 key-value 鍵值對。

transient Node<K,V>[] table;

Node 類做爲 HashMap 中的一個內部類,除了 key、value 兩個屬性外,還定義了一個next 指針。當有哈希衝突時,HashMap 會用以前數組當中相同哈希值對應存儲的 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;
    }
}

HashMap 還有兩個重要的屬性:加載因子(loadFactor)和邊界值(threshold)。在初始化 HashMap 時,就會涉及到這兩個關鍵初始化參數。

int threshold;
final float loadFactor;

LoadFactor 屬性是用來間接設置 Entry 數組(哈希表)的內存空間大小,在初始HashMap 不設置參數的狀況下,默認 LoadFactor 值爲 0.75。爲何是 0.75 這個值呢?

這是由於對於使用鏈表法的哈希表來講,查找一個元素的平均時間是 O(1+n),這裏的 n 指的是遍歷鏈表的長度,所以加載因子越大,對空間的利用就越充分,這就意味着鏈表的長度越長,查找效率也就越低。若是設置的加載因子過小,那麼哈希表的數據將過於稀疏,對空間形成嚴重浪費。

那有沒有什麼辦法來解決這個因鏈表過長而致使的查詢時間複雜度高的問題呢?你能夠先想一想,我將在後面的內容中講到。

Entry 數組的 Threshold 是經過初始容量和 LoadFactor 計算所得,在初始 HashMap 不設置參數的狀況下,默認邊界值爲 12。若是咱們在初始化時,設置的初始化容量較小,HashMap 中 Node 的數量超過邊界值,HashMap 就會調用 resize() 方法從新分配 table數組。這將會致使 HashMap 的數組複製,遷移到另外一塊內存中去,從而影響 HashMap的效率。

HashMap 添加元素優化

初始化完成後,HashMap 就可使用 put() 方法添加鍵值對了。從下面源碼能夠看出,當程序將一個 key-value 對添加到 HashMap 中,程序首先會根據該 key 的 hashCode() 返回值,再經過 hash() 方法計算出 hash 值,再經過 putVal 方法中的 (n - 1) & hash 決定該 Node 的存儲位置。

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;
    // 經過 putVal 方法中的 (n - 1) & hash 決定該 Node 的存儲位置
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);

若是你不太清楚 hash() 以及 (n-1)&hash 的算法,就請你看下面的詳述。

咱們先來了解下 hash() 方法中的算法。若是咱們沒有使用 hash() 方法計算 hashCode,而是直接使用對象的 hashCode 值,會出現什麼問題呢?

假設要添加兩個對象 a 和 b,若是數組長度是 16,這時對象 a 和 b 經過公式 (n - 1) &hash 運算,也就是 (16-1)&a.hashCode 和 (16-1)&b.hashCode,15 的二進制爲0000000000000000000000000001111,假設對象 A 的 hashCode 爲1000010001110001000001111000000,對象 B 的 hashCode 爲0111011100111000101000010100000,你會發現上述與運算結果都是 0。這樣的哈希結果就太讓人失望了,很明顯不是一個好的哈希算法。

但若是咱們將 hashCode 值右移 16 位(h >>> 16 表明無符號右移 16 位),也就是取int 類型的一半,恰好能夠將該二進制數對半切開,而且使用位異或運算(若是兩個數對應的位置相反,則結果爲 1,反之爲 0),這樣的話,就能避免上面的狀況發生。這就是hash() 方法的具體實現方式。簡而言之,就是儘可能打亂 hashCode 真正參與運算的低 16位

我再來解釋下 (n - 1) & hash 是怎麼設計的,這裏的 n 表明哈希表的長度,哈希表習慣將長度設置爲 2 的 n 次方,這樣剛好能夠保證 (n - 1) & hash 的計算獲得的索引值老是位於table 數組的索引以內。例如:hash=15,n=16 時,結果爲 15;hash=17,n=16 時,結果爲 1。

在得到 Node 的存儲位置後,若是判斷 Node 不在哈希表中,就新增一個 Node,並添加到哈希表中,整個流程我將用一張圖來講明:

深刻理解Java編程性能調優——深刻淺出HashMap的設計與優化

從圖中咱們能夠看出:在 JDK1.8 中,HashMap 引入了紅黑樹數據結構來提高鏈表的查詢效率。

這是由於鏈表的長度超過 8 後,紅黑樹的查詢效率要比鏈表高,因此當鏈表超過 8 時,HashMap 就會將鏈表轉換爲紅黑樹,這裏值得注意的一點是,這時的新增因爲存在左旋、右旋效率會下降。講到這裏,我前面我提到的「因鏈表過長而致使的查詢時間複雜度高」的問題,也就迎刃而解了。

如下就是 put 的實現源碼:

final V putVal(int hash, K key, V value, Boolean onlyIfAbsent,
Boolean evict) {
    Node<K,V>[] tab;
    Node<K,V> p;
    int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
    //一、判斷當 table 爲 null 或者 tab 的長度爲 0 時,即 table 還沒有初始化,此時經過 resize() 方法
    n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)
    //1.一、此處經過(n - 1) & hash 計算出的值做爲 tab 的下標 i,並另 p 表示 tab[i],也就是該鏈表
    tab[i] = newNode(hash, key, value, null);
    //1.1.一、當 p 爲 null 時,代表 tab[i] 上沒有任何元素,那麼接下來就 new 第一個 Node 節點,調用 else {
        //2.1 下面進入 p 不爲 null 的狀況,有三種狀況:p 爲鏈表節點;p 爲紅黑樹節點;p 是鏈表節點但長度
        Node<K,V> e;
        K k;
        if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))
        //2.1.1HashMap 中判斷 key 相同的條件是 key 的 hash 相同,而且符合 equals 方法。這裏判斷了 p.
        e = p; else if (p instanceof TreeNode)
        //2.1.2 如今開始了第一種狀況,p 是紅黑樹節點,那麼確定插入後仍然是紅黑樹節點,因此咱們直接強制轉
        e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else {
            //2.1.3 接下里就是 p 爲鏈表節點的情形,也就是上述說的另外兩類狀況:插入後仍是鏈表 / 插入後轉紅黑
            for (int binCount = 0; ; ++binCount) {
                // 咱們須要一個計數器來計算當前鏈表的元素個數,並遍歷鏈表,binCount 就是這個計數器
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1)
                    // 插入成功後,要判斷是否須要轉換爲紅黑樹,由於插入後鏈表長度加 1,而 binCount 並不包含新節點,
                    treeifyBin(tab, hash);
                    // 當新長度知足轉換條件時,調用 treeifyBin 方法,將該鏈表轉換爲紅黑樹
                    break;
                }
                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;
    if (++size > threshold)
    resize();
    afterNodeInsertion(evict);
    return null;
}

HashMap 獲取元素優化

當 HashMap 中只存在數組,而數組中沒有 Node 鏈表時,是 HashMap 查詢數據性能最好的時候。一旦發生大量的哈希衝突,就會產生 Node 鏈表,這個時候每次查詢元素均可能遍歷 Node 鏈表,從而下降查詢數據的性能。

特別是在鏈表長度過長的狀況下,性能將明顯下降,紅黑樹的使用很好地解決了這個問題,使得查詢的平均複雜度下降到了 O(log(n)),鏈表越長,使用黑紅樹替換後的查詢效率提高就越明顯。

咱們在編碼中也能夠優化 HashMap 的性能,例如,從新 key 值的 hashCode() 方法,下降哈希衝突,從而減小鏈表的產生,高效利用哈希表,達到提升性能的效果。

HashMap 擴容優化

HashMap 也是數組類型的數據結構,因此同樣存在擴容的狀況。

在 JDK1.7 中,HashMap 整個擴容過程就是分別取出數組元素,通常該元素是最後一個放入鏈表中的元素,而後遍歷以該元素爲頭的單向鏈表元素,依據每一個被遍歷元素的 hash 值計算其在新數組中的下標,而後進行交換。這樣的擴容方式會將原來哈希衝突的單向鏈表尾部變成擴容後單向鏈表的頭部。

而在 JDK 1.8 中,HashMap 對擴容操做作了優化。因爲擴容數組的長度是 2 倍關係,因此對於假設初始 tableSize = 4 要擴容到 8 來講就是 0100 到 1000 的變化(左移一位就是2 倍),在擴容中只用判斷原來的 hash 值和左移動的一位(newtable 的值)按位與操做是 0 或 1 就行,0 的話索引不變,1 的話索引變成原索引加上擴容前數組。

之因此能經過這種「與運算「來從新分配索引,是由於 hash 值原本就是隨機的,而 hash按位與上 newTable 獲得的 0(擴容前的索引位置)和 1(擴容前索引位置加上擴容前數組長度的數值索引處)就是隨機的,因此擴容的過程就能把以前哈希衝突的元素再隨機分佈到不一樣的索引中去。

總結

HashMap 經過哈希表數據結構的形式來存儲鍵值對,這種設計的好處就是查詢鍵值對的效率高。

咱們在使用 HashMap 時,能夠結合本身的場景來設置初始容量和加載因子兩個參數。當查詢操做較爲頻繁時,咱們能夠適當地減小加載因子;若是對內存利用率要求比較高,我能夠適當的增長加載因子。

咱們還能夠在預知存儲數據量的狀況下,提早設置初始容量(初始容量 = 預知數據量 / 加載因子)。這樣作的好處是能夠減小 resize() 操做,提升 HashMap 的效率。

HashMap 還使用了數組 + 鏈表這兩種數據結構相結合的方式實現了鏈地址法,當有哈希值衝突時,就能夠將衝突的鍵值對鏈成一個鏈表。

但這種方式又存在一個性能問題,若是鏈表過長,查詢數據的時間複雜度就會增長。HashMap 就在 Java8 中使用了紅黑樹來解決鏈表過長致使的查詢性能降低問題。如下是HashMap 的數據結構圖:

深刻理解Java編程性能調優——深刻淺出HashMap的設計與優化

寫在最後

今天的"深刻淺出HashMap的設計與優化"就總到這裏,更多關於Java編程性能調優的問題技術點(以下圖)

深刻理解Java編程性能調優——深刻淺出HashMap的設計與優化

更多強如:

多線程性能調優

深刻理解Java編程性能調優——深刻淺出HashMap的設計與優化

JVM性能監測及調優

深刻理解Java編程性能調優——深刻淺出HashMap的設計與優化

設計模式調優

深刻理解Java編程性能調優——深刻淺出HashMap的設計與優化

數據庫性能調優

深刻理解Java編程性能調優——深刻淺出HashMap的設計與優化


本文轉載自:深刻理解Java編程性能調優——深刻淺出HashMap的設計與優化

相關文章
相關標籤/搜索