題記:這是以前寫得一篇文章,有對別人文章的引用,現對文章進行補充複習!添加一些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 |
|
存儲查找原理:
- 存儲:首先獲取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)
- 對 Key 求 Hash 值,然後再計算下標
- 如果沒有碰撞,直接放入桶中(碰撞的意思是計算得到的 Hash 值相同,需要放到同一個 bucket 中)
- 如果碰撞了,以鏈表的方式鏈接到後面
- 如果鏈表長度超過閥值(TREEIFY THRESHOLD==8),就把鏈表轉成紅黑樹,鏈表長度低於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 |
|
簡單來說就是:
- 高16 bit 不變,低16 bit 和高16 bit 做了一個異或(得到的 hashcode 轉化爲32位二進制,前16位和後16位低16 bit和高16 bit做了一個異或)
- (n·1) & hash = -> 得到下標
5、拉鍊法導致的鏈表過深,爲什麼不用二叉查找樹代替而選擇紅黑樹?爲什麼不一直使用紅黑樹?
之所以選擇紅黑樹是爲了解決二叉查找樹的缺陷:二叉查找樹在特殊情況下會變成一條線性結構(這就跟原來使用鏈表結構一樣了,造成層次很深的問題),遍歷查找會非常慢。而紅黑樹在插入新數據後可能需要通過左旋、右旋、變色這些操作來保持平衡。引入紅黑樹就是爲了查找數據快,解決鏈表查詢深度的問題。我們知道紅黑樹屬於平衡二叉樹,爲了保持“平衡”是需要付出代價的,但是該代價所損耗的資源要比遍歷線性鏈表要少。所以當長度大於8的時候,會使用紅黑樹;如果鏈表長度很短的話,根本不需要引入紅黑樹,引入反而會慢。
6、說說你對紅黑樹的見解?
- 每個節點非紅即黑
- 根節點總是黑色的
- 如果節點是紅色的,則它的子節點必須是黑色的(反之不一定)
- 每個葉子節點都是黑色的空節點(NIL節點)
- 從根節點到葉節點或空子節點的每條路徑,必須包含相同數目的黑色節點(即相同的黑色高度)
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。
- 擴容:創建一個新的 Entry 空數組,長度是原數組的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 |
|
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來說並不是必須的,不過如果實現了當然最好。如果沒有實現這個接口,在出現嚴重的哈希碰撞的時候,你就並別指望能獲得性能提升了。