天天看點

11張圖讓你徹底明白jdk1.7 hashmap的死循環是如何産生的

jdk1.7 hashmap的循環依賴問題是面試經常被問到的問題,如何回答不好,可能會被扣分。今天我就帶大家一下梳理一下,這個問題是如何産生的,以及如何解決這個問題。

一、hashmap的資料結構

先一起看看jdk1.7 hashmap的資料結構

11張圖讓你徹底明白jdk1.7 hashmap的死循環是如何産生的

數組 + 連結清單

hashmap會給每個元素的key生成一個hash值,然後根據這個hash值計算一個在數組中的位置i。i不同的元素放在數組的不同位置,i相同的元素放在連結清單上,最新的資料放在連結清單的頭部。

往hashmap中儲存元素會調用put方法,擷取元素會調用get方法。接下來,我們重點看看put方法。

二、put方法

重點看看put方法

public V put(K key, V value) {
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    if (key == null)
        return putForNullKey(value);
    //根據key擷取hash     
    int hash = hash(key);
    //計算在數組中的下表
    int i = indexFor(hash, table.length);
    //變量集合查詢相同key的資料,如果已經存在則更新資料
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            //傳回已有資料
            return oldValue;
        }
    }

    modCount++;
    //如果不存在相同key的元素,則添加新元素
    addEntry(hash, key, value, i);
    return null;
}           

複制

再看看addEntry方法

void addEntry(int hash, K key, V value, int bucketIndex) {
      // 當數組的size >= 擴容門檻值,觸發擴容,size大小會在createEnty和removeEntry的時候改變
      if ((size >= threshold) && (null != table[bucketIndex])) {
          // 擴容到2倍大小,後邊會跟進這個方法
          resize(2 * table.length);
          // 擴容後重新計算hash和index
          hash = (null != key) ? hash(key) : 0;
          bucketIndex = indexFor(hash, table.length);
      }
      // 建立一個新的連結清單節點,點進去可以了解到是将新節點添加到了連結清單的頭部
      createEntry(hash, key, value, bucketIndex);
  }           

複制

看看resize是如何擴容的

void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }
        // 建立2倍大小的新數組
        Entry[] newTable = new Entry[newCapacity];
        // 将舊數組的連結清單轉移到新數組,就是這個方法導緻的hashMap不安全,等下我們進去看一眼
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        table = newTable;
        // 重新計算擴容門檻值(容量*加載因子)
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}           

複制

出問題的就是這個transfer方法

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);
            }
            // 計算節點在新數組中的下标
            int i = indexFor(e.hash, newCapacity);
            // 将舊節點插入到新節點的頭部
            e.next = newTable[i];
            //這行才是真正把資料插入新數組中,前面那行代碼隻是設定目前節點的next
            //這兩行代碼決定了倒序插入
            //比如:以前同一個位置上是:3,7,後面可能變成了:7、3
            newTable[i] = e;
            //将下一個元素指派給目前元素,以便周遊下一個元素
            e = next;
        }
    }
}           

複制

我來給大家分析一下,為什麼這幾個代碼是頭插法,網上很多技術文章都沒有說清楚。

三、頭插法

我們把目光聚焦到這幾行代碼:

//擷取下一個元素,記錄到一個臨時變量,以便後面使用
  Entry<K,V> next = e.next;
  // 計算節點在新數組中的下标
  int i = indexFor(e.hash, newCapacity);
  // 将舊節點插入到新節點的頭部
  e.next = newTable[i];
  //這行才是真正把資料插入新數組中,前面那行代碼隻是設定目前節點的next
  newTable[i] = e;
  //将下一個元素指派給目前元素,以便周遊下一個元素
  e = next;           

複制

假設剛開始hashMap有這些資料

11張圖讓你徹底明白jdk1.7 hashmap的死循環是如何産生的

調用put方法需要進行一次擴容,剛開始會建立一個空的數組,大小是以前的2倍,如圖所示:

11張圖讓你徹底明白jdk1.7 hashmap的死循環是如何産生的

開始第一輪循環:

//next= 7   e = 3  e.next = 7
  Entry<K,V> next = e.next;
  // i=3
  int i = indexFor(e.hash, newCapacity);
  //e.next = null ,剛初始化時新數組的元素為null
  e.next = newTable[i];
  //給新數組i位置 指派 3
  newTable[i] = e;
  // e = 7
  e = next;           

複制

執行完之後,第一輪循環之後資料變成這樣的

11張圖讓你徹底明白jdk1.7 hashmap的死循環是如何産生的

再接着開始第二輪循環:

//next= 5   e = 7  e.next = 5
  Entry<K,V> next = e.next;
  // i=3
  int i = indexFor(e.hash, newCapacity);
  //e.next = 3 ,此時相同位置上已經有key=3的值了,将該值指派給目前元素的next
  e.next = newTable[i];
  //給新數組i位置 指派 7
  newTable[i] = e;
  // e = 5
  e = next;           

複制

上面會構成一個新連結清單,連接配接的順序正好反過來了。

11張圖讓你徹底明白jdk1.7 hashmap的死循環是如何産生的

由于第二次循環時,節點key=7的元素插到相同位置上已有元素key=3的前面,是以說是采用的頭插法。

四、死循環的産生

接下來重點看看死循環是如何産生的?

假設資料跟元素資料一緻,有兩個線程:線程1 和 線程2,同時執行put方法,最後同時調用transfer方法。

線程1 先執行,到 Entry<K,V> next = e.next; 這一行,被挂起了。

//next= 7   e = 3  e.next = 7
  Entry<K,V> next = e.next;
  int i = indexFor(e.hash, newCapacity);
  e.next = newTable[i];
  newTable[i] = e;
  e = next;           

複制

此時線程1 建立的數組會建立一個空數組

11張圖讓你徹底明白jdk1.7 hashmap的死循環是如何産生的

接下來,線程2開始執行,由于線程2運氣比較好,沒有被中斷過,執行完畢了。

11張圖讓你徹底明白jdk1.7 hashmap的死循環是如何産生的

過一會兒,線程1被恢複了,重新執行代碼。

//next= 7   e = 3  e.next = 7
  Entry<K,V> next = e.next;
  // i = 3
  int i = indexFor(e.hash, newCapacity);
  // e.next = null,剛初始化時新數組的元素為null
  e.next = newTable[i];
  // 給新數組i位置 指派 3
  newTable[i] = e;
  // e = 7
  e = next;           

複制

這時候線程1的數組會變成這樣的

11張圖讓你徹底明白jdk1.7 hashmap的死循環是如何産生的

再執行第二輪循環,此時的e=7

//next= 3   e = 7  e.next = 3
  Entry<K,V> next = e.next;
  // i = 3
  int i = indexFor(e.hash, newCapacity);
  // e.next = 3,此時相同位置上已經有key=3的值了,将該值指派給目前元素的next
  e.next = newTable[i];
  // 給新數組i位置 指派 7
  newTable[i] = e;
  // e = 3
  e = next;           

複制

這裡特别要說明的是 此時e=7,而e.next為什麼是3呢?

因為hashMap的資料是公共的,還記得線程2中的生成的資料嗎?

11張圖讓你徹底明白jdk1.7 hashmap的死循環是如何産生的

此時e=7,那麼e.next肯定是3。

經過上面第二輪循環之後,線程1得到的資料如下:

11張圖讓你徹底明白jdk1.7 hashmap的死循環是如何産生的

此時由于循環判斷還沒有退出,判斷條件是: while(null != e),是以要開始第三輪循環:

//next= null   e = 3  e.next = null
  Entry<K,V> next = e.next;
  // i = 3
  int i = indexFor(e.hash, newCapacity);
  // e.next = 7,關鍵的一步,由于第二次循環是 key:7 .next = key:3,現在key:3.next = key:7
  e.next = newTable[i];
  // 給新數組i位置 指派 3
  newTable[i] = e;
  // e = null
  e = next;           

複制

由于e=null,此時會退出循環,最終線程1的資料會是這種結構:

11張圖讓你徹底明白jdk1.7 hashmap的死循環是如何産生的

key:3 和 key:7又恢複了剛開始的順序,但是他們的next會互相引用,構成環形引用。

注意,此時調用hashmap的get方法擷取資料時,如果隻是擷取循環鍊上key:3 和 key:7的資料,是不會有問題的,因為可以找到。就怕擷取循環鍊上沒有的資料,比如:key:11,key:15等,會進入無限循環中導緻CPU使用率飙升。

五、如何避免死循環

為了解決這個問題,jdk1.8把擴容是複制元素到新數組由 頭插法 改成了 尾插法 。此外,引入了紅黑樹,提升周遊節點的效率。在這裡我就不過多介紹了,如果有興趣的朋友,可以關注我的公衆号,後面會給大家詳細分析jdk1.8的實作,以及 jdk1.7、jdk1.8 hashmap的差別。

此外,HashMap是非線程安全的,要避免在多線程的環境中使用HashMap,而應該改成使用ConcurrentHashMap。

是以總結一下要避免發生死循環的問題的方法:

  1. 更新jdk到1.8以上
  2. 改成ConcurrentHashMap

如果這篇文檔對您有所幫助的話,麻煩關注一下我的公衆賬号:蘇三說技術,或者幫忙點贊或轉發,堅持原創不易,您的支援是我堅持最大的動力。後面我會分享更多更實用的幹貨,謝謝大家的支援。