Java筆記4.1:HashMap源碼探索

基本數據模型

    在HashMap的實現中,最基本的數據模型有兩個,分別是用來表示一個鍵值對的類Node<K, V>和用於保存所有鍵值對的數組transient Node<K,V>[] table;Node<K, V>的部分定義如下:

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;

    // methods
}

在向HashMap中添加鍵值對時,會先根據key的哈希值進行再一次的哈希運算,得到該鍵值對在table中的位置(源碼中對table中的一個位置稱爲一個桶,bucket)。該過程不可避免地會產生哈希衝突,即出現計算出的位置上已經有其他鍵值對存在的情況。爲了兼顧時間性能與空間性能,HashMap採用了以下兩個主要的優化手段:

  • 適當的時機擴容以降低衝突:當向表中插入的鍵值對太多時,發生哈希衝突的概率會提高。因此在HashMap中的鍵值對數量超過閾值時進行擴容與重新哈希。
  • 當“桶”中對象數太多時,用紅黑樹組織。HashMap採用的解決衝突的辦法是拉鍊法,即將新的值仍然插入到該位置,形成一個鏈表。如果鏈表過長,會產生性能問題,因此從jdk1.8開始,又引入了紅黑樹,即當某個位置上的鏈表長度超過閾值時,將鏈表改造爲一棵紅黑樹,這個過程稱爲“樹化”。樹化過程中,Node對象將變爲HashMap.TreeNode類型(是Node的子類)

    綜上,HashMap內部的結構如下:
hashmap

哈希與擴容

哈希值計算——爲什麼要右移16位?

    HashMap中會根據key對象的hashCode重新計算哈希值,計算哈希值的方法如下:

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

從中可以得到以下兩方面的信息:

  • HashMap是允許null作爲key的,並且null的哈希爲0
  • 一般的key在進行計算哈希時,用其哈希值右移16位的結果與其自身做按位異或。

    註釋中簡單解釋了採用這種哈希函數的原因,大意是說尋址計算時,能夠參與到計算的有效二進制位僅僅是右側和數組長度值對應的那幾位,意味着發生衝突的機率會高。通過移位和異或運算讓hashCode的高位能夠參與到尋址計算中。採用這種方式是爲了在性能、實用性、分佈質量上取得一個平衡。有很多hashCode算法都已經是分佈合理的了,並且大量衝突時,還可以通過樹結構來解決查詢性能問題。

容量與尋址——爲什麼是2的次方?

    HashMap中根據哈希結果計算數組下標(尋址)的方法如下:hash & (capacity-1),其中hash是重新計算過的hash值,capacity是table的長度。由於HashMap的實現確保了capacity是2的次冪,此時hash & (capacity-1)的運算效果等同於hash % capacity,而位運算的效率高,這樣的寫法能夠提高尋址的性能。

這也很好理解,例如length爲16時,其二進制爲00010000,那麼length-1的二進制爲00001111,與hash做按位與計算時,結果就是隻留下低4位的值,屏蔽了高位,也即是height%length的結果。

    擴容相關的方法resize()中的部分代碼如下:

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; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    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;
    // something else...
}
  • 在構造HashMap時,tablenull,只有在第一次向表中放入對象時纔會觸發resize()方法,構建一個容量爲16(即代碼中的DEFAULT_INITIAL_CAPACITY)的數組。
  • 每一次觸發擴容時,都會將數組的大小擴充爲原來的兩倍。這就保證了數組的大小一定是2的次冪。

擴容與負載因子

何時觸發擴容

    在向表中添加鍵值對時,如果表內已有的鍵值對數量大於閾值threshold,就會觸發一次擴容,擴容的過程上文已有簡述。

負載因子

    HashMap中有一個字段爲loadFactor,稱爲負載因子,閾值的計算就是依賴於負載因子。threshold = capacity * loadFactor。如果HashMap 在構造時沒有指定負載因子,默認的負載因子爲0.75。如果擴容因子越大,意味着哈希表越滿,碰撞的概率也就越大,發生碰撞後的代價也更大,結果導致效率大打折扣。因此擴容因子取0.75也是一個空間換時間的考慮。

樹化

    前面已經提到過樹化的概念,即當table中某個位置出現了太多的衝突時,就要將該位置上的元素由鏈表排列改爲按紅黑樹排列,以提高查找性能。在HashMap中,這個閾值默認爲8。

在鏈表查找時,時間複雜度爲爲O(n)O(n),而在紅黑樹上進行查找時,時間複雜度爲O(logn)O(\log n)

爲什麼不直接用紅黑樹?

    對於這一點,源碼註釋用也有提到,因爲在用紅黑樹組織對象時,樹的節點所用的類爲HashMap.TreeNode,該類的大小几乎爲其祖先類HashMap.Node的兩倍,而在鏈表很小的時候,其實其查找性能的差異並不是特別大,因此這是一個空間換時間的決定。

爲什麼樹化的默認閾值爲8?

    源碼的註釋中有提到,假設hashCode真的分佈很均勻,那麼數組中每個位置出現的對象數符合泊松分佈,在負載因子爲0.75的前提下,分佈爲:

頻數 概率
0 0.60653066
1 0.30326533
2 0.07581633
3 0.01263606
4 0.00157952
5 0.00015795
6 0.00001316
7 0.00000094
8 0.00000006
大於8 小於一千萬分之一

可見其實當閾值定爲8時,需要樹化的概率已經相當低了,大於8的概率更是低到可以忽略不及。因此閾值取8已經有很好的時間和空間性能了。

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