關於Java你不知道的那些事之Java8新特性[HashMap優化]

輕狂書生FS 發表於 2020-10-16

前言

本文開始重溫Java8新特性之HashMap優化,後續還會重溫其他主要新特性,敬請期待,點點關注不迷路哦!!

其他主要新特性

  • Lambda表示式
  • 函式式介面
  • 方法引用與構造器引用
  • Stream API
  • 介面中預設方法與靜態方法
  • 新時間日期API
  • 最大化減少空指標異常(Optional)
  • 。。。。

HashMap優化

HashMap1.7

在JDK1.7 到 JDK1.8的時候,對HashMap做了優化

首先JDK1.7的HashMap當出現Hash碰撞的時候,最後插入的元素會放在前面,這個稱為 “頭插法”

JDK7用頭插是考慮到了一個所謂的熱點資料的點(新插入的資料可能會更早用到),但這其實是個偽命題,因為JDK7中rehash的時候,舊連結串列遷移新連結串列的時候,如果在新表的陣列索引位置相同,則連結串列元素會倒置(就是因為頭插) 所以最後的結果 還是打亂了插入的順序 所以總的來看支撐JDK7使用頭插的這點原因也不足以支撐下去了 所以就乾脆換成尾插 一舉多得

在這裡插入圖片描述

HashMap1.7存在死鏈問題

參考:hashmap擴容時死迴圈問題

在JDK1.8以後,由頭插法改成了尾插法,因為頭插法還存在一個死鏈的問題

在說死鏈問題時,我們先從Hashmap儲存資料說起,下面這個是HashMap的put方法

public V put(K key, V value)
{
    ......
    //計算Hash值
    int hash = hash(key.hashCode());
    int i = indexFor(hash, table.length);
    //各種校驗吧
    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;
}

這裡新增一個節點需要檢查是否超出容量,出現一個負載因子

void addEntry(int hash, K key, V value, int bucketIndex)
{
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
    //檢視當前的size是否超過了我們設定的閾值threshold,如果超過,需要resize
    if (size++ >= threshold)
        resize(2 * table.length);//擴容都是2倍2倍的來的,
}

HashMap有 負載因子:0.75,以及 初始容量:16,擴容閾值:16*0.75 = 12,當HashMap達到擴容的條件時候,會把HashMap中的每個元素,重新進行運算Hash值,打入到擴容後的陣列中。

既然新建了一個更大尺寸的hash表,然後把資料從老的Hash表中遷移到新的Hash表中。

void resize(int newCapacity)
{
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    ......
    //建立一個新的Hash Table
    Entry[] newTable = new Entry[newCapacity];
    
    //將Old Hash Table上的資料遷移到New Hash Table上
    transfer(newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}

重點在這個transfer()方法

void transfer(Entry[] newTable)
{
    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;
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            } while (e != null);
        }
    }
}

do迴圈裡面的是最能說明問題的,當只有一個執行緒的時候:

在這裡插入圖片描述

最上面的是old hash 表,其中的Hash表的size=2, 所以key = 3, 7, 5,在mod 2以後都衝突在table[1]這裡了。接下來的三個步驟是Hash表 擴容變成4,然後在把所有的元素放入新表

do {
    Entry<K,V> next = e.next; // <--假設執行緒一執行到這裡就被排程掛起了
    int i = indexFor(e.hash, newCapacity);
    e.next = newTable[i];
    newTable[i] = e;
    e = next;
} while (e != null);

而我們的執行緒二執行完成了。於是我們有下面的這個樣子

在這裡插入圖片描述

在這裡插入圖片描述

注意,因為Thread1的 e 指向了key(3),而next指向了key(7),其線上程二rehash後,指向了執行緒二重組後的連結串列。我們可以看到連結串列的順序被反轉後。
這裡的意思是執行緒1這會還沒有完全開始擴容,但e和next已經指向了,執行緒2是正常的擴容的,那這會在3這個位置上,就是7->3這個順序。
然後執行緒一被排程回來執行:

先是執行 newTalbe[i] = e;
然後是e = next,導致了e指向了key(7),
而下一次迴圈的next = e.next導致了next指向了key(3)
注意看圖裡面的線,執行緒1指向執行緒2裡面的key3.

在這裡插入圖片描述

執行緒一接著工作。把key(7)摘下來,放到newTable[i]的第一個,然後把e和next往下移。

在這裡插入圖片描述

這時候,原來的執行緒2裡面的key7的e和key3的next沒了,e=key3,next=null。

當繼續執行,需要將key3加回到key7的前面。
e.next = newTable[i] 導致 key(3).next 指向了 key(7)

注意:此時的key(7).next 已經指向了key(3), 環形連結串列就這樣出現了。

在這裡插入圖片描述

執行緒2生成的e和next的關係影響到了執行緒1裡面的情況。從而打亂了正常的e和next的鏈。於是,當我們的執行緒一呼叫到,HashTable.get(11)時,即又到了3這個位置,需要插入新的,那這會就e 和next就亂了

HashMap每次擴容為什麼是2倍

參考:HashMap初始容量為什麼是2的n次冪

首先看向HashMap中新增元素是怎麼存放的

在這裡插入圖片描述

在這裡插入圖片描述

第一個截圖是向HashMap中新增元素putVal()方法的部分原始碼,可以看出,向集合中新增元素時,會使用(n - 1) & hash的計算方法來得出該元素在集合中的位置;而第二個截圖是HashMap擴容時呼叫resize()方法中的部分原始碼,可以看出會新建一個tab,然後遍歷舊的tab,將舊的元素進過e.hash & (newCap - 1)的計算新增進新的tab中,也就是(n - 1) & hash的計算方法,其中n是集合的容量,hash是新增的元素進過hash函式計算出來的hash值

HashMap的容量為什麼是2的n次冪,和這個(n - 1) & hash的計算方法有著千絲萬縷的關係,符號&是按位與的計算,這是位運算,計算機能直接運算,特別高效,按位與&的計算方法是,只有當對應位置的資料都為1時,運算結果也為1,當HashMap的容量是2的n次冪時,(n-1)的2進位制也就是1111111***111這樣形式的,這樣與新增元素的hash值進行位運算時,能夠充分的雜湊,使得新增的元素均勻分佈在HashMap的每個位置上,減少hash碰撞,下面舉例進行說明。

當HashMap的容量是16時,它的二進位制是10000,(n-1)的二進位制是01111,與hash值得計算結果如下:

在這裡插入圖片描述

上面四種情況我們可以看出,不同的hash值,和(n-1)進行位運算後,能夠得出不同的值,使得新增的元素能夠均勻分佈在集合中不同的位置上,避免hash碰撞,下面就來看一下HashMap的容量不是2的n次冪的情況,當容量為10時,二進位制為01010,(n-1)的二進位制是01001,向裡面新增同樣的元素,結果為:

在這裡插入圖片描述

可以看出,有三個不同的元素進過&運算得出了同樣的結果,嚴重的hash碰撞了。

終上所述,HashMap計算新增元素的位置時,使用的位運算,這是特別高效的運算;另外,HashMap的初始容量是2的n次冪,擴容也是2倍的形式進行擴容,是因為容量是2的n次冪,可以使得新增的元素均勻分佈在HashMap中的陣列上,減少hash碰撞,避免形成連結串列的結構,使得查詢效率降低

JDK1.8結構變化

由JDK1.7的,陣列 + 連結串列

JDK1.8變為:陣列 + 連結串列 + 紅黑樹

具體觸發條件為:某個連結串列連線的個數大於8,並且總的容量大於64的時候,那麼會把原來的連結串列轉換成紅黑樹

這麼做的好處是什麼:除了新增元素外,查詢和刪除效率比連結串列快

紅黑樹查詢、增加和刪除的時間複雜度:O(log2n)

連結串列的查詢和刪除的時間複雜度: O(n),插入為:O(1)

ConcurrentHashMap變化

為何JDK8要放棄分段鎖?

由原來的分段鎖,變成了CAS,也就是通過無鎖化設計替代了阻塞同步的加鎖操作,效能得到了提高。

通過分段鎖的方式提高了併發度。分段是一開始就確定的了,後期不能再進行擴容的,其中的段Segment繼承了重入鎖ReentrantLock,有了鎖的功能,同時含有類似HashMap中的陣列加連結串列結構(這裡沒有使用紅黑樹),雖然Segment的個數是不能擴容的,但是單個Segment裡面的陣列是可以擴容的。

JDK1.8的ConcurrentHashMap摒棄了1.7的segment設計,而是JDK1.8版本的HashMap的基礎上實現了執行緒安全的版本,即也是採用陣列+連結串列+紅黑樹的形式,雖然ConcurrentHashMap的讀不需要鎖,但是需要保證能讀到最新資料,所以必須加volatile。即陣列的引用需要加volatile,同時一個Node節點中的val和next屬性也必須要加volatile。

至於為什麼拋棄Segment的設計,是因為分段鎖的這個段不太好評定,如果我們的Segment設定的過大,那麼隔離級別也就過高,那麼就有很多空間被浪費了,也就是會讓某些段裡面沒有元素,如果太小容易造成衝突

記憶體結構優化

取消永久區,把方法區 放在 元空間中

方法區主要用於儲存一些類别範本

在這裡插入圖片描述

OOM錯誤發生概率降低

同時相關JVM調優命令變為:

MetaspaceSize

MaxMetaspaceSize
gment設定的過大,那麼隔離級別也就過高,那麼就有很多空間被浪費了,也就是會讓某些段裡面沒有元素,如果太小容易造成衝突

總結

點贊+關注,謝謝