Java基礎——HashMap原始碼分析

it_was發表於2020-09-24

HashMap在程式碼編寫和麵試過程中都經常用到,所以有必要總結一下其原始碼

1.1屬性

  1. 預設初始容量大小,一定為2的冪次:facepunch:

    //The default initial capacity - MUST be a power of two.
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
  2. 預設最大容量:facepunch:

    static final int MAXIMUM_CAPACITY = 1 << 30;
  3. 預設載入因子大小:facepunch:

    //The load factor used when none specified in constructor.
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
  4. 預設紅黑樹的閾值:facepunch:

    static final int TREEIFY_THRESHOLD = 8;
  5. 預設非樹形閾值

    static final int UNTREEIFY_THRESHOLD = 6;
  6. 預設轉紅黑樹時當前最小容量:facepunch:

    static final int MIN_TREEIFY_CAPACITY = 64;
  7. hashmap中的結點

    static class Node<K,V> implements Map.Entry<K,V>{...}
  8. hashmap的鍵值對Node陣列:facepunch:

    transient Node<K,V>[] table;
  9. 鍵值對的集合!

    transient Set<Map.Entry<K,V>> entrySet;
  10. 當前hashmap中鍵值對的數量

    transient int size;
  11. hashmap被修改的次數!:facepunch:

    transient int modCount;

    :exclamation:注意這個值當且僅當當前的hashmap結構發生改變的時候會加一,這些結構改變主要包括:新增或刪除元素,rehash(擴容)等,主要用在迭代器使用過程中的 fail_fast機制!
    舉個例子,以下程式碼中獲取了hashmap的key集合的迭代器,並在迭代過程中通過hashmap本身刪除了一個key為2的鍵值對,這樣就會導致迭代器在迭代過程中 出現 預期modcount 不等於現在的modcount!最終 throw new ConcurrentModificationException()!注意這種情況不論在單執行緒多執行緒都會發生,即在使用迭代器的時候一定要通過迭代器本身進行修改!該同步同步!

    public static void main(String[] args){
        HashMap<Integer, Integer> hashMap = new HashMap<>();
        hashMap.put(1,1);
        hashMap.put(2,2);
        hashMap.put(3,3);
        hashMap.put(4,4);
        Iterator<Integer> iterator = hashMap.keySet().iterator();
        while(iterator.hasNext()){
            Integer key= iterator.next();
            if(key== 2){
                hashMap.remove(key);
            }
        }
    }
  12. 下一次擴容的大小

    int threshold;// = capacity * load factor
  13. 載入因子大小

    final float loadFactor;

1.2 核心方法

  1. 建構函式
    通過觀察以下的建構函式我們可以發現,前面三種都只是在初始化容量和載入因子,並沒有真正去開闢一個node陣列!即使用時建立!

    • 建構函式一,兩者都指定
      public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
      }
    • 建構函式二,單獨指定初始值,則載入因子使用預設載入因子
      public HashMap(int initialCapacity) {
         this(initialCapacity, DEFAULT_LOAD_FACTOR);
      }
    • 建構函式三,空構造器
      public HashMap() {
         this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
      }
    • 建構函式四,使用map作為引數
      public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
      }
  2. 計算key的hash值——擾動函式!

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

    :raising_hand:這個方法主要是用來和陣列的長度取餘找到存放的下標的!畢竟如果要存放全部的整數的話記憶體是放不下將近40億的空間的。而尋找這個下標的方法就是 hash & (length -1),這也印證陣列的容量為什麼是2的冪次(使用位運算能加快運算速度!)這樣的結果就是擷取了當前key的hash的低位!!!高位全部為零。
    :exclamation:但這也同樣帶了問題,如果插入的某些key在低位上具有某種規律性,這樣會導致當前的hash衝突十分嚴重!所以,下面的這個hash方法採用低16位與高16位進行異或運算,混合原始雜湊碼的高位和低位,以此加大低位隨機性,而混合後的低位摻雜了高位的資訊,即高位的資訊也被變相的保留下來!之後在與長度進行位運算,衝突概率就會降低!下面展示了hash方法的執行過程!

Java基礎——HashMap原始碼分析

  1. 將指定容量擴充至2的冪次
    通過不斷地或運算將指定容量擴充至2的冪次
    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }
  2. 核心方法——put方法
    public V put(K key, V value) {
         return putVal(hash(key), key, value, false, true);
         //onlyIfAbsent預設為false,即覆蓋已存在的key的value
     }
    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) 
         //首先進行node陣列的判斷,如果為空或者長度為0則通過resize方法建立,印證了上面建構函式中使用才建立!
             n = (tab = resize()).length;
         if ((p = tab[i = (n - 1) & hash]) == null)
         //如果要新增的對應下標為空,ok,則說明沒有衝突,可以新增
             tab[i] = newNode(hash, key, value, null);
         else {
         //此時說明有衝突!
             Node<K,V> e; K k;
             if (p.hash == hash &&
                 ((k = p.key) == key || (key != null && key.equals(k))))
                 //在這判斷兩者的key是否是等價的!!!
                 //注意這裡就是為什麼自己建立的物件需要重寫hashcode和equals方法!!!
                 //如果兩者不重寫,會導致hashmap將等價物件判定為不同物件!!!!或者說插入重複物件!
                 e = p;//兩個物件等價!!!
             else if (p instanceof TreeNode)
             //兩者不等價,需要在此節點往後插,先判斷當前節點是否是樹結點!是的話採用樹結點的插入方式
                 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
             else {
             //不是樹結點,ok ,在這個連結串列上不斷尋找與待插入結點等價的結點
                 for (int binCount = 0; ; ++binCount) {
                     if ((e = p.next) == null) {
                         //此時說明遍歷完這個連結串列也沒有等價的,那我就再最後一個插入吧!即尾插法!
                         p.next = newNode(hash, key, value, null); //尾部插入中!!!!
                         if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                             treeifyBin(tab, hash); //如果說連結串列結點達到了8,需要轉紅黑樹!
                         break;
                     }
                     if (e.hash == hash &&
                         ((k = e.key) == key || (key != null && key.equals(k))))
                         //找到這個連結串列中與待插入結點等價的結點!!直接break
                         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;//新增之後導致map數量增加,即修改次數加一!
         if (++size > threshold)//如果新增後的容量等於擴容容量,即需要擴容!
             resize();
         afterNodeInsertion(evict);
         return null; 
     }
  3. resize方法
    注意jdk1.8之前採用的是頭插法,容易造成連結串列死迴圈!之後改成尾插法,解除隱患!
    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;
         //從這開始,才是真正的resize!!!即擴容!
         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; //即當前下標只有一個結點,直接rehash過去就好了
                     else if (e instanceof TreeNode) //如果說是樹結點,執行對應方法
                         ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                     else { // preserve order //說明此時是長度至少為2的連結串列
                         Node<K,V> loHead = null, loTail = null;
                         Node<K,V> hiHead = null, hiTail = null;
                         Node<K,V> next;
                         do { //這個dowhile在做舊結點往新節點的移動!並且採用尾插法
                             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; //舊結點hash小於oldcap的不需要移動!
                         }
                         if (hiTail != null) {
                             hiTail.next = null;
                             newTab[j + oldCap] = hiHead; //大於hash的直接放到當前下標 + oldcap位置即可!
                         }
                     }
                 }
             }
         }
         return newTab;
     }

先看下舊版本的擴容方法

void transfer(Entry[] newTable){  //複製一個原陣列src,Entry是一個靜態內部類,有K,V,next三個成員變數
    Entry[] src = table;  //陣列新容量
    int newCapacity = newTable.length;//  從OldTable裡摘一個元素出來,然後放到NewTable中
    for (int j = 0; j < src.length; j++) {
        Entry<K,V> e = src[j];//取出原陣列一個元素
        if (e != null) {//判斷原陣列該位置有元素
            src[j] = null;//原陣列位置置為空
            do {//對原陣列某一位置下的一串元素進行操作
                Entry<K,V> next = e.next;//next是當前元素下一個
                int i = indexFor(e.hash, newCapacity);//i是元素在新陣列的位置
                e.next = newTable[i];//此處體現了頭插法,當前元素的下一個是新陣列的頭元素
                newTable[i] = e;//將原陣列元素加入新陣列
                e = next;//遍歷到原陣列某一位置下的一串元素的下一個      
            } while (e != null);     
        }              
    }
}

接下來圖示單執行緒情況下,do迴圈內的情況:

  初始:當前陣列容量為2,有三個元素3、7、5,此處的hash演算法是簡化處理(對容量取模)。因此,3、7、5都在陣列索引1對應的連結串列上。

  擴容新容量為2*2=4。

  第一步:當前Entry e對應3,next對應7,新位置i為3,然後將3插入新陣列對應位置。

  第二步:當前Entry e對應7,next對應5,新位置i為3,然後將新陣列對應索引處的元素3新增到7的尾巴後(頭插),然後將7插入新陣列對應位置。

  第三步:當前Entry e對應5,next對應null,新位置i為1, 然後將5插入新陣列對應位置。

Java基礎——HashMap原始碼分析

  接下來圖示多執行緒情況下死迴圈場景:初始條件相同。如果有兩個執行緒:

  • 執行緒一執行到 Entry<K,V> next = e.next; 便掛起了,即此時Entry e是3,next是7,3是在7前面的。

  • 執行緒二執行完成。

  此時如下圖所示,執行緒一的3的next是7,而執行緒二的7的next是3。(此處是Entry裡的next成員變數,在多個執行緒中相同Entry不衝突)。此時可以看出出現了死迴圈問題。

Java基礎——HashMap原始碼分析

Java基礎——HashMap原始碼分析

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章