HashMap墨跡詳解-由淺入深

HashMap集合

HashMap爲Key-Value鍵值對存儲的集合,本文不會說出HashMap所有的原理,尤其是紅黑樹的原理,我沒去看,太麻煩了,其他的我覺得也夠用了!一起來看!墨跡起來。。。
 

關鍵字段及含義

//Map默認的開發的容量大小16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大容量2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
//負載因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//沒看
static final int TREEIFY_THRESHOLD = 8;
//沒看
static final int UNTREEIFY_THRESHOLD = 6;
//沒看
static final int MIN_TREEIFY_CAPACITY = 64;

1. 爲什麼默認16個長度?

查詢了好多文章,沒有一個特別有說服力的,做一個簡單的總結吧,加上個人想法!!

1.1 先說爲什麼容量一直是2的N次方?

第一點是2的N次方,可以保證Hash值的二進制位都是1,HashMap在計算元素索引的時候,代碼是這個樣子滴:(n - 1) & hash,假設n=16,那麼n-1=1111,可以保證n-1的二進制數都是1。爲什麼要保證都爲1呢?這就涉及到一個概率的問題,也就是常說的爲了HashMap中的元素能夠均勻的分佈到每個槽位上,至於算法概率上,不太好舉例子,只能做一個簡單的假設:

  1. 假如新加入一個元素,Key的hash值的後四位爲1001,那麼與1111做運算,1001 & 1111 = 1001;
  2. 再加入一個元素,key的hash值後四位是1011,做運算,1011 & 1111 = 1011
  3. 可以知道,這兩次的索引計算結果不同。再看下面的假設
  4. 假設我們規定Hash的默認長度爲10,那麼10-1=9=1001
  5. 然後我們分別看一下計算結果

              長度   key的hash值           結果

              1001   1101                        1001

              1001   1011                        1001

 可以看出來,不同的hash值,計算的結果一樣,這就是一個概率的問題,爲了儘量讓元素均勻分佈,如果長度值爲16,那麼二進制             全是1,則可以縮小索引碰撞的機率。

1.2 爲什麼是16長度,爲什麼不是4、32、64?

這就是一個“合適”的問題,可能覺得4太小,32有點浪費!其他我想不出來有什麼原因了!4的話,沒加入幾個元素就要再次擴容,32的話,不一定有這麼多元素會被設置到map中,所以取了16作爲默認長度。

2. 負載因子作用是什麼?爲什麼是0.75?

負載因子是爲了計算閾值的,閾值就是Map擴充的一個判斷值,當元素的數量達到閾值,會擴容。初始化時:閾值=負載因子*默認容量;擴容時:閾值=當前閾值<<1,即2倍

2.1爲什麼是0.75?

探討這個問題,首先要知道如果負載因子大了或者小了會有什麼問題!

     1. 假如負載因子爲1,那麼會導致下面的情況:

         Map中的元素達到當前Map的容量時纔會擴容,那麼此時,會出現一個效率問題,什麼問題呢?第一點,因爲沒有提前擴容,哈希碰撞的機率增加,鏈表深度過長。這樣就帶來兩個問題,由於碰撞機率增加,插入會變慢!鏈表深度過長,查詢會變慢!

     2. 假如負載因子過小,那麼會導致提前擴容,hash衝突機率倒是變小了,但在沒有更多的元素加入情況下,空間是一種浪費!

好,到這裏,那麼爲啥子非要是0.75呢?爲啥子不是0.5、0.6呢?答:泊松分佈!!!面試官要特麼問我這個,我日他大爺,老子不會。

HashMap擴容

1. 什麼時候會擴容?

直接上代碼看:HashMap的putVal方法,主要兩個地方涉及到擴容!

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)
        n = (tab = resize()).length;
//省略部分代碼,看重點
        if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

resize爲HashMap的擴容方法!

  1. 如果當前Map沒有元素,則擴容
  2. 如果當前Map的元素大於閾值,則擴容。閾值=負載因子*當前Map容量
  3. 還有樹化的時候,也會擴容,沒看

2. 擴容機制及原理

那就必須擼代碼啦!上!直接在代碼上註釋啦!先說簡單的部分代碼:

final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
    //定義當前容量,爲空則是0
int oldCap = (oldTab == null) ? 0 : oldTab.length; 
    //定義當前閾值
int oldThr = threshold;
    //定義新的容量和新的閾值
int newCap, newThr = 0;
    //如果當前容量大於0
if (oldCap > 0) {
//如果當前容量大於最大容量,2的30次方
        if (oldCap >= MAXIMUM_CAPACITY) {
//閾值設置爲2的31次方
            threshold = Integer.MAX_VALUE;
//返回舊的容量大小,不再擴充容量
            return oldTab;
        }
//如果舊容量的2倍小於最大容量,並且舊容量大於默認的容量16
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
//擴充閾值爲原來的2倍
            newThr = oldThr << 1; // double threshold
    }
//如果舊的閾值大於0,則新的容量等於舊的閾值,一般不走到這裏,在初始化Map的時候指定容量和負載因子的時候,第二次擴容會走到這。
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
//如果是無參構造創建Map,則走這裏
    else {               // zero initial threshold signifies using defaults
//新容量=默認容量16
        newCap = DEFAULT_INITIAL_CAPACITY;
//新的閾值=負載因子*默認容量=0.75*16=12
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
//經過上面的判斷,如果新的閾值還是0,也就是說通過指定容量和負載因子初始化Map的時候,會走到這
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
//將新的閾值設置到threshold
    threshold = newThr;
    }

總結:

  1. 通過無參構造初始化Map,默認開發16長度的容量,負載因子0.75,閾值12
  2. 當Map新增元素後,size達到12後,進行擴容,容量提升2倍,閾值提升2倍
  3. 指定構造方法初始化Map,容量和負載因子由用戶指定,擴容條件還是和上述一樣,大於閾值纔會擴容,算法也是一樣的,只是初始容量和負載因子不同而已。

接着往下看:

  Node<K,V> e;
  if ((e = oldTab[j]) != null) {
       oldTab[j] = null;
       if (e.next == null)
           newTab[e.hash & (newCap - 1)] = e;

  1. 創建一個臨時變量,Node E節點,來保存當前循環節點的鏈表,oldTab[j]就是當前索引下的鏈表體。
  2. 如果當前索引不爲空,則將當前的索引置爲空
  3. 如果節點E沒有後續節點,則進行rehash,就是重新計算該元素的索引位置。這裏就比較有意思了,不經讚歎開發者對算法的高超啊。如果E有後續節點,後續再說。
  4. 那麼rehash是不是一定會變更索引的位置呢,答:不一定!存在兩種情況,要麼保持原有的位置不動,要麼一當前索引爲基準向後移動舊容量數目。
  5. 看下面計算,我們向map中設置一個key爲42,對應代碼 newTab[e.hash & (newCap - 1)]來看,4次擴容後的索引位置變化。注意:如果newCap-1的二進制位高位不夠則補0,比如第一次計算1010101&1111=101010&001111,也可以說newCap-1有多少二進制位,e.hash的後幾位纔算有效的。

                     e(十進制)       e.hash                       newCap-1         newCap-1(二進制)          擴容後索引位置

                     42                  101010                       15                       1111                                     1010 = 10

                     42                  101010                       31                       11111                                   1010 = 10

                     42                  101010                       63                       111111                                101010 = 42

                     42                  101010                       127                     1111111                              101010 = 42    

2.1 擴容索引移位規律

根據上述計算可以得知,rehash的索引位置移動存在兩種情況,要麼保持原有的位置不動,要麼以當前索引爲基準向後移動舊容量數目。比如第三次擴容的時候,索引向後移動了2的5次方,即是32(我們原來的舊容量大小)。這是一個規律,因爲每次擴容量是2倍,那麼在二進制位看來,也就是前面的一個位多了一個1,好比第三次的擴容計算,容量由32擴充到64,這樣一來高位多了一個1,高位多出來的1與e.hash做運算,e.hash高位的1就保留了下來,而恰巧這個高位的1就是2的5次方=我們的舊容量大小32。所以如果我們key的哈希值過短,則在幾次擴容之後,不會再變更索引位置;如果夠長,則可能還會變更位置。

好了!!上述代碼已經解析完畢,接下來的情況,就是索引上的節點存在深度爲2以上的鏈表,這種情況是怎麼rehash的呢?分兩種情況:

第一種情況:樹化節點的rehash,這個要看紅黑樹TreeNode!!!太噁心,略過!!!

第二種情況:普通鏈表的rehash, 那麼前提我們要知道,元素在put的時候,發生了什麼,put是一個怎麼樣的操作,put一個元素後,數據結構是怎樣的!!請先看put方法解析,在來看多節點鏈表的重組過程!

3. 深度節點擴容過程

接着上面的擴容代碼說:

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 {
        if (hiTail == null)
            hiHead = e;
        else
            hiTail.next = e;
        hiTail = e;
    }
} while ((e = next) != null);
if (loTail != null) {
    loTail.next = null;
    newTab[j] = loHead;
}
if (hiTail != null) {
    hiTail.next = null;
    newTab[j + oldCap] = hiHead;
}

這個代碼,是擴容中else的代碼,也就是說如果索引處存在2個以上的節點,會走到這裏。來看一下下面兩個圖,初始容量16, 位置爲0索引處的節點有3個,節點分別用ABC代替:

我們假設有一種情況,注意這是邏輯的條件,看懂了:

  1. 我們打算擴容到32
  2. 擴容到32位之後,會重新計算每個key的索引
  3. 假設A和C通過e.hash & oldCap == 0 爲真,則說明A和C的索引不變
  4. 假設B通過e.hash & oldCap == 0 爲假,則說明B的索引要變化
  5. B的索引變化,就是j+oldCap。
  6. 所以擴容之後各節點的結構如下圖

這裏可以得知,e.hash & oldCap是用來判定索引是否要發生變化的,又是算法的巧妙。總之不論哪種方式,索引要麼不變,要不移動舊數量的數目

put方法解析

代碼就不貼上了,直接看HashMap的putVal方法

  1. 如果當前map是空的,則調用擴容方法進行初次擴容
  2. 擴容後,計算元素在數祖上的索引,公式index=(容量-1)&hash值
  3. 如果當前索引處沒有節點,則new一個Node節點存放key和value,再把這個Node設置到當前索引處
  4. 如果當前索引處存在節點是普通節點,取出第一個節點Node,與新添加的元素相比較,如果hash值和equals方法都是相同的,則替換該元素的value
  5. 如果當前索引處存在節點是樹節點,調用putTreeVal方法
  6. 如果當前索引處不是樹節點,也與新加入的元素不相等(hash和equals不相同),則進入死循環
  7. 死循環跳出的條件:一:後續再無節點,在最後的一個Node尾部插入新節點。二:循環的當前節點與新加入的元素相等,替換當前節點的Value。兩種情況任意一種,可break當前循環。
  8. ++modCount, 這個和迭代有關,迭代器迭代時候不會有異常,就是因爲這個。
  9. 如果++size>threshold,也就是新加入節點後,size大於當前閾值,則調用擴容。

可以看一個put之後Map的數據結構:

這樣put操作就完成了!!!咱們回頭可以看擴容的剩餘部分代碼啦!看上一節。。

hash算法

粘貼來的,瞭解下

  1. 加法Hash;把輸入元素一個一個的加起來構成最後的結果
  2. 位運算Hash;這類型Hash函數通過利用各種位運算(常見的是移位和異或)來充分的混合輸入元素
  3. 乘法Hash;這種類型的Hash函數利用了乘法的不相關性(乘法的這種性質,最有名的莫過於平方取頭尾的隨機數生成算法,雖然這種算法效果並不好);jdk5.0裏面的String類的hashCode()方法也使用乘法Hash;32位FNV算法
  4. 除法Hash;除法和乘法一樣,同樣具有表面上看起來的不相關性。不過,因爲除法太慢,這種方式幾乎找不到真正的應用
  5. 查表Hash;查表Hash最有名的例子莫過於CRC系列算法。雖然CRC系列算法本身並不是查表,但是,查表是它的一種最快的實現方式。查表Hash中有名的例子有:Universal Hashing和Zobrist Hashing。他們的表格都是隨機生成的。
  6. 混合Hash;混合Hash算法利用了以上各種方式。各種常見的Hash算法,比如MD5、Tiger都屬於這個範圍。它們一般很少在面向查找的Hash函數裏面使用

HashMap的hash算法

static final int hash(Object key) {

   int h;

   return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

}

 

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