聊聊Java的HashMap

前言

前不久,在一次和好友喝茶聊天的時候談到Java的HashMap集合問題,我們在一起探討過好多,現在又到了程序員找工作的黃金時期“金三銀四”,對此方便一些夥伴面試的需要以及把自己的一些見解總結出來提供一種學習以及解決問題思路。

實現原理

  1. 利用key的hashCode重新hash計算出當前對象的元素在數組中的下標
  2. 存儲時,如果出現hash值相同的key,此時有兩種情況。(1)如果key相同,則覆蓋原始值;(2)如果key不同(出現衝突),則將當前的key-value放入鏈表中或者紅黑樹中
  3. 獲取時,直接找到hash值對應的下標,在進一步判斷key是否相同,從而找到對應值。
  4. 理解了以上過程就不難明白HashMap是如何解決hash衝突的問題,核心就是使用了數組的存儲方式,然後將衝突的key的對象放入鏈表中,一旦發現衝突就在鏈表中做進一步的對比。

實現演進

不同版本 JDK1.7 JDK1.8
存儲結構 數組+鏈表 數組+鏈表+紅黑樹
初始化方式 單獨方法:inflateTable() 直接集成到拓容方法resize()種
Hash值計算方式 擾動處理=9次擾動=4次位運算+5次異或運算 擾動處理=2次=1一次位運算+1次異或運算
存放數據的規則 無衝突時存放數組,衝突時存放鏈表 無衝突時存放數組;衝突且鏈表長度<8時,存放單鏈表;衝突且鏈表長度>8時,樹化並存放紅黑樹
插入數據方式 頭插法(將原來位置的數據移到後一位,再插入數據到該位置) 尾插法(直接插入到鏈表或者紅黑樹的尾部)
擴容後存儲位置的計算方式 全部按照原來的方法進行計算(hashCode->>擾動方法->>(h&length-1)) 按照擴容後的規律計算(即擴容後的位置=原位置或者原來位置+舊容量)

源碼解剖

存儲方式

JDK1.7採用的是數組+鏈表的形式,而JDK1.8在數組容量大於64且鏈表長度大於8的情況下會使用紅黑樹。源碼裏也有很詳細的解釋,這裏不過多贅述。具體可以查看文章HashMap部分源碼的理解

初始化方式

在JDK1.7中,table數組默認值爲EMPTY_TABLE,在添加元素的時候判斷table是否爲EMPTY_TABLE來調用inflateTable。在構造HashMap實例的時候默認threshold閾值等於初始容量。當構造方法的參數爲Map時,調用inflateTable(threshold)方法對table數組容量進行設置。

public HashMap(Map<? extends K, ? extends V> m) {
    this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                    DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
    inflateTable(threshold);

    putAllForCreate(m);
}

在JDK1.8中初始化的過程則是直接集成到了resize()函數中。

擾動方法變化

在JDK1.7中擾動方法如下:

final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }
 
        h ^= k.hashCode();
 
        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
}

在JDK1.8中如下:

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

上面分別爲兩個版本的擾動方法,在JDK1.8中簡化不少。

1.png

從上述圖片中,我們樣本採用352個字符串做測試,發現在bits較低的情況下,擾動函數的collisions概率會有10%左右的降低,至於擾動方法的作用,涉及如下另一段源碼:

static int indexFor(int h, int length) {
        return h & (length-1);
}

改方法就是用來取下標的,但這時,就算散列值分佈再鬆散,只取最後幾位的話碰撞依然會很嚴重,在隨機少量的情況下影響尤爲大,所以引入了擾動函數來減少hash碰撞,而至於爲什麼JDK1.8進行了縮減,可能是因爲做多了離散分佈提升不明顯,還是爲了效率考慮,進行了縮減。

存放數據的規則

存放數據的規則網上有不錯文章進行剖析,由於篇幅有限就不再此分析,可以參考文章HashMap部分源碼的理解

插入數據方式

在JDK1.7中採用的是頭插法,JDK1.8改成了尾插法,爲了避免頭插法導致的在多線程的情況下HashMap在put元素時產生的環形鏈表的問題,但1.8仍然存在數據覆蓋的問題,所以依舊不是線程安全的.具體推薦兩篇不錯文章

TIPS:在閱讀的時候務必自己參照源碼,否則理解起來比較困難。

其實概括講HashMap 在JDK1.7中與JDK1.8中表現線程安全是不一樣,主要表現如下:

  1. 在JDK1.7中,當併發執行擴容操作時會造成環形鏈和數據丟失的情況;
  2. 在JDK1.8中,在併發執行put操作時會發生數據覆蓋的情況;

擴容後存儲位置的計算方式

擴容就是resize()方法,在JDK1.7和JDK1.8裏面都有對應源碼方法。

擴容情況

在進行擴容時候存在兩種情況:

  • 設定threshold, 當threshold = 默認容量(16) * 加載因子(0.75)的時候,進行resize();
  • 如上文所講,treeifyBin首先判斷當前hashMap的長度,如果不足64,只進行resize,擴容table,同時最小樹形化容量閾值:即 當哈希表中的容量 > 該值時,才允許樹形化鏈表,因爲樹化本身就是一個效率不高的操作,因爲紅黑樹非常複雜,涉及到了自旋的問題,所以hashmap的設計者也將樹化的長度設置爲8,因爲經過概率計算,8是一個比較難達到的鏈表長度,在鏈表長度比較小的時候,雖然是O(n),但並不會對效率有什麼影響。

在JDK1.7版本中,如下:

void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    //判斷是否有超出擴容的最大值,如果達到最大值則不進行擴容操作
    if (oldCapacity == MAXIMUM_CAPACITY) {
      threshold = Integer.MAX_VALUE;
      return;
    }
 
    Entry[] newTable = new Entry[newCapacity];
    // transfer()方法把原數組中的值放到新數組中
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    //設置hashmap擴容後爲新的數組引用
    table = newTable;
    //設置hashmap擴容新的閾值
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
  }
 
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
      while(null != e) {
        Entry<K,V> next = e.next;
        if (rehash) {
          e.hash = null == e.key ? 0 : hash(e.key);
        }
        //通過key值的hash值和新數組的大小算出在當前數組中的存放位置
        int i = indexFor(e.hash, newCapacity);
        e.next = newTable[i];
        newTable[i] = e;
        e = next;
      }
    }
  }

從上述源碼中可以看到在1.7中,藉助transfer()方法(jdk1.8中已移除),在擴容的時候會重新計算threshold,數組長度並進行rehash,這樣的效率是偏低的。

在JDK1.8版本中,如下:

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;//首次初始化後table爲Null
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;//默認構造器的情況下爲0
        int newCap, newThr = 0;
        if (oldCap > 0) {//table擴容過
             //當前table容量大於最大值得時候返回當前table
             if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
            //table的容量乘以2,threshold的值也乘以2           
            newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
        //使用帶有初始容量的構造器時,table容量爲初始化得到的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;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                HashMap.Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    // help gc
                    oldTab[j] = null;
                    if (e.next == null)
                        // 當前index沒有發生hash衝突,直接對2取模,即移位運算hash &(2^n -1)
                        // 擴容都是按照2的冪次方擴容,因此newCap = 2^n
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof HashMap.TreeNode)
                        // 當前index對應的節點爲紅黑樹,當樹的高度小於等於UNTREEIFY_THRESHOLD則轉成鏈表
                        ((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        // 把當前index對應的鏈表分成兩個鏈表,減少擴容的遷移量
                        HashMap.Node<K,V> loHead = null, loTail = null;
                        HashMap.Node<K,V> hiHead = null, hiTail = null;
                        HashMap.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) {
                            // help gc
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            // help gc
                            hiTail.next = null;
                            // 擴容長度爲當前index位置+舊的容量
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

從上面的源碼中可以分析出擴容分爲三種情況:

  1. 第一種是初始化階段,此時的newCap和newThr均設爲0,從之前的源碼可知,第一次擴容的時候,默認的閾值就是threshold = DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR = 12。而 DEFAULT_INITIAL_CAPACITY爲16, DEFAULT_LOAD_FACTOR 爲0.75;
  2. 第二種是如果oldCap大於0,也就是擴容過的話,每次table的容量和threshold都會擴圍原來的兩倍;
  3. 第三種是如果指定了threshold的初始容量的話,newCap就會等於這個threshold,新的threshold會重新計算;
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章