JDK8中HashMap源碼細節死扣

在《JDK8中HashMap源碼精讀》中有留下一些待扣問題,本篇將做解答,並會對7和8中HashMap做一些對比和補充問題。

loadFactor默認值爲啥是0.75?

  1. 加載因子越小,那麼Map更容易達到擴容的條件,擴容頻繁,帶來的好處就是發送hash碰撞的概率小了,鏈表短,查詢效率高了,缺點就是頻繁擴容,損耗性能,就是“空間換時間”;
  2. 加載因子越大,就擴容就不那麼頻繁,那麼發生hash衝突的概率就大,鏈表就會長,查詢效率低了,但是空間利用率高了,就是“時間換空間”;

所以,0.75是一個折衷的取值,在空間和時間上取得一個平衡。

爲什麼鏈表長度>=8才轉爲紅黑樹?

紅黑樹的時間複雜度爲O(logn),而鏈表時間複雜度平均爲O(n/2)。
當等於6,鏈表平均查找長度爲3,log6也是>2,雖然差不多,但是鏈表轉成樹,效率會低;
當等於8,鏈表平均查找長度爲4,而紅黑樹爲3,所以這時纔有轉爲樹的必要。

源碼中,鏈表轉爲樹是8,樹轉爲鏈表是6,爲什麼不是7呢?

假設一下,如果設計成鏈表個數超過8則鏈表轉換成樹結構,鏈表個數小於8則樹結構轉換成鏈表,如果一個HashMap不停的插入、刪除元素,鏈表個數在8左右徘徊,就會頻繁的發生樹轉鏈表、鏈表轉樹,效率會很低。所以7可以防止頻繁轉換。

key在數組中下標如何計算?

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

我們希望key均勻的落在數組上,以防止哈希衝突,我們肯定會這麼計算:
index = key.hash % 數組長度
雖然取模簡單,但是效率低,所以通過位運算:
index = key.hash & (數組長度-1)
這個位運算等價於取模計算,但前提是數組長度必須是2的冪次方。

爲什麼容量必須是2的冪次方?

上面的問題已經解答了這個問題,就是數組下標的計算方式決定了容量必須是2的冪次方
如果容量不是2的冪次方會是什麼結果呢?

初始化HashMap時候,tableSizeFor(int cap)作用是啥?

作用就是是返回一個大於等於輸入參數且最小的爲2的n次冪的數。

modCount是用來幹嘛的?fast-fail?

在使用迭代器的過程中,如果HashMap被修改,那麼會拋出ConcurrentModificationException異常,即Fast-fail策略。
如何判斷是否被修改呢,就是通過modCount,類似於數據庫樂觀鎖的版本號。

//僞代碼
int expectedModCount = modCount;
//do someThing
if(expectedModCount != modCount){
    throw new ConcurrentModificationException();
}

HashMap是線程安全的嗎?會出現什麼問題?

是線程不安全的。分別從7和8分析:

HashMap8

在put方法中:

如圖中標識處,當兩個線程同時到達此處,發現tab[i]都是null,則會new一個Node,此時如果一個線程掛起了,另一個線程完成了tab[i]
的賦值,當另一個線程此時獲得cpu分片,由於不會檢查,認爲此時還是null,則會進行覆蓋,那麼之前的數據就丟失了。

還有一個地方線程不安全:

size不是volatile修飾,也沒有進行同步,那麼多線程情況就下,++size就會出現線程不安全。

總結一下:
HashMap8,因爲是尾插,不會產生7那種閉環死循環情況,但是多線程put會產生數據被覆蓋丟失的情況。

HashMap7

會在擴容的時候線程不安全:
resize方法裏有一個transfer方法

 /**
   * 作用:將舊數組上的數據轉移到新table中,從而完成擴容
   * 過程:按舊鏈表的正序遍歷鏈表、在新鏈表的頭部依次插入
   */
void transfer(Entry[] newTable) {
      Entry[] src = table;               
      int newCapacity = newTable.length;
      // 通過遍歷舊數組,將舊數組上的數據轉移到新數組中
      for (int j = 0; j < src.length; j++) {  
          Entry e = src[j];          
          if (e != null) {
              src[j] = null;
              do {
                  // 注:轉移鏈表時,因是單鏈表,故要保存下1個結點,否則轉移後鏈表會斷開
                  Entry next = e.next;
                 int i = indexFor(e.hash, newCapacity);
                 // 擴容後,出現逆序:1->2->3 => 因爲頭插法:3->2->1
                 e.next = newTable[i];
                 //這裏會出現線程不安全,當一個線程在這裏掛起
                 newTable[i] = e; 
                 // 訪問下1個Entry鏈上的元素,如此不斷循環,直到遍歷完該鏈表上的所有節點
                 e = next;            
             } while (e != null);
         }
     }
 }

簡單點說,就是當線程A執行到面線程不安全的地方,失去cpu時間片,當線程B完成了擴容,此時A獲取到cpu時間片了,繼續執行代碼,這時候會出現兩個Node的next互相指向,形成了閉環,也就是死循環,還有造成數據丟失。

總結一下:
HashMap7因爲頭插法,在多線程情況下,會導致兩節點next指針互相指引,形成死循環。

Java7和8中HashMap的區別?

對比維度 HashMap8 HashMap7
數據結構 數組+鏈表+紅黑樹 數組+鏈表
插入方式 尾插/紅黑樹 頭插 ,會出現逆序和死循環
擴容時機 先插入,發現需要擴容,再擴容 先擴容,再插入數據
初始化方式 和擴容方法集成在一起 單獨方法,和擴容方法分開
擴容後存儲位置計算 原位置 or 原位置 + 舊容量 按照原來方式重新計算
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章