Java實習生面試複習(二):HashMap

我是一名很普通的雙非大三學生,跟不少同窗同樣,有着一顆想進大廠的夢。接下來的幾個月內,我將堅持寫博客,輸出知識的同時鞏固本身的基礎,記錄本身的成長和鍛鍊本身,備戰2021暑期實習面試!奧利給!!java

Map這個你們庭真的是成員不少呢,咱們能夠簡單回憶一下有哪些,我這裏例舉幾個:HashMap、TreeMap、LikedHashMap、ConcurrentHashMap(線程安全)、WeekHashMap、HashTable。不記的話,能夠搜搜其餘文章回顧一下哦。本文只討論HashMapnode

HashMap基本是咱們在平常使用中頻率特別高的一個數據結構類型了,同時也是面試常常問到的,圍繞着HashMap能展開一系列問題,好比:面試

  • HashMap底層是如何實現的?
  • HashMap的擴容機制?
  • HashMap爲何會出現死循環?
  • HashMap在1.7和1.8有什麼區別?作了哪些優化?

本文不對源碼作過深的討論,由於我以爲實習生應該還不須要了解的那麼透徹,咱們須要作的是知道這些東西,源碼什麼的,每一步怎麼作的,感興趣的同窗能夠本身看一下。算法

HashMap源碼分析

1.HashMap中常見的屬性api

//HashMap的 初始容量爲 16
 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

 //最大容量
 static final int MAXIMUM_CAPACITY = 1 << 30;

 //默認的擴容因子
 static final float DEFAULT_LOAD_FACTOR = 0.75f;
 
 //轉換紅黑樹的臨界值,當鏈表長度大於此值時,會把鏈表結構轉換爲紅黑樹結構
 static final int TREEIFY_THRESHOLD = 8;

 //轉換鏈表的臨界值,當元素小於此值時,會將紅黑樹結構轉換成鏈表結構
 static final int UNTREEIFY_THRESHOLD = 6;

 //當數組容量大於 64 時,鏈表纔會轉化成紅黑樹
 static final int MIN_TREEIFY_CAPACITY = 64;

 //記錄迭代過程當中 HashMap 結構是否發生變化,若是有變化,迭代時會 fail-fast
 transient int modCount;

 //HashMap 的實際大小,可能不許(由於當你拿到這個值的時候,可能又發生了變化)
 transient int size;

 //存放數據的數組
 transient Node<K,V>[] table;

 // 擴容的門檻,有兩種狀況
 // 若是初始化時,給定數組大小的話,經過 tableSizeFor 方法計算,數組大小永遠接近於 2 的冪次方,好比你給定初始化大小 19,實際上初始化大小爲 32,爲 2 的 5 次方。
 // 若是是經過 resize 方法進行擴容,大小 = 數組容量 * 0.75
 int threshold;

 /** HashMap中的內部類 **/
 
 //鏈表的節點 1.7以前叫Entry,1.8以後叫Node
 static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
}
 
 //紅黑樹的節點
 static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {}
複製代碼

上面已經對屬性寫了註釋,下面在補充一點:數組

  • 什麼是擴容因子("加載因子,負載因子")? 擴容因子是用來判斷當前數組("哈希桶")何時須要進行擴容,假設因子爲0.5,那麼HashMap的初始化容量是16,則16*0.5 = 8個元素的時候,HashMap就會進行擴容。安全

  • 爲何擴容因子是0.75? 這是均衡了時間和空間損耗算出來的值,由於當擴容因子設置比較大的時候,至關於擴容的門檻就變高了,發生擴容的頻率變低了,但此時發生Hash衝突的概率就會提高,當衝突的元素過多的時候,變成鏈表或者紅黑樹都會增長了查找成本(hash 衝突增長,鏈表長度變長)。而擴容因子太小的時候,會頻繁觸發擴容,佔用的空間變大,好比從新計算Hash等,使得操做性能會比較高。數據結構

  • HashMap初始化容量是多少? 在不指定capacity狀況下,初始化容量是16,但不是初始化的時候就建立了一個16大小的數組,而是在第一次put的時候去判斷是否須要初始化,我感受這有一點懶加載的味道。而且咱們常常在一些文章,包括阿里巴巴開發手冊中看到:"集合初始化時,指定集合初始值大小",若是有不少數據須要儲存到 HashMap 中,建議 HashMap 的容量一開始就設置成足夠的大小,這樣能夠防止在其過程當中不斷的擴容,影響性能,好比HashMap 須要放置1024 個元素,因爲沒有設置容量初始大小,隨着元素不斷增長,容量 7 次被迫擴大,resize 須要重建hash 表,嚴重影響性能多線程

  • 初始化容量爲何是16?爲何每次擴容是2的冪次方? 由於在使用是2的冪的數字的時候,Length-1的值是全部二進制位全爲1,這種狀況下,index的結果等同於HashCode後幾位的值。只要輸入的HashCode自己分佈均勻,Hash算法的結果就是均勻的。這是爲了實現均勻分佈。深刻解釋能夠看這篇文章:HashMap中hash(Object key)原理,爲何(hashcode >>> 16)。源碼分析

  • Node節點是什麼?。 node節點在1.7以前也叫entry節點,是HashMap存儲數據的一個節點,主要有四個屬性,hash,key,value,next,其實就是一個標準的鏈表節點,很容易理解。

  • 鏈表何時會轉換成紅黑樹? 當鏈表長度大於等於 8 時,此時的鏈表就會轉化成紅黑樹,轉化的方法是:treeifyBin,此方法有一個判斷,當鏈表長度大於等於 8,而且整個數組大小大於 64 時,纔會轉成紅黑樹,當數組大小小於 64 時,只會觸發擴容,不會轉化成紅黑樹

  • 加強 for 循環進行刪除,爲何會出現ConcurrentModificationException? 由於加強 for 循環過程其實調用的就是迭代器的 next () 方法,當你調用 map#remove () 方法進行刪除時,modCount 的值會 +1,而這時候迭代器中的 expectedModCount 的值卻沒有變,致使在迭代器下次執行 next () 方法時,expectedModCount != modCount 就會報 ConcurrentModificationException 的錯誤。這實際上是一種快速失敗的機制,java.util下面的集合類基本都是快速失敗的,實現都同樣,都是依靠這兩個變量。

可使用迭代器的remove()方法去刪除,由於 Iterator.remove () 方法在執行的過程當中,會把最新的modCount 賦值給 expectedModCount,這樣在下次循環過程當中,modCount 和 expectedModCount 二者就會相等。

HashMap在1.7和1.8有什麼區別?作了哪些優化?

從文章開頭貼出的代碼屬性中咱們能夠看出,1.8版本的HashMap的底層其實是一個數組+鏈表+紅黑樹的結構,可是在1.7的時候是沒有紅黑樹的,這正是1.8版本中對查詢的優化,咱們都知道鏈表的查詢時間複雜度是O(n)的,由於它須要一個一個去遍歷鏈表上全部的節點,因此當鏈表長度過長的時候,會嚴重影響 HashMap 的性能,而紅黑樹具備快速增刪改查的特色,這樣就能夠有效的解決鏈表過長時操做比較慢的問題。因而在1.8中引入了紅黑樹。

在這裏插入圖片描述

HashMap的新增

咱們來看1.8中HashMap的新增圖示,就不在曬那一大段的新增代碼了。

在這裏插入圖片描述
咱們在文字總結描述一下,新增大概的步驟以下:

  1. 判斷哈希表(數組)是否爲空,第一次put的時候須要擴容(初始化);
  2. 經過 key 的 hash 計算出index,找到數組中對應的位置,並判斷是否有值,有就跳轉到 6,不然到 3;
  3. 若是 hash衝突,兩種解決方案:鏈表 or 紅黑樹;
  4. 判斷是紅黑樹,調用紅黑樹新增的方法;
  5. 若是是鏈表,遞歸循環,判斷長度會不會大於8,大於8就先轉換成紅黑樹,在調用4;
  6. 根據 onlyIfAbsent 判斷是否須要覆蓋,而後插入;
  7. 判斷是否須要擴容,須要擴容進行擴容,結束。

HashMap的擴容

HashMap擴容
從上圖中,咱們能夠看出HashMap在 當前元素個數 > 擴容因子 * 最大容量的時候會觸發擴容,那麼它是怎麼擴容的? 注意:這樣的元素個數和數組大小是有區別的,元素個數是指hashmap中node的個數,並非指數組的大小。

  • 擴容:建立一個新的Node(Entry)空數組,長度是原數組的2倍。
  • ReHash:遍歷原Node(Entry)數組,把全部的Node從新Hash到新數組。

爲何須要從新計算Hash?

1.7中計算下標:
static int indexFor(int h, int length) {
       return h & (length-1);
}
// n 表示數組的長度,i 爲數組索引下標
1.8中計算下標:tab[i = (n - 1) & hash])

// 1.8中的高位運算
if ((e.hash & oldCap) == 0) {
    if (loTail == null)
        loHead = e;
    else
        loTail.next = e;
    loTail = e;
}
複製代碼

在jdk1.7中有indexFor(int h, int length)方法。jdk1.8裏沒有1.8中用tab[(n - 1) & hash]代替但原理同樣。從上咱們能夠看出是由於長度擴大之後,Hash的規則也隨之改變。這在1.7和1.8中差異不大,可是1.7須要與新的數組長度進行從新hash運算,這個方式是相對耗性能的,而在1.8中對這一步進行了優化,採用高位運算取代了ReHash操做,其實這是一種規律,由於每次擴容,其實元素的新位置就是原位置+原數組長度,不懂的能夠看jdk8之HashMap resize方法詳解(深刻講解爲何1.8中擴容後的元素新位置爲原位置+原數組長度)

既然說到擴容,那麼確定會扯出1.7HashMap的死循環問題

咱們都知道1.7以前,JDK是頭插法,考慮頭插法的緣由是不用遍歷鏈表,提升插入性能,但在JDK8已經改成尾插法了,不存在這個死循環問題,因此問題就出在頭插法這。 我以爲這篇文章在這寫的很清楚,想深刻了解一下產生死循環的過程,能夠看這篇文章:老生常談,HashMap的死循環。總結一下來講就是:Java7在多線程操做HashMap時可能引發死循環,緣由是擴容轉移後先後鏈表順序倒置,在轉移過程當中修改了原來鏈表中節點的引用關係。Java8在一樣的前提下並不會引發死循環,緣由是擴容轉移後先後鏈表順序不變,保持以前節點的引用關係。

那麼1.8以後,HashMap就是線程安全的了嘛? 首先咱們要知道所謂線程安全是對(讀/寫)兩種狀況都是數據一致而言的,而只讀且不變化的話,HashMap也是線程安全的,之因此不安全是在寫的時候,索引構建的時候會產生構建不一致的狀況,好比沒法保證上一秒put的值,下一秒get的時候仍是原值,因此線程安全仍是沒法保證,因此要問面試官有沒有讀寫並存的狀況。且java.util下面的集合類基本都不是線程安全的。因此HashMap 是非線程安全的,咱們能夠本身在外部加鎖,或者經過 Collections#synchronizedMap 來實現線程安全,Collections#synchronizedMap 的實現是在每一個方法上加上了 synchronized 鎖;或者使用ConcurrentHashMap

小結:

HashMap 的內容雖然較多,但大多數 api 都只是對數組 + 鏈表 + 紅黑樹這種數據結構進行封裝而已。那麼看完這些,你能觸類旁通答出一下問題了嗎?

  • HashMap的底層數據結構?
  • HashMap的存取原理?
  • Java7和Java8的區別? 爲啥會線程不安全? 有什麼線程安全的類代替麼?
  • 默認初始化大小是多少?爲啥是這麼多?爲啥大小都是2的冪?
  • HashMap的擴容方式?負載因子是多少?爲什是這麼多?
  • HashMap的主要參數都有哪些?
  • HashMap是怎麼處理hash碰撞的?
  • hash的計算規則?
相關文章
相關標籤/搜索