HashMap源碼和實現原理(詳解)

題記:這是以前寫得一篇文章,有對別人文章的引用,現對文章進行補充複習!添加一些jdk1.8看到的內容。

1、HashMap的特性

  • HashMap 是一個散列桶(數組和鏈表)這是jdk1.6和jdk1.7中得實現,而JDK1.8中,HashMap採用位桶+鏈表+紅黑樹實現
  • 它存儲的內容是鍵值對 key-value 映射
  • HashMap 採用了數組,鏈表和紅黑樹的數據結構,能在查詢和修改方便繼承了數組的線性查找和鏈表的尋址修改,當鏈表長度超過閾值(8)時,將鏈表轉換爲紅黑樹
  • HashMap 是非 synchronized,所以 HashMap 很快
  • HashMap 可以接受 null 鍵和值,而 Hashtable 則不能(原因就是 equlas() 方法需要對象,因爲 HashMap 是後出的 API 經過處理纔可以)

2、HashMap 的工作原理是什麼?

HashMap 是基於 hashing 的原理實現的

Hash算法:
h 通過hash算法計算得到的的一個整型數值,也就是h是一個由hashcode產生的僞隨機數
h可以近似看做一個由key的hashcode生成的隨機數,區別在於相同的hashcode生成的h必然相同
而不同的hashcode也可能生成相同h,這種情況叫做hash碰撞,好的hash算法應儘量避免hash碰撞
(ps:hash碰撞只能儘量避免,而無法杜絕,由於h是一個固定長度整型數據,原則上只要有足夠多的輸入,就一定會產生碰撞)
關於hash算法有很多種,這裏不展開贅述,只需要記住h是一個由hashcode產生的僞隨機數即可
同時需要滿足key.hashcode -> h 分佈儘量均勻(下文會解釋爲何需要分佈均勻)

這裏先說put存儲。HashMap重寫了Map中的put方法

我們使用 put(key, value) 存儲對象到 HashMap 中,使用 get(key) 從 HashMap 中獲取對象。當我們給 put() 方法傳遞鍵和值時,我們先對鍵調用 hashCode() 方法,計算並返回的 hashCode 是用於找到 Map 數組的 bucket 位置來儲存 Node 對象。

這裏關鍵點在於指出,HashMap 是在 bucket 中儲存鍵對象和值對象,作爲Map.Node 。

下面簡單說下添加鍵值對put(key,value)的過程:
1,判斷鍵值對數組tab[]是否爲空或爲null,否則以默認大小resize();
2,根據鍵值key計算hash值得到插入的數組索引i,如果tab[i]==null,直接新建節點添加,否則轉入3
3,判斷當前數組中處理hash衝突的方式爲鏈表還是紅黑樹(check第一個節點類型即可),分別處理

以下是 HashMap 初始化

簡化的模擬數據結構:

1

2

3

4

5

6

7

Node[] table = new Node[16]; // 散列桶初始化,table

class Node {

    hash; //hash值

    key; //鍵

    value; //值

    node next; //用於指向鏈表的下一層(產生衝突,用拉鍊法)

}

存儲查找原理:

  • 存儲:首先獲取key的hashcode,然後取模數組的長度,這樣可以快速定位到要存儲到數組中的座標,然後判斷數組中是否存儲元素,如果沒有存儲則,新構建Node節點,把Node節點存儲到數組中,如果有元素,則迭代鏈表(紅黑二叉樹),如果存在此key,默認更新value,不存在則把新構建的Node存儲到鏈表的尾部。
  • 查找:同上,獲取key的hashcode,通過hashcode取模數組的長度,獲取要定位元素的座標,然後迭代鏈表,進行每一個元素的key的equals對比,如果相同則返回該元素。

HashMap在相同元素個數時,數組的長度越大,則Hash的碰撞率越低,則讀取的效率就越高,數組長度越小,則碰撞率高,讀取速度就越慢。典型的空間換時間的例子。

如何減少碰撞?

擾動函數可以減少碰撞

原理是如果兩個不相等的對象返回不同的 hashcode 的話,那麼碰撞的機率就會小些。這就意味着存鏈表結構減小,這樣取值的話就不會頻繁調用 equal 方法,從而提高 HashMap 的性能(擾動即 Hash 方法內部的算法實現,目的是讓不同對象返回不同hashcode)。

使用不可變的、聲明作 final 對象,並且採用合適的 equals() 和 hashCode() 方法,將會減少碰撞的發生

不可變性使得能夠緩存不同鍵的 hashcode,這將提高整個獲取對象的速度,使用 String、Integer 這樣的 wrapper 類作爲鍵是非常好的選擇。

爲什麼 String、Integer 這樣的 wrapper 類適合作爲鍵?

因爲 String 是 final,而且已經重寫了 equals() 和 hashCode() 方法了。不可變性是必要的,因爲爲了要計算 hashCode(),就要防止鍵值改變,如果鍵值在放入時和獲取時返回不同的 hashcode 的話,那麼就不能從 HashMap 中找到你想要的對象。

如何解決Hash衝突? 

解決方法一:開放尋址法

通過在數組以某種方式尋找數組中空餘的結點放置
基本思想是:當關鍵字key的哈希地址p=H(key)出現衝突時
以p爲基礎,產生另一個哈希地址p1,如果p1仍然衝突,再以p爲基礎,產生另一個哈希地址p2,…,直到找出一個不衝突的哈希地址pi ,

通俗易懂的說法:

簡單地講,也就是說,一間廁所,來了一個顧客就蹲其想蹲的位置,如果又來一個顧客,把廁所單間門拉開,一看裏
面有位童鞋正在用勁,那麼怎麼辦?很自然的,拉另一個單間的門,看看有人不,有的話就繼續找坑。當然了,一般來說,
這個顧客不會按順序一個一個地拉廁所門,而是會去拉他認爲有可能沒有被佔用的單間的門,這可以通過聞味道,聽聲音
來辨別,這就是尋址查找算法。如果找遍了所有廁所單間,看盡了所有人的光屁股,還是找不到坑,那麼這座廁所就該擴
容了。當然了,廁所擴容不會就只單單增加一個坑位,而是綜合考慮成本和保證不讓太多顧客拉到褲子裏,會多增加幾個
坑位,比如增加現有坑位的0.75倍。爲什麼是0.75呢,這是所長多年經營所得到的經驗值,爲了讓自己的經驗發揚光大,
需要出去演講,又不能太俗,總不能說“廁所坑位因子”吧,那就把把0.75叫做“裝填因子”或者“擴容因子”吧。目
前很多產品使用0.75這個常數。

線性探查法:

當衝突發生時,使用某種探查技術在散列表中形成一個探查(測)序列。沿此序列逐個單元地查找,直到找到給定的地址。按照形成探查序列的方法不同,可將開放定址法區分爲線性探查法、二次探查法、雙重散列法等。

下面給一個線性探查法的例子:

問題:已知一組關鍵字爲 (26,36,41,38,44,15,68,12,06,51),用除餘法構造散列函數,用線性探查法解決衝突構造這組關鍵字的散列表。
解答:爲了減少衝突,通常令裝填因子 α 由除餘法因子是13的散列函數計算出的上述關鍵字序列的散列地址爲 (0,10,2,12,5,2,3,12,6,12)。
前5個關鍵字插入時,其相應的地址均爲開放地址,故將它們直接插入 T[0]、T[10)、T[2]、T[12] 和 T[5] 中。
當插入第6個關鍵字15時,其散列地址2(即 h(15)=15%13=2)已被關鍵字 41(15和41互爲同義詞)佔用。故探查 h1=(2+1)%13=3,此地址開放,所以將 15 放入 T[3] 中。
當插入第7個關鍵字68時,其散列地址3已被非同義詞15先佔用,故將其插入到T[4]中。
當插入第8個關鍵字12時,散列地址12已被同義詞38佔用,故探查 hl=(12+1)%13=0,而 T[0] 亦被26佔用,再探查 h2=(12+2)%13=1,此地址開放,可將12插入其中。
類似地,第9個關鍵字06直接插入 T[6] 中;而最後一個關鍵字51插人時,因探查的地址 12,0,1,…,6 均非空,故51插入 T[7] 中。

 

解決方法二:鏈地址法

通過引入鏈表 數組中每一個實體存儲爲鏈表結構,如果發生碰撞,則把舊結點指針指向新鏈表結點,
此時查詢碰撞結點只需要遍歷該鏈表即可
在這種方法下,數據結構如下所示
int類型數據 hashcode 爲自身值

如下圖可以看出HashMap底層就是一個數組結構,每個數組中又存儲着鏈表(鏈表的引用)   

HashMap是由數組加鏈表的結合體。如下圖:

這裏敲黑板的是擴容:爲什麼要擴容?擴容因子是大好,還是小好?

什麼時間開始擴容?
當鏈表數組的容量超過初始容量的0.75時,再散列將鏈表數組擴大2倍,把原鏈表數組的搬移到新的數組中
數組是定長的,如果擴容因子太小數組就會很大,擴容時老數據要搬移到新數組中所以搬移的次數就會增多,
增加消耗;如果擴容因子太大,數組長度小,hash衝突的機率增加,造成鏈表的長度過長,
假設一種極端情況數組只有一位,鏈表無限長,這個時候就成了一個單向的遍歷查詢,
這個時候的時間複雜度變成了O(n)。
加載因子(默認0.75):爲什麼需要使用加載因子,爲什麼需要擴容呢?因爲如果填充比很大,
說明利用的空間很多,如果一直不進行擴容的話,鏈表就會越來越長,這樣查找的效率很低,
因爲鏈表的長度很大(當然最新版本使用了紅黑樹後會改進很多),擴容之後,將原來鏈表數組的每
一個鏈表分成奇偶兩個子鏈表分別掛在新鏈表數組的散列位置,這樣就減少了每個鏈表的長度,
增加查找效率

HashMap本來是以空間換時間,所以填充比沒必要太大。但是填充比太小又會導致空間浪費。
如果關注內存,填充比可以稍大,如果主要關注查找性能,填充比可以稍小。

當從哈希表中查詢數據時,如果key對應一條鏈表,遍歷時如何判斷是否應該覆蓋?

當遍歷鏈表時,如果兩個key.hashcode的h一致會調用equals()方法判斷是否爲同一對象,
equal的默認實現是比較兩者的內存地址。
因此爲什麼Java強調當重寫equals()時需要同時重寫hashcode()方法,假設兩個不同對象,
在內存中的地址不同分別爲a和b,那麼重寫equals()以後a.equals(b) =true 
開發者希望把a,b這兩個key視作完全相等
然而由於內存地址的不同導致hashcode不同,會導致在hashmap中儲存2個本應相同的key值

驗證Demo:如果只重寫了

equals()時需要同時重寫hashcode()方法,假設兩個不同對象,在內存中的地址不同分別爲a和b,那麼重寫equals()以後a.equals(b) =true 開發者希望把a,b這兩個key視作完全相等,然而由於內存地址的不同導致hashcode不同,會導致在hashmap中儲存2個本應相同的key值

重寫hashcode()方法就會只有一個數據。

以下是具體的 put 過程(JDK1.8)

  1. 對 Key 求 Hash 值,然後再計算下標
  2. 如果沒有碰撞,直接放入桶中(碰撞的意思是計算得到的 Hash 值相同,需要放到同一個 bucket 中)
  3. 如果碰撞了,以鏈表的方式鏈接到後面
  4. 如果鏈表長度超過閥值(TREEIFY THRESHOLD==8),就把鏈表轉成紅黑樹,鏈表長度低於6,就把紅黑樹轉回鏈表
  5. 如果節點已經存在就替換舊值
  6. 如果桶滿了(容量16*加載因子0.75),就需要 resize(擴容2倍後重排)

以下是具體 get 過程

考慮特殊情況:如果兩個鍵的 hashcode 相同,你如何獲取值對象?

當我們調用 get() 方法,HashMap 會使用鍵對象的 hashcode 找到 bucket 位置,找到 bucket 位置之後,會調用 keys.equals() 方法去找到鏈表中正確的節點,最終找到要找的值對象。

4、HashMap 中 hash 函數怎麼是實現的?

我們可以看到,在 hashmap 中要找到某個元素,需要根據 key 的 hash 值來求得對應數組中的位置。如何計算這個位置就是 hash 算法。

前面說過,hashmap 的數據結構是數組和鏈表的結合,所以我們當然希望這個 hashmap 裏面的元素位置儘量的分佈均勻些,儘量使得每個位置上的元素數量只有一個。那麼當我們用 hash 算法求得這個位置的時候,馬上就可以知道對應位置的元素就是我們要的,而不用再去遍歷鏈表。 所以,我們首先想到的就是把 hashcode 對數組長度取模運算。這樣一來,元素的分佈相對來說是比較均勻的。

但是“模”運算的消耗還是比較大的,能不能找一種更快速、消耗更小的方式?我們來看看 JDK1.8 源碼是怎麼做的(被樓主修飾了一下)

1

2

3

4

5

6

7

8

9

10

11

static final int hash(Object key) {

    if (key == null){

        return 0;

    }

    int h;

    h = key.hashCode();返回散列值也就是hashcode

    // ^ :按位異或

    // >>>:無符號右移,忽略符號位,空位都以0補齊

    //其中n是數組的長度,即Map的數組部分初始化長度

    return (n-1)&(h ^ (h >>> 16));

}

簡單來說就是:

  • 高16 bit 不變,低16 bit 和高16 bit 做了一個異或(得到的 hashcode 轉化爲32位二進制,前16位和後16位低16 bit和高16 bit做了一個異或)
  • (n·1) & hash = -> 得到下標

5、拉鍊法導致的鏈表過深,爲什麼不用二叉查找樹代替而選擇紅黑樹?爲什麼不一直使用紅黑樹?

之所以選擇紅黑樹是爲了解決二叉查找樹的缺陷:二叉查找樹在特殊情況下會變成一條線性結構(這就跟原來使用鏈表結構一樣了,造成層次很深的問題),遍歷查找會非常慢。而紅黑樹在插入新數據後可能需要通過左旋、右旋、變色這些操作來保持平衡。引入紅黑樹就是爲了查找數據快,解決鏈表查詢深度的問題。我們知道紅黑樹屬於平衡二叉樹,爲了保持“平衡”是需要付出代價的,但是該代價所損耗的資源要比遍歷線性鏈表要少。所以當長度大於8的時候,會使用紅黑樹;如果鏈表長度很短的話,根本不需要引入紅黑樹,引入反而會慢。

6、說說你對紅黑樹的見解?

  1. 每個節點非紅即黑
  2. 根節點總是黑色的
  3. 如果節點是紅色的,則它的子節點必須是黑色的(反之不一定)
  4. 每個葉子節點都是黑色的空節點(NIL節點)
  5. 從根節點到葉節點或空子節點的每條路徑,必須包含相同數目的黑色節點(即相同的黑色高度)

8、如果 HashMap 的大小超過了負載因子(load factor)定義的容量怎麼辦?

HashMap 默認的負載因子大小爲0.75。也就是說,當一個 Map 填滿了75%的 bucket 時候,和其它集合類一樣(如 ArrayList 等),將會創建原來 HashMap 大小的兩倍的 bucket 數組來重新調整 Map 大小,並將原來的對象放入新的 bucket 數組中。這個過程叫作 rehashing

因爲它調用 hash 方法找到新的 bucket 位置。這個值只可能在兩個地方,一個是原下標的位置,另一種是在下標爲 <原下標+原容量> 的位置。

9、重新調整 HashMap 大小存在什麼問題嗎?

重新調整 HashMap 大小的時候,確實存在條件競爭。

因爲如果兩個線程都發現 HashMap 需要重新調整大小了,它們會同時試着調整大小。在調整大小的過程中,存儲在鏈表中的元素的次序會反過來。因爲移動到新的 bucket 位置的時候,HashMap 並不會將元素放在鏈表的尾部,而是放在頭部。這是爲了避免尾部遍歷(tail traversing)。如果條件競爭發生了,那麼就死循環了。多線程的環境下不使用 HashMap。

爲什麼多線程會導致死循環,它是怎麼發生的?

HashMap 的容量是有限的。當經過多次元素插入,使得 HashMap 達到一定飽和度時,Key 映射位置發生衝突的機率會逐漸提高。這時候, HashMap 需要擴展它的長度,也就是進行Resize。

  1. 擴容:創建一個新的 Entry 空數組,長度是原數組的2倍
  2. rehash:遍歷原 Entry 數組,把所有的 Entry 重新 Hash 到新數組

10、HashTable

  • 數組 + 鏈表方式存儲
  • 默認容量:11(質數爲宜)
  • put操作:首先進行索引計算 (key.hashCode() & 0x7FFFFFFF)% table.length;若在鏈表中找到了,則替換舊值,若未找到則繼續;當總元素個數超過 容量 * 加載因子 時,擴容爲原來 2 倍並重新散列;將新元素加到鏈表頭部
  • 對修改 Hashtable 內部共享數據的方法添加了 synchronized,保證線程安全

11、HashMap 與 HashTable 區別

  • 默認容量不同,擴容不同
  • 線程安全性:HashTable 安全
  • 效率不同:HashTable 要慢,因爲加鎖

12、可以使用 CocurrentHashMap 來代替 Hashtable 嗎?

  • 我們知道 Hashtable 是 synchronized 的,但是 ConcurrentHashMap 同步性能更好,因爲它僅僅根據同步級別對 map 的一部分進行上鎖
  • ConcurrentHashMap 當然可以代替 HashTable,但是 HashTable 提供更強的線程安全性
  • 它們都可以用於多線程的環境,但是當 Hashtable 的大小增加到一定的時候,性能會急劇下降,因爲迭代時需要被鎖定很長的時間。由於 ConcurrentHashMap 引入了分割(segmentation),不論它變得多麼大,僅僅需要鎖定 Map 的某個部分,其它的線程不需要等到迭代完成才能訪問 Map。簡而言之,在迭代的過程中,ConcurrentHashMap 僅僅鎖定 Map 的某個部分,而 Hashtable 則會鎖定整個 Map

13、CocurrentHashMap(JDK 1.7)

  • CocurrentHashMap 是由 Segment 數組和 HashEntry 數組和鏈表組成
  • Segment 是基於重入鎖(ReentrantLock):一個數據段競爭鎖。每個 HashEntry 一個鏈表結構的元素,利用 Hash 算法得到索引確定歸屬的數據段,也就是對應到在修改時需要競爭獲取的鎖。ConcurrentHashMap 支持 CurrencyLevel(Segment 數組數量)的線程併發。每當一個線程佔用鎖訪問一個 Segment 時,不會影響到其他的 Segment
  • 核心數據如 value,以及鏈表都是 volatile 修飾的,保證了獲取時的可見性
  • 首先是通過 key 定位到 Segment,之後在對應的 Segment 中進行具體的 put 操作如下:
    • 將當前 Segment 中的 table 通過 key 的 hashcode 定位到 HashEntry。
    • 遍歷該 HashEntry,如果不爲空則判斷傳入的  key 和當前遍歷的 key 是否相等,相等則覆蓋舊的 value
    • 不爲空則需要新建一個 HashEntry 並加入到 Segment 中,同時會先判斷是否需要擴容
    • 最後會解除在 1 中所獲取當前 Segment 的鎖。
  • 雖然 HashEntry 中的 value 是用 volatile 關鍵詞修飾的,但是並不能保證併發的原子性,所以 put 操作時仍然需要加鎖處理

首先第一步的時候會嘗試獲取鎖,如果獲取失敗肯定就有其他線程存在競爭,則利用 scanAndLockForPut() 自旋獲取鎖。

  • 嘗試自旋獲取鎖
  • 如果重試的次數達到了 MAX_SCAN_RETRIES 則改爲阻塞鎖獲取,保證能獲取成功。最後解除當前 Segment 的鎖

14、CocurrentHashMap(JDK 1.8)

CocurrentHashMap 拋棄了原有的 Segment 分段鎖,採用了 CAS + synchronized 來保證併發安全性。其中的 val next 都用了 volatile 修飾,保證了可見性。

最大特點是引入了 CAS

藉助 Unsafe 來實現 native code。CAS有3個操作數,內存值 V、舊的預期值 A、要修改的新值 B。當且僅當預期值 A 和內存值 V 相同時,將內存值V修改爲 B,否則什麼都不做。Unsafe 藉助 CPU 指令 cmpxchg 來實現。

CAS 使用實例

對 sizeCtl 的控制都是用 CAS 來實現的:

  • -1 代表 table 正在初始化
  • N 表示有 -N-1 個線程正在進行擴容操作
  • 如果 table 未初始化,表示table需要初始化的大小
  • 如果 table 初始化完成,表示table的容量,默認是table大小的0.75倍,用這個公式算 0.75(n – (n >>> 2))

CAS 會出現的問題:ABA

解決:對變量增加一個版本號,每次修改,版本號加 1,比較的時候比較版本號。

put 過程

  • 根據 key 計算出 hashcode
  • 判斷是否需要進行初始化
  • 通過 key 定位出的 Node,如果爲空表示當前位置可以寫入數據,利用 CAS 嘗試寫入,失敗則自旋保證成功
  • 如果當前位置的 hashcode == MOVED == -1,則需要進行擴容
  • 如果都不滿足,則利用 synchronized 鎖寫入數據
  • 如果數量大於 TREEIFY_THRESHOLD 則要轉換爲紅黑樹

get 過程

  • 根據計算出來的 hashcode 尋址,如果就在桶上那麼直接返回值
  • 如果是紅黑樹那就按照樹的方式獲取值
  • 就不滿足那就按照鏈表的方式遍歷獲取值

ConcurrentHashMap 在 Java 8 中存在一個 bug 會進入死循環,原因是遞歸創建 ConcurrentHashMap 對象,但是在 JDK 1.9 已經修復了。場景重現如下:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

public class ConcurrentHashMapDemo{

    private Map<Integer,Integer> cache =new ConcurrentHashMap<>(15);

 

    public static void main(String[]args){

        ConcurrentHashMapDemo ch =    new ConcurrentHashMapDemo();

        System.out.println(ch.fibonaacci(80));       

    }

 

    public int fibonaacci(Integer i){       

        if(i==0||i ==1) {               

            return i;       

        }

 

        return cache.computeIfAbsent(i,(key) -> {

            System.out.println("fibonaacci : "+key);

            return fibonaacci(key -1)+fibonaacci(key - 2);       

        });      

    }

}

HashMap的源碼:

1,位桶數組:數組元素Node<K,V>實現了Entry接口

transient Node<k,v>[] table;//存儲(位桶)的數組</k,v>

2,數組元素Node<K,V>實現了Entry接口 

//Node是單向鏈表,它實現了Map.Entry接口  
static class Node<k,v> implements Map.Entry<k,v> {  
    final int hash;  
    final K key;  
    V value;  
    Node<k,v> next;  
    //構造函數Hash值 鍵 值 下一個節點  
    Node(int hash, K key, V value, Node<k,v> next) {  
        this.hash = hash;  
        this.key = key;  
        this.value = value;  
        this.next = next;  
    }  
   
    public final K getKey()        { return key; }  
    public final V getValue()      { return value; }  
    public final String toString() { return key + = + value; }  
   
    public final int hashCode() {  
        return Objects.hashCode(key) ^ Objects.hashCode(value);  
    }  
   
    public final V setValue(V newValue) {  
        V oldValue = value;  
        value = newValue;  
        return oldValue;  
    }  
    //判斷兩個node是否相等,若key和value都相等,返回true。可以與自身比較爲true  
    public final boolean equals(Object o) {  
        if (o == this)  
            return true;  
        if (o instanceof Map.Entry) {  
            Map.Entry<!--?,?--> e = (Map.Entry<!--?,?-->)o;  
            if (Objects.equals(key, e.getKey()) &&  
                Objects.equals(value, e.getValue()))  
                return true;  
        }  
        return false;  
    }

3,源碼中的數據域 

public class HashMap<k,v> extends AbstractMap<k,v> implements Map<k,v>, Cloneable, Serializable {  
    private static final long serialVersionUID = 362498820763181265L;  
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16  
    static final int MAXIMUM_CAPACITY = 1 << 30;//最大容量  
    static final float DEFAULT_LOAD_FACTOR = 0.75f;//填充比  
    //當add一個元素到某個位桶,其鏈表長度達到8時將鏈表轉換爲紅黑樹  
    static final int TREEIFY_THRESHOLD = 8;  
    static final int UNTREEIFY_THRESHOLD = 6;  
    static final int MIN_TREEIFY_CAPACITY = 64;  
    transient Node<k,v>[] table;//存儲元素的數組  
    transient Set<map.entry<k,v>> entrySet;  
    transient int size;//存放元素的個數  
    transient int modCount;//被修改的次數fast-fail機制  
    int threshold;//臨界值 當實際大小(容量*填充比)超過臨界值時,會進行擴容   
    final float loadFactor;//填充比(......後面略)

4,HashMap的構造函數

HashMap的構造方法有4種,主要涉及到的參數有,指定初始容量,指定填充比和用來初始化的Map

//構造函數1  
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);//新的擴容臨界值  
}  
   
//構造函數2  
public HashMap(int initialCapacity) {  
    this(initialCapacity, DEFAULT_LOAD_FACTOR);  
}  
   
//構造函數3  
public HashMap() {  
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted  
}  
   
//構造函數4用m的元素初始化散列映射  
public HashMap(Map<!--? extends K, ? extends V--> m) {  
    this.loadFactor = DEFAULT_LOAD_FACTOR;  
    putMapEntries(m, false);  
}

5,HashMap如何getValue值 

get(key)方法時獲取key的hash值,計算hash&(n-1)得到在鏈表數組中的位置first=tab[hash&(n-1)],先判斷first的key是否與參數key相等,不等就遍歷後面的鏈表找到相同的key值返回對應的Value值即可

public V get(Object key) {  
        Node<K,V> e;  
        return (e = getNode(hash(key), key)) == null ? null : e.value;  
    }  
      /** 
     * Implements Map.get and related methods 
     * 
     * @param hash hash for key 
     * @param key the key 
     * @return the node, or null if none 
     */  
    final Node<K,V> getNode(int hash, Object key) {  
        Node<K,V>[] tab;//Entry對象數組  
    Node<K,V> first,e; //在tab數組中經過散列的第一個位置  
    int n;  
    K k;  
    /*找到插入的第一個Node,方法是hash值和n-1相與,tab[(n - 1) & hash]*/  
    //也就是說在一條鏈上的hash值相同的  
        if ((tab = table) != null && (n = tab.length) > 0 &&(first = tab[(n - 1) & hash]) != null) {  
    /*檢查第一個Node是不是要找的Node*/  
            if (first.hash == hash && // always check first node  
                ((k = first.key) == key || (key != null && key.equals(k))))//判斷條件是hash值要相同,key值要相同  
                return first;  
      /*檢查first後面的node*/  
            if ((e = first.next) != null) {  
                if (first instanceof TreeNode)  
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);  
                /*遍歷後面的鏈表,找到key值和hash值都相同的Node*/  
                do {  
                    if (e.hash == hash &&  
                        ((k = e.key) == key || (key != null && key.equals(k))))  
                        return e;  
                } while ((e = e.next) != null);  
            }  
        }  
        return null;  
    }

6,紅黑二叉樹的結構

//紅黑樹  
static final class TreeNode<k,v> extends LinkedHashMap.Entry<k,v> {  
    TreeNode<k,v> parent;  // 父節點  
    TreeNode<k,v> left; //左子樹  
    TreeNode<k,v> right;//右子樹  
    TreeNode<k,v> prev;    // needed to unlink next upon deletion  
    boolean red;    //顏色屬性  
    TreeNode(int hash, K key, V val, Node<k,v> next) {  
        super(hash, key, val, next);  
    }  
   
    //返回當前節點的根節點  
    final TreeNode<k,v> root() {  
        for (TreeNode<k,v> r = this, p;;) {  
            if ((p = r.parent) == null)  
                return r;  
            r = p;  
        }  
    }

7,HashMap.put(key, value)插入方法

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        //p:鏈表節點  n:數組長度   i:鏈表所在數組中的索引座標
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //判斷tab[]數組是否爲空或長度等於0,進行初始化擴容
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //判斷tab指定索引位置是否有元素,沒有則,直接newNode賦值給tab[i]
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        //如果該數組位置存在Node
        else {
            //首先先去查找與待插入鍵值對key相同的Node,存儲在e中,k是那個節點的key
            Node<K,V> e; K k;
            //判斷key是否已經存在(hash和key都相等)
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //如果Node是紅黑二叉樹,則執行樹的插入操作
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            //否則執行鏈表的插入操作(說明Hash值碰撞了,把Node加入到鏈表中)
            else {
                for (int binCount = 0; ; ++binCount) {
                    //如果該節點是尾節點,則進行添加操作
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        //判斷如果鏈表長度,如果鏈表長度大於8則調用treeifyBin方法,判斷是擴容還是把鏈表轉換成紅黑二叉樹
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    //如果鍵值存在,則退出循環
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    //把p執行p的子節點,開始下一次循環(p = e = p.next)
                    p = e;
                }
            }
            //在循環中判斷e是否爲null,如果爲null則表示加了一個新節點,不是null則表示找到了hash、key都一致的Node。
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                //判斷是否更新value值。(map提供putIfAbsent方法,如果key存在,不更新value,但是如果value==null任何情況下都更改此值)
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                //此方法是空方法,什麼都沒實現,用戶可以根據需要進行覆蓋
                afterNodeAccess(e);
                return oldValue;
            }
        }
        //只有插入了新節點才進行++modCount;
        ++modCount;
        //如果size>threshold則開始擴容(每次擴容原來的1倍)
        if (++size > threshold)
            resize();
        //此方法是空方法,什麼都沒實現,用戶可以根據需要進行覆蓋
        afterNodeInsertion(evict);
        return null;
    }

1.判斷鍵值對數組tab[i]是否爲空或爲null,否則執行resize()進行擴容;

2.根據鍵值key計算hash值得到插入的數組索引i,如果table[i]==null,直接新建節點添加,轉向6,如果table[i]不爲空,轉向3;

3.判斷鏈表(或二叉樹)的首個元素是否和key一樣,不一樣轉向④,相同轉向6;

4.判斷鏈表(或二叉樹)的首節點 是否爲treeNode,即是否是紅黑樹,如果是紅黑樹,則直接在樹中插入鍵值對,不是則執行5;

5.遍歷鏈表,判斷鏈表長度是否大於8,大於8的話把鏈表轉換爲紅黑樹(還判斷數組長度是否小於64,如果小於只是擴容,不進行轉換二叉樹),在紅黑樹中執行插入操作,否則進行鏈表的插入操作;遍歷過程中若發現key已經存在直接覆蓋value即可;如果調用putIfAbsent方法插入,則不更新值(只更新值爲null的元素)。

6.插入成功後,判斷實際存在的鍵值對數量size是否超多了最大容量threshold,如果超過,進行擴容。

    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            do {
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }

1、首先判斷數組的長度是否小於64,如果小於64則進行擴容
2、否則把鏈表結構轉換成紅黑二叉樹結構

8,HasMap的擴容機制resize();

構造hash表時,如果不指明初始大小,默認大小爲16(即Node數組大小16),如果Node[]數組中的元素達到(填充比*Node.length)重新調整HashMap大小 變爲原來2倍大小,擴容很耗時

/** 
    * Initializes or doubles table size.  If null, allocates in 
    * accord with initial capacity target held in field threshold. 
    * Otherwise, because we are using power-of-two expansion, the 
    * elements from each bin must either stay at same index, or move 
    * with a power of two offset in the new table. 
    * 
    * @return the table 
    */  
   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;  
           }  
/*把新表的長度設置爲舊錶長度的兩倍,newCap=2*oldCap*/  
           else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&  
                    oldCap >= DEFAULT_INITIAL_CAPACITY)  
      /*把新表的門限設置爲舊錶門限的兩倍,newThr=oldThr*2*/  
               newThr = oldThr << 1; // double threshold  
       }  
    /*如果舊錶的長度的是0,就是說第一次初始化表*/  
       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;//把新表賦值給table  
       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)//說明這個node沒有鏈表直接放在新表的e.hash & (newCap - 1)位置  
                       newTab[e.hash & (newCap - 1)] = e;  
                   else if (e instanceof TreeNode)  
                       ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);  
/*如果e後邊有鏈表,到這裏表示e後面帶着個單鏈表,需要遍歷單鏈表,將每個結點重*/  
                   else { // preserve order保證順序  
                ////新計算在新表的位置,並進行搬運  
                       Node<K,V> loHead = null, loTail = null;  
                       Node<K,V> hiHead = null, hiTail = null;  
                       Node<K,V> next;  
                      
                       do {  
                           next = e.next;//記錄下一個結點  
          //新表是舊錶的兩倍容量,實例上就把單鏈表拆分爲兩隊,  
             //e.hash&oldCap爲偶數一隊,e.hash&oldCap爲奇數一對  
                           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) {//lo隊不爲null,放在新表原位置  
                           loTail.next = null;  
                           newTab[j] = loHead;  
                       }  
                       if (hiTail != null) {//hi隊不爲null,放在新表j+oldCap位置  
                           hiTail.next = null;  
                           newTab[j + oldCap] = hiHead;  
                       }  
                   }  
               }  
           }  
       }  
       return newTab;  
   }

modCount 變量的作用

    public final void forEach(Consumer<? super K> action) {
            Node<K,V>[] tab;
            if (action == null)
                throw new NullPointerException();
            if (size > 0 && (tab = table) != null) {
                int mc = modCount;
                for (int i = 0; i < tab.length; ++i) {
                    for (Node<K,V> e = tab[i]; e != null; e = e.next)
                        action.accept(e.key);
                }
                if (modCount != mc)
                    throw new ConcurrentModificationException();
            }
        }

從forEach循環中可以發現 modCount 參數的作用。就是在迭代器迭代輸出Map中的元素時,不能編輯(增加,刪除,修改)Map中的元素。如果在迭代時修改,則拋出ConcurrentModificationException異常。

疑問解答:

1、hash取餘數,爲什麼不用取模操作呢,而用tab[i = (n - 1) & hash]?

它通過 (n - 1) & hash來得到該對象的保存位,而HashMap底層數組的長度總是2的n次方,這是HashMap在速度上的優化。當length總是2的n次方時, (n - 1) & hash運算等價於對length取模,也就是h%length,但是&比%具有更高的效率。

2、爲什麼使用紅黑二叉樹呢?

因爲在好的算法,也避免不了hash的碰撞,避免不了鏈表過長的的情況,一旦出現鏈表過長,則嚴重影響到HashMap的性能。JDK8對HashMap做了優化,把鏈表長度超過8個的,則改成紅黑二叉樹,提高訪問的速度。

JDK1.8使用紅黑樹的改進

Java jdk8中對HashMap的源碼進行了優化,在jdk7中,HashMap處理“碰撞”的時候,都是採用鏈表來存儲,當碰撞的結點很多時,查詢時間是O(n)。
在jdk8中,HashMap處理“碰撞”增加了紅黑樹這種數據結構,當碰撞結點較少時,採用鏈表存儲,當較大時(>8個),採用紅黑樹(特點是查詢時間是O(logn))存儲(有一個閥值控制,大於閥值(8個),將鏈表存儲轉換成紅黑樹存儲)

                                             

問題分析:

你可能還知道哈希碰撞會對hashMap的性能帶來災難性的影響。如果多個hashCode()的值落到同一個桶內的時候,這些值是存儲到一個鏈表中的。最壞的情況下,所有的key都映射到同一個桶中,這樣hashmap就退化成了一個鏈表——查找時間從O(1)到O(n)。

隨着HashMap的大小的增長,get()方法的開銷也越來越大。由於所有的記錄都在同一個桶裏的超長鏈表內,平均查詢一條記錄就需要遍歷一半的列表。

JDK1.8HashMap的紅黑樹是這樣解決的

         如果某個桶中的記錄過大的話(當前是TREEIFY_THRESHOLD = 8),HashMap會動態的使用一個專門的treemap實現來替換掉它。這樣做的結果會更好,是O(logn),而不是糟糕的O(n)。

        它是如何工作的?前面產生衝突的那些KEY對應的記錄只是簡單的追加到一個鏈表後面,這些記錄只能通過遍歷來進行查找。但是超過這個閾值後HashMap開始將列表升級成一個二叉樹,使用哈希值作爲樹的分支變量,如果兩個哈希值不等,但指向同一個桶的話,較大的那個會插入到右子樹裏。如果哈希值相等,HashMap希望key值最好是實現了Comparable接口的,這樣它可以按照順序來進行插入。這對HashMap的key來說並不是必須的,不過如果實現了當然最好。如果沒有實現這個接口,在出現嚴重的哈希碰撞的時候,你就並別指望能獲得性能提升了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章