Java源碼系列2——HashMap

HashMap 的源碼很多也很複雜,本文只是摘取簡單常用的部分代碼進行分析。能力有限,歡迎指正。

HASH 值的計算

前置知識——位運算

按位異或操作符^:1^1=0, 0^0=0, 1^0=0, 值相同爲0,值不同爲1。按位異或就是對二進制中的每一位進行異或運算。

   1111 0000 1111 1110
^  1111 1111 0000 1111
______________________
   0000 1111 1111 0001

按位右移補零操作符>>>:左操作數按右操作數指定的位數右移,移動得到的空位以零填充。

        1110 1101 1001 1111
>>> 4  
___________________________
        0000 1110 1101 1001

擾動函數

爲什麼要做擾動?

理論上哈希值是一個int類型,如果直接拿哈希值做下標的話,考慮到2進制32位帶符號的int表值範圍從-2147483648到2147483648。前後加起來大概40億的映射空間。這麼大的數組,內存是存不下的,所以這個散列值是不能直接拿來用的。用之前還要先做對數組的長度取模運算,得到的餘數才能用來訪問數組下標。

因爲只取最後幾位,所以哈希碰撞的可能性大大增加,這時候擾動函數的價值就來了。

擾動計算

先調用hashCode()方法得出hash值,再進行擾動操作。

右位移16位,正好是32bit的一半(int 是32位的),自己的高半區和低半區做異或,就是爲了混合原始哈希碼的高位和低位,以此來加大低位的隨機性。而且混合後的低位摻雜了高位的部分特徵,這樣高位的信息也變相保留下來。

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

取模,計算出下標

在計算下標的時候,讓列表長度對哈希值做取模操作,讓計算出來的哈希值在列表範圍內,n 爲list長度

i = (n - 1) & hash

爲什麼HashMap的數組長度要取2的整次冪

因爲這樣(數組長度 - 1)正好相當於一個“低位掩碼”。&操作的結果就是散列值的高位全部歸零,只保留低位值,用來做數組的下標訪問。以初始長度16爲例,16-1=15,2進製表示是0000 1111。和某散列值做&操作如下:

    1010 0011 0110 1111 0101
&   0000 0000 0000 0000 1111
____________________________
    0000 0000 0000 0000 0101

是什麼存入了 table

HashMap存入table的值並不只有value,而是構造成一個 Node 對象實例存入 table。

Node對象裏有:hash, key, value, next(哈希衝突時的鏈表)

理論最大容量

int MAXIMUM_CAPACITY = 1 << 30;

2的30次方

負載因子

負載因子是用來計算負載容量(所能容納的最大Node個數)的,當前list長度 length,負載因子 loadFactor

負載容量計算公式爲:

threshold = length * loadFactor

默認負載因子爲 0.75。也就是說,當Node個數達到當前list長度的75%時,就要進行擴容,否則會增加哈希碰撞的可能性。負載因子的作用是在空間和時間效率上取得一個平衡。

float DEFAULT_LOAD_FACTOR = 0.75f

擴容做了哪些操作

  1. 創建一個新的Entry空數組,長度是原數組的2倍。
    當Node個數超過負載容量時,進行擴容。

old << 1 左移一位相當於 old * 2。

  1. 重新Hash

    遍歷原Entry數組,把所有的Entry重新Hash到新數組中。

    爲什麼要重新hash?因爲長度擴大以後,hash值也隨之改變(數組下標的計算是數組長度對hashcode進行取模)。

    這樣就可以把原先哈希衝突的鏈表拉平,使數組變得稀疏。

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;
        }
        // 原有容量左移一位,相當於 oldCap * 2
        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);
    }
    // 負載容量爲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;
    // 遍歷數組中所有元素,重新進行hash
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                // 刪除舊索引位置的值
                oldTab[j] = null;
                if (e.next == null)
                    // 給新的索引位置賦值
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // 優化鏈表
                    // 把原有鏈表拆成兩個鏈表
                    // 鏈表1存放在低位(原索引位置)
                    Node<K,V> loHead = null, loTail = null;
                    // 鏈表2存放在高位(原索引 + 舊數組長度)
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        // 鏈表1
                        // 這個位運算的原理可以參考第三篇參考資料
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // 鏈表2
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 鏈表1存放於原索引位置
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 鏈表2存放原索引加上舊數組長度的偏移量
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

樹化改造

鏈表長度太長,會被改造成紅黑樹。

當鏈表的長度超過MIN_TREEIFY_CAPACITY 最大樹化臨界值,就會進行樹化改造。

final void treeifyBin(Node<K,V>[] tab, int hash) {
  int n, index; Node<K,V> e;
  if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
    resize();
  else if ((e = tab[index = (n - 1) & hash]) != null) {
    ...
  }
}

爲什麼要樹化?

本質上是個安全問題。因爲鏈表查詢影響性能,如果有人惡意造成哈希碰撞,就會構成哈希碰撞拒絕服務攻擊,服務端CPU被大量佔用用於鏈表查詢,造成服務變慢或不可用。

源碼系列文章

Java源碼系列1——ArrayList

Java源碼系列2——HashMap

Java源碼系列3——LinkedHashMap

參考

Java 8系列之重新認識HashMap

JDK 源碼中 HashMap 的 hash 方法原理是什麼?胖君的回答

HashMap 源碼詳細分析(JDK1.8)

本文首發於我的個人博客 http://chaohang.top

作者張小超

轉載請註明出處

歡迎關注我的微信公衆號 【超超不會飛】,獲取第一時間的更新。

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