文章很長,建議收藏起來慢慢讀!瘋狂創客圈總目錄 語雀版 | 總目錄 碼雲版| 總目錄 博客園版 爲您奉上珍貴的學習資源 :
-
免費贈送 :《尼恩Java面試寶典》持續更新+ 史上最全 + 面試必備 2000頁+ 面試必備 + 大廠必備 +漲薪必備
-
免費贈送 經典圖書:《Java高併發核心編程(卷1)》 面試必備 + 大廠必備 +漲薪必備 加尼恩免費領
-
免費贈送 經典圖書:《Java高併發核心編程(卷2)》 面試必備 + 大廠必備 +漲薪必備 加尼恩免費領
-
免費贈送 經典圖書:《Netty Zookeeper Redis 高併發實戰》 面試必備 + 大廠必備 +漲薪必備 加尼恩免費領
-
免費贈送 經典圖書:《SpringCloud Nginx高併發核心編程》 面試必備 + 大廠必備 +漲薪必備 加尼恩免費領
-
免費贈送 資源寶庫: Java 必備 百度網盤資源大合集 價值>10000元 加尼恩領取
推薦:入大廠 、做架構、大力提升Java 內功 的 精彩博文
入大廠 、做架構、大力提升Java 內功 必備的精彩博文 | 秋招漲薪1W + 必備的精彩博文 |
---|---|
1:Redis 分佈式鎖 (圖解-秒懂-史上最全) | 2:Zookeeper 分佈式鎖 (圖解-秒懂-史上最全) |
3: Redis與MySQL雙寫一致性如何保證? (面試必備) | 4: 面試必備:秒殺超賣 解決方案 (史上最全) |
5:面試必備之:Reactor模式 | 6: 10分鐘看懂, Java NIO 底層原理 |
7:TCP/IP(圖解+秒懂+史上最全) | 8:Feign原理 (圖解) |
9:DNS圖解(秒懂 + 史上最全 + 高薪必備) | 10:CDN圖解(秒懂 + 史上最全 + 高薪必備) |
11: 分佈式事務( 圖解 + 史上最全 + 吐血推薦 ) | 12:限流:計數器、漏桶、令牌桶 三大算法的原理與實戰(圖解+史上最全) |
13:架構必看:12306搶票系統億級流量架構 (圖解+秒懂+史上最全) |
14:seata AT模式實戰(圖解+秒懂+史上最全) |
15:seata 源碼解讀(圖解+秒懂+史上最全) | 16:seata TCC模式實戰(圖解+秒懂+史上最全) |
SpringCloud 微服務 精彩博文 | |
---|---|
nacos 實戰(史上最全) | sentinel (史上最全+入門教程) |
SpringCloud gateway (史上最全) | 分庫分表sharding-jdbc底層原理與實操(史上最全,5W字長文,吐血推薦) |
推薦:尼恩Java面試寶典(持續更新 + 史上最全 + 面試必備)
尼恩Java面試寶典,32個最新pdf,含2000多頁,不斷更新、持續迭代 具體詳情,請點擊此鏈接
史上最全 Java 面試題 30 專題 總目錄
Hashmap 面試題 + Hashmap 原理 + Hashmap 源碼
HashMap作爲我們日常使用最頻繁的容器之一,相信你一定不陌生了。今天我們就從HashMap的底層實現講起,深度瞭解下它的設計與優化。
常用的數據結構
一起來溫習下常用的數據結構,這樣也有助於你更好地理解後面地內容。
數組:採用一段連續的存儲單元來存儲數據。對於指定下標的查找,時間複雜度爲O(1),但在數 組中間以及頭部插入數據時,需要複製移動後面的元素。
鏈表:一種在物理存儲單元上非連續、非順序的存儲結構,數據元素的邏輯順序是通過鏈表中的指針鏈接次序實現的。
鏈表由一系列結點(鏈表中每一個元素)組成,結點可以在運行時動態生成。每個結點都包含“存儲數據單元的數據域”和“存儲下一個結點地址的指針域”這兩個部分。
由於鏈表不用必須按順序存儲,所以鏈表在插入的時候可以達到O(1)的複雜度,但查找一個結點或者訪問特定編號的結點需要O(n)的時間。
哈希表:根據關鍵碼值(Key value)直接進行訪問的數據結構。通過把關鍵碼值映射到表中一個位置來訪問記錄,以加快查找的速度。這個映射函數叫做哈希函數,存放記錄的數組就叫做哈希表。
樹:由n(n≥1)個有限結點組成的一個具有層次關係的集合,就像是一棵倒掛的樹。
什麼是哈希表
從根本上來說,一個哈希表包含一個數組,通過特殊的關鍵碼(也就是key)來訪問數組中的元素。
哈希表的主要思想是:
-
存放Value的時候,通過一個哈希函數,通過 關鍵碼(key)進行哈希運算得到哈希值,然後得到 映射的位置, 去尋找存放值的地方 ,
-
讀取Value的時候,也是通過同一個哈希函數,通過 關鍵碼(key)進行哈希運算得到哈希值,然後得到 映射的位置,從那個位置去讀取。
最直接的例子就是字典,例如下面的字典圖,如果我們要找 “啊” 這個字,只要根據拼音 “a” 去查找拼音索引,查找 “a” 在字典中的位置 “啊”,這個過程就是哈希函數的作用,用公式來表達就是:f(key),而這樣的函數所建立的表就是哈希表。
哈希表的優勢:加快了查找的速度。
比起數組和鏈表查找元素時需要遍歷整個集合的情況來說,哈希表明顯方便和效率的多。
常見的哈希算法
哈希表的組成取決於哈希算法,也就是哈希函數的構成,下面列舉幾種常見的哈希算法。
1) 直接定址法
- 取關鍵字或關鍵字的某個線性函數值爲散列地址。
- 即 f(key) = key 或 f(key) = a*key + b,其中a和b爲常數。
2) 除留餘數法
- 取關鍵字被某個不大於散列表長度 m 的數 p 求餘,得到的作爲散列地址。
- 即 f(key) = key % p, p < m。這是最爲常見的一種哈希算法。
3) 數字分析法
- 當關鍵字的位數大於地址的位數,對關鍵字的各位分佈進行分析,選出分佈均勻的任意幾位作爲散列地址。
- 僅適用於所有關鍵字都已知的情況下,根據實際應用確定要選取的部分,儘量避免發生衝突。
4) 平方取中法
- 先計算出關鍵字值的平方,然後取平方值中間幾位作爲散列地址。
- 隨機分佈的關鍵字,得到的散列地址也是隨機分佈的。
5) 隨機數法
- 選擇一個隨機函數,把關鍵字的隨機函數值作爲它的哈希值。
- 通常當關鍵字的長度不等時用這種方法。
什麼是哈希衝突(hash碰撞)
哈希表因爲其本身的結構使得查找對應的值變得方便快捷,但也帶來了一些問題,
以上面的字典圖爲例,key中的一個拼音對應一個字,那如果字典中有兩個字的拼音相同呢?
例如,我們要查找 “按” 這個字,根據字母拼音就會跳到 “安” 的位置,這就是典型的哈希衝突問題。
哈希衝突問題,用公式表達就是:
key1 ≠ key2 , f(key1) = f(key2)
一般來說,哈希衝突是無法避免的,
如果要完全避免的話,那麼就只能一個字典對應一個值的地址,也就是一個字就有一個索引 (安 和 按就是兩個索引),
這樣一來,空間就會增大,甚至內存溢出。
需要想盡辦法,減少 哈希衝突(hash碰撞)爲啥呢?Hash碰撞的概率就越小,map的存取效率就會越高
哈希衝突的解決辦法
常見的哈希衝突解決辦法有兩種:
- 開放地址法
- 鏈地址法。
一、開放地址法
開發地址法的做法是,當衝突發生時,使用某種探測算法在散列表中尋找下一個空的散列地址,只要散列表足夠大,空的散列地址總能找到。
按照探測序列的方法,一般將開放地址法區分爲線性探查法、二次探查法、雙重散列法等。
這裏爲了更好的展示三種方法的效果,我們用以一個模爲8的哈希表爲例,採用除留餘數法,
往表中插入三個關鍵字分別爲26,35,36的記錄,分別除8取模後,在表中的位置如下:
這個時候插入42,那麼正常應該在地址爲2的位置裏,但因爲關鍵字30已經佔據了位置,
所以就需要解決這個地址衝突的情況,接下來就介紹三種探測方法的原理,並展示效果圖。
1) 線性探查法:
fi=(f(key)+i) % m ,0 ≤ i ≤ m-1
探查時從地址 d 開始,首先探查 T[d],然後依次探查 T[d+1],…,直到 T[m-1],此後又循環到 T[0],T[1],…,直到探查到有空餘的地址或者到 T[d-1]爲止。
插入42時,探查到地址2的位置已經被佔據,接着下一個地址3,地址4,直到空位置的地址5,所以39應放入地址爲5的位置。
缺點:需要不斷處理衝突,無論是存入還是査找效率都會大大降低。
2) 二次探查法
fi=(f(key)+di) % m,0 ≤ i ≤ m-1
探查時從地址 d 開始,首先探查 T[d],然後依次探查 T[d+di],di 爲增量序列12,-12,22,-22,……,q2,-q2 且q≤1/2 (m-1) ,直到探查到 有空餘地址或者到 T[d-1]爲止。
缺點:無法探查到整個散列空間。
所以插入42時,探查到地址2被佔據,就會探查T[2+1^2]也就是地址3的位置,被佔據後接着探查到地址7,然後插入。
3) 雙哈希函數探測法
fi=(f(key)+i*g(key)) % m (i=1,2,……,m-1)
其中,f(key) 和 g(key) 是兩個不同的哈希函數,m爲哈希表的長度
步驟:
雙哈希函數探測法,先用第一個函數 f(key) 對關鍵碼計算哈希地址,一旦產生地址衝突,再用第二個函數 g(key) 確定移動的步長因子,最後通過步長因子序列由探測函數尋找空的哈希地址。
比如,f(key)=a 時產生地址衝突,就計算g(key)=b,則探測的地址序列爲 f1=(a+b) mod m,f2=(a+2b) mod m,……,fm-1=(a+(m-1)b) % m,假設 b 爲 3,那麼關鍵字42應放在 “5” 的位置。
開發地址法的問題:
開發地址法,通過持續的探測,最終找到空的位置。
上面的例子中,開發地址方雖然解決了問題,但是26和42,佔據了一個數組同一個元素,42只能向下,此時再來一個取餘爲2 的值呢,只能向下繼續尋找,同理,每一個來的值都只能向下尋找。
爲了解決這個問題,引入了鏈地址法。
二、鏈地址法:
在哈希表每一個單元中設置鏈表,某個數據項對的關鍵字還是像通常一樣映射到哈希表的單元中,而數據項本身插入到單元的鏈表中。
鏈地址法簡單理解如下:
來一個相同的數據,就將它插入到單元對應的鏈表中,在來一個相同的,繼續給鏈表中插入。
鏈地址法解決哈希衝突的例子如下:
(1)採用除留餘數法構造哈希函數,而 衝突解決的方法爲 鏈地址法。
(2)具體的關鍵字列表爲(19,14,23,01,68,20,84,27,55,11,10,79),則哈希函數爲H(key)=key MOD 13。則採用除留餘數法和鏈地址法後得到的預想結果應該爲:
(3)哈希造表完成後,進行查找時,首先是根據哈希函數找到關鍵字的位置鏈,然後在該鏈中進行搜索,如果存在和關鍵字值相同的值,則查找成功,否則若到鏈表尾部仍未找到,則該關鍵字不存在。
哈希表性能
哈希表的特性決定了其高效的性能,大多數情況下查找元素的時間複雜度可以達到O(1), 時間主要花在計算hash值上,
然而也有一些極端的情況,最壞的就是hash值全都映射在同一個地址上,這樣哈希表就會退化成鏈表,例如下面的圖片:
當hash表變成圖2的情況時,查找元素的時間複雜度會變爲O(n),效率瞬間低下,
所以,設計一個好的哈希表尤其重要,如HashMap在jdk1.8後引入的紅黑樹結構就很好的解決了這種情況。
HashMap的類結構
類繼承關係
Java爲數據結構中的映射定義了一個接口java.util.Map,此接口主要有四個常用的實現類,分別是HashMap、Hashtable、LinkedHashMap和TreeMap,
類繼承關係如下圖所示:
下面針對各個實現類的特點做一些說明:
(1) HashMap:
它根據鍵的hashCode值存儲數據,大多數情況下可以直接定位到它的值,因而具有很快的訪問速度,但遍歷順序卻是不確定的。
HashMap 最多隻允許一條記錄的鍵爲null,允許多條記錄的值爲null。
HashMap非線程安全,即任一時刻可以有多個線程同時寫HashMap,可能會導致數據的不一致。
如果需要滿足線程安全,可以用:
-
Collections的synchronizedMap方法使HashMap具有線程安全的能力,
-
或者使用ConcurrentHashMap。
(2) Hashtable:
Hashtable是遺留類,很多映射的常用功能與HashMap類似,不同的是它承自Dictionary類,並且是線程安全的。
這個是老古董,Hashtable不建議在代碼中使用,
不需要線程安全的場合可以用HashMap替換,需要線程安全的場合可以用ConcurrentHashMap替換。
爲何不建議用呢?
任一時間只有一個線程能寫Hashtable,併發性不如ConcurrentHashMap。後者使用了 分段保護機制,也就是 分而治之的思想。
(3) LinkedHashMap:
LinkedHashMap是HashMap的一個子類,其優點在於: 保存了記錄的插入順序,
在用Iterator遍歷LinkedHashMap時,先得到的記錄肯定是先插入的,也可以在構造時帶參數,按照訪問次序排序。
(4) TreeMap:
TreeMap實現SortedMap接口,能夠把它保存的記錄根據鍵排序,默認是按鍵值的升序排序,也可以指定排序的比較器,
當用Iterator遍歷TreeMap時,得到的記錄是排過序的。
如果使用排序的映射,建議使用TreeMap。
在使用TreeMap時,key必須實現Comparable接口, 或者在構造TreeMap傳入自定義的Comparator,
否則會在運行時拋出java.lang.ClassCastException類型的異常。
注意:
對於上述四種Map類型的類,要求映射中的key是不可變的。
在創建內部的Entry後, key的哈希值不會被改變。
爲啥呢?
如果對象的哈希值發生變化,Map對象很可能就定位不到映射的位置了。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //key的哈希值不會被改變
final K key; // 映射中的key是不可變的
V value;
Node<K,V> next;
HashMap存儲結構
通過上面的比較,我們知道了HashMap是Java的Map家族中一個普通成員,鑑於它可以滿足大多數場景的使用條件,所以是使用頻度最高的一個。
下文我們主要結合源碼,從存儲結構、常用方法分析、擴容以及安全性等方面深入講解HashMap的工作原理。
HashMap的重要屬性:table 桶數組
從HashMap的源碼中,我們可以發現,HashMap有一個非常重要的屬性 —— table,
這是由一個Node類型的元素構成的數組:
transient Node<K,V>[] table;
table 也叫 哈希數組, 哈希槽位 數組 ,table 桶數組 , 散列表, 數組中的一個 元素,常常被稱之爲 一個 槽位 slot
Node類作爲HashMap中的一個內部類,每個 Node 包含了一個 key-value 鍵值對。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
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 int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
..........
}
Node 類作爲 HashMap 中的一個內部類,除了 key、value 兩個屬性外,還定義了一個next 指針。
next 指針的作用:鏈地址法解決哈希衝突。
當有哈希衝突時,HashMap 會用之前數組當中相同哈希值對應存儲的 Node 對象,通過指針指向新增的相同哈希值的 Node 對象的引用。
JDK1.8的table結構圖
從結構實現來講,HashMap是數組+鏈表+紅黑樹(JDK1.8增加了紅黑樹部分)實現的,如下如所示。
問題:
HashMap的有什麼特點呢?
HashMap的有什麼特點
(1)HashMap採用了鏈地址法解決衝突
HashMap就是使用哈希表來存儲的。
Node是HashMap的一個內部類,實現了Map.Entry接口,本質是就是一個映射(鍵值對)。
上圖中的每個黑色圓點就是一個Node對象。
Java中HashMap採用了鏈地址法。鏈地址法,簡單來說,就是 數組加鏈表 的結合。
在每個數組元素上都一個鏈表結構, 當數據被Hash後,首先得到數組下標,然後 , 把數據放在對應下標元素的鏈表上。
例如程序執行下面代碼:
map.put("keyA","value1");
map.put("keyB","value2");
對於 第一句, 系統將調用"keyA"的hashCode()方法得到其hashCode ,然後再通過Hash算法來定位該鍵值對的存儲位置,然後將 構造 entry 後加入到 存儲位置 指向 的 鏈表中
對於 第一句, 系統將調用"keyB"的hashCode()方法得到其hashCode ,然後再通過Hash算法來定位該鍵值對的存儲位置,然後將 構造 entry 後加入到 存儲位置 指向 的鏈表中
有時兩個key會定位到相同的位置,表示發生了Hash碰撞。
Hash算法計算結果越分散均勻,Hash碰撞的概率就越小,map的存取效率就會越高。
(2)HashMap有較好的Hash算法和擴容機制
哈希桶數組的大小, 在空間成本和時間成本之間權衡,時間和空間 之間進行 權衡:
-
如果哈希桶數組很大,即使較差的Hash算法也會比較分散, 空間換時間
-
如果哈希桶數組數組很小,即使好的Hash算法也會出現較多碰撞, 時間換空間
所以, 就需要在空間成本和時間成本之間權衡,
其實就是在根據實際情況確定哈希桶數組的大小,並在此基礎上設計好的hash算法減少Hash碰撞。
那麼通過什麼方式來控制map使得Hash碰撞的概率又小,哈希桶數組(Node[] table)佔用空間又少呢?
答案就是好的Hash算法和擴容機制。
HashMap的重要屬性:加載因子(loadFactor)和邊界值(threshold)
HashMap還有兩個重要的屬性:
-
加載因子(loadFactor)
-
邊界值(threshold)。
在初始化 HashMap時,就會涉及到這兩個關鍵初始化參數。
loadFactor和threshold的源碼如下:
int threshold; // 所能容納的key-value對極限
final float loadFactor; // 負載因子
Node[] table的初始化長度length(默認值是16),
loadFactor 爲負載因子(默認值是0.75),
threshold是HashMap所能容納的最大數據量的Node 個數。
threshold 、length 、loadFactor 三者之間的關係:
threshold = length * Load factor。
默認情況下 threshold = 16 * 0.75 =12。
threshold就是允許的哈希數組 最大元素數目,超過這個數目就重新resize(擴容),擴容後的哈希數組 容量length 是之前容量length 的兩倍。
threshold是通過初始容量和LoadFactor計算所得,在初始HashMap不設置參數的情況下,默認邊界值爲12。
如果HashMap中Node的數量超過邊界值,HashMap就會調用resize()方法重新分配table數組。
這將會導致HashMap的數組複製,遷移到另一塊內存中去,從而影響HashMap的效率。
HashMap的重要屬性:loadFactor 屬性
爲什麼loadFactor 默認是0.75這個值呢?
loadFactor 也是可以調整的,默認是0.75,但是,如果loadFactor 負載因子越大,在數組定義好 length 長度之後,所能容納的鍵值對個數越多。
LoadFactor屬性是用來間接設置Entry數組(哈希表)的內存空間大小,在初始HashMap不設置參數的情況下,默認LoadFactor值爲0.75。
爲什麼loadFactor 默認是0.75這個值呢?
這是由於 加載因子的兩面性導致的
加載因子越大,對空間的利用就越充分,碰撞的機會越高,這就意味着鏈表的長度越長,查找效率也就越低。
因爲對於使用鏈表法的哈希表來說,查找一個元素的平均時間是O(1+n),這裏的n指的是遍 歷鏈表的長度,
如果設置的加載因子太小,那麼哈希表的數據將過於稀疏,對空間造成嚴重浪費。
當然,加載因子小,碰撞的機會越低, 查找的效率就搞,性能就越好。
默認的負載因子0.75是對空間和時間效率的一個平衡選擇,建議大家不要修改,除非在時間和空間比較特殊的情況下。
分爲兩種情況:
- 如果內存空間很多而又對時間效率要求很高,可以降低負載因子Load factor的值;
- 相反,如果內存空間緊張而對時間效率要求不高,可以增加負載因子loadFactor的值,這個值可以大於1。
HashMap的重要屬性:size屬性
size這個字段其實很好理解,就是HashMap中實際存在的鍵值對數量。
注意: size和table的長度length的區別,length是 哈希桶數組table的長度
在HashMap中,哈希桶數組table的長度length大小必須爲2的n次方,這是一定是一個合數,這是一種反常規的設計.
常規的設計是把桶數組的大小設計爲素數。相對來說素數導致衝突的概率要小於合數,
比如,Hashtable初始化桶大小爲11,就是桶大小設計爲素數的應用(Hashtable擴容後不能保證還是素數)。
HashMap採用這種非常規設計,主要是爲了方便擴容。
而 HashMap爲了減少衝突,採用另外的方法規避:計算哈希桶索引位置時,哈希值的高位參與運算。
HashMap的重要屬性:modCount屬性
我們能夠發現,在集合類的源碼裏,像HashMap、TreeMap、ArrayList、LinkedList等都有modCount屬性,字面意思就是修改次數,
首先看一下源碼裏對此屬性的註釋
HashMap部分源碼:
/**
* The number of times this HashMap has been structurally modified
* Structural modifications are those that change the number of mappings in
* the HashMap or otherwise modify its internal structure (e.g.,
* rehash). This field is used to make iterators on Collection-views of
* the HashMap fail-fast. (See ConcurrentModificationException).
*/
transient int modCount;
漢譯:
此哈希表已被結構性修改的次數,結構性修改是指哈希表的內部結構被修改,比如桶數組被修改或者拉鍊被修改。
那些更改桶數組或者拉鍊的操作如,重新哈希。 此字段用於HashMap集合迭代器的快速失敗。
所以,modCount主要是爲了防止在迭代過程中某些原因改變了原集合,導致出現不可預料的情況,從而拋出併發修改異常,
這可能也與Fail-Fast機制有關: 在可能出現錯誤的情況下提前拋出異常終止操作。
HashMap的remove方法源碼(部分截取):
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
++modCount; //進行了modCount自增操作
--size;
afterNodeRemoval(node);
return node;
remove方法則進行了modCount自增操作,
然後來看一下HashMap的put方法源碼(部分截取):
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount; //對於之前不存在的key進行put的時候,對modCount有修改
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
對於已經存在的key進行put修改value的時候,對modCount沒有修改,
對於之前不存在的key進行put的時候,對modCount有修改,
通過比較put方法和remove方法可以看出,所以只有當對HashMap元素個數產生影響的時候纔會修改modCount。
也是是說:modCount表示 HashMap集合的元素個數,導致集合的結構發生變化。
那麼修改modCount有什麼用呢?
這裏用HashMap舉例,大家知道當用迭代器遍歷HashMap的時候,調用HashMap.remove方法時,
會產併發修改的異常ConcurrentModificationException
這是因爲remove改變了HashMap集合的元素個數,導致集合的結構發生變化。
public static void main(String args[]) {
Map<String, String> map = new HashMap<>();
map.put("1", "zhangsan");
map.put("2", "lisi");
map.put("3", "wangwu");
Iterator<String> iterator = map.keySet().iterator();
while(iterator.hasNext()) {
String name = iterator.next();
map.remove("1");
}
}
執行結果: 拋出ConcurrentModificationException異常
Exception in thread "main" java.util.ConcurrentModificationException
at java.util.HashMap$HashIterator.nextNode(HashMap.java:1442)
at java.util.HashMap$KeyIterator.next(HashMap.java:1466)
at com.cesec.springboot.system.service.Test.main(Test.java:14)
我們看一下拋出異常的KeyIterator.next()方法源碼:
final class KeyIterator extends HashIterator
implements Iterator<K> {
public final K next() { return nextNode().key; }
}
final Node<K,V> nextNode() {
Node<K,V>[] t;
Node<K,V> e = next;
if (modCount != expectedModCount) //判斷modCount和expectedModCount是否一致
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
if ((next = (current = e).next) == null && (t = table) != null) {
do {} while (index < t.length && (next = t[index++]) == null);
}
return e;
}
在迭代器初始化時,會賦值expectedModCount,
在迭代過程中判斷modCount和expectedModCount是否一致,如果不一致則拋出異常,
可以看到KeyIterator.next()調用了nextNode()方法,nextNode()方法中進行了modCount與expectedModCount判斷。
這裏更詳細的說明一下,在迭代器初始化時,賦值expectedModCount,
假設與modCount相等,都爲0,在迭代器遍歷HashMap每次調用next方法時都會判斷modCount和expectedModCount是否相等,
當進行remove操作時,modCount自增變爲1,而expectedModCount仍然爲0,再調用next方法時就會拋出異常。
需要通過迭代器的刪除方法進行刪除
所以迭代器遍歷時, 如果想刪除元素, 需要通過迭代器的刪除方法進行刪除, 這樣下一次迭代操作,纔不會拋出 併發修改的異常ConcurrentModificationException
那麼爲什麼通過迭代器刪除就可以呢?
HashIterator的remove方法源碼:
public final void remove() {
Node<K,V> p = current;
if (p == null)
throw new IllegalStateException();
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
current = null;
K key = p.key;
removeNode(hash(key), key, null, false, false);
expectedModCount = modCount;
}
通過迭代器進行remove操作時,會重新賦值expectedModCount。
這樣下一次迭代操作,纔不會拋出 併發修改的異常ConcurrentModificationException
hashmap屬性總結
HashMap通過哈希表數據結構的形式來存儲鍵值對,這種設計的好處就是查詢鍵值對的效率 高。
我們在使用HashMap時,可以結合自己的場景來設置初始容量和加載因子兩個參數。當查詢操 作較爲頻繁時,我們可以適當地減少加載因子;如果對內存利用率要求比較高,我可以適當的增加加載因子。
我們還可以在預知存儲數據量的情況下,提前設置初始容量(初始容量=預知數據量/加載因 子)。這樣做的好處是可以減少resize()操作,提高HashMap的效率。
HashMap還使用了數組+鏈表這兩種數據結構相結合的方式實現了鏈地址法,當有哈希值衝突 時,就可以將衝突的鍵值對鏈成一個鏈表。
但這種方式又存在一個性能問題,如果鏈表過長,查詢數據的時間複雜度就會增加。HashMap 就在Java8中使用了紅黑樹來解決鏈表過長導致的查詢性能下降問題。以下是HashMap的數據結 構圖:
HashMap源碼分析
HashMap構造方法:
HashMap有兩個重要的屬性:加載因子(loadFactor)和邊界值(threshold)。
loadFactor 屬性是用來間接設置 Entry 數組(哈希表)的內存空間大小,在初始 HashMap 不設置參數的情況下,默認 loadFactor 爲0.75。
爲什麼是0.75這個值呢?
這是因爲對於使用鏈表法的哈希表來說,查找一個元素的平均時間是 O(1+n),這裏的 n 指的是遍歷鏈表的長度,
因此加載因子越大,對空間的利用就越充分,這就意味着鏈表的長度越長,查找效率也就越低。
如果設置的加載因子太小,那麼哈希表的數據就過於稀疏,對空間造成嚴重浪費。
有什麼辦法可以來解決因鏈表過長而導致的查詢時間複雜度高的問題呢?
在JDK1.8後就使用了將鏈表轉換爲紅黑樹來解決這個問題。
Entry 數組(哈希槽位數組)的 threshold 閾值 是通過初始容量和 loadFactor計算所得,
在初始 HashMap 不設置參數的情況下,默認邊界值爲12(16*0.75)。
如果我們在初始化時,設置的初始化容量較小,HashMap 中 Node 的數量超過邊界值,HashMap 就會調用 resize() 方法重新分配 table 數組。
這將導致 HashMap 的數組複製,遷移到另一塊內存中去,從而影響 HashMap 的效率。
public HashMap() {//默認初始容量爲16,加載因子爲0.75
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(int initialCapacity) {//指定初始容量爲initialCapacity
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
static final int MAXIMUM_CAPACITY = 1 << 30;//最大容量
//當size到達threshold這個閾值時會擴容,下一次擴容的值,根據capacity * load factor進行計算,
int threshold;
/**由於HashMap的capacity都是2的冪,因此這個方法用於找到大於等於initialCapacity的最小的2的冪(initialCapacity如果就是2的冪,則返回的還是這個數)
* 通過5次無符號移位運算以及或運算得到:
* n第一次右移一位時,相當於將最高位的1右移一位,再和原來的n取或,就將最高位和次高位都變成1,也就是兩個1;
* 第二次右移兩位時,將最高的兩個1向右移了兩位,取或後得到四個1;
* 依次類推,右移16位再取或就能得到32個1;
* 最後通過加一進位得到2^n。
* 比如initialCapacity = 10 ,那就返回16, initialCapacity = 17,那麼就返回32
* 10的二進制是1010,減1就是1001
* 第一次右移取或: 1001 | 0100 = 1101 ;
* 第二次右移取或: 1101 | 0011 = 1111 ;
* 第三次右移取或: 1111 | 0000 = 1111 ;
* 第四次第五次同理
* 最後得到 n = 1111,返回值是 n+1 = 2 ^ 4 = 16 ;
* 讓cap-1再賦值給n的目的是另找到的目標值大於或等於原值。這是爲了防止,cap已經是2的冪。如果cap已經是2的冪,又沒有執行這個減1操作,則執行完後面的幾條無符號右移操作之後,返回的capacity將是這個cap的2倍。
* 例如十進制數值8,二進制爲1000,如果不對它減1而直接操作,將得到答案10000,即16。顯然不是結果。減1後二進制爲111,再進行操作則會得到原來的數值1000,即8。
* 問題:tableSizeFor()最後賦值給threshold,但threshold是根據capacity * load factor進行計算的,這是不是有問題?
* 注意:在構造方法中,並沒有對table這個成員變量進行初始化,table的初始化被推遲到了put方法中,在put方法中會對threshold重新計算。
* 問題:既然put會重新計算threshold,那麼在構造初始化threshold的作用是什麼?
* 答:在put時,會對table進行初始化,如果threshold大於0,會把threshold當作數組的長度進行table的初始化,否則創建的table的長度爲16。
*/
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 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))//加載因子小於等於0或爲NaN拋出異常
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);//邊界值
}
put方法源碼:
當將一個 key-value 對添加到 HashMap 中,
-
首先會根據該 key 的 hashCode() 返回值,再通過 hash() 方法計算出 hash 值,
-
再除留餘數法,取得餘數,這裏通過位運算來完成。 putVal 方法中的 (n-1) & hash 就是 hash值除以n留餘數, n 代表哈希表的長度。餘數 (n-1) & hash 決定該 Node 的存儲位置,哈希表習慣將長度設置爲2的 n 次方,這樣可以恰好保證 (n-1)&hash 計算得出的索引值總是位於 table 數組的索引之內。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
hash計算:
key的hash值高16位不變,低16位與高16位異或,作爲key的最終hash值。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 要點1: h >>> 16,表示無符號右移16位,高位補0,任何數跟0異或都是其本身,因此key的hash值高16位不變。
// 要點2: 異或的運算法則爲:0⊕0=0,1⊕0=1,0⊕1=1,1⊕1=0(同爲0,異爲1)
即取 int 類型的一半,剛好可以將該二進制數對半切開,
利用異或運算(如果兩個數對應的位置相反,則結果爲1,反之爲0),這樣可以避免哈希衝突。
底16位與高16位異或,其目標:
儘量打亂 hashCode 真正參與運算的低16位,減少hash 碰撞。
之所以要無符號右移16位,是跟table的下標有關,位置計算方式是:
(n-1)&hash 計算 Node 的存儲位置
假如n=16,從下圖可以看出:
table的下標僅與hash值的低n位有關,hash值的高位都被與操作置爲0了,只有hash值的低4位參與了運算。
putVal方法源碼
putVal:
而當鏈表長度太長(默認超過 8)時,鏈表就進行轉換紅黑樹的操作。
這裏利用紅黑樹快速增刪改查的特點,提高 HashMap 的性能。
當紅黑樹結點個數少於 6 個的時候,又會將紅黑樹轉化爲鏈表。
因爲在數據量較小的情況下,紅黑樹要維護平衡,比起鏈表來,性能上的優勢並不明顯。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//此時 table 尚未初始化,通過 resize 方法得到初始化的table
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// (n-1)&hash 計算 Node 的存儲位置,如果判斷 Node 不在哈希表中(鏈表的第一個節點位置),新增一個 Node,並加入到哈希表中
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {//hash衝突了
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;//判斷key的條件是key的hash相同和eqauls方法符合,p.key等於插入的key,將p的引用賦給e
else if (p instanceof TreeNode)// p是紅黑樹節點,插入後仍然是紅黑樹節點,所以直接強制轉型p後調用putTreeVal,返回的引用賦給e
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {//鏈表
// 循環,直到鏈表中的某個節點爲null,或者某個節點hash值和給定的hash值一致且key也相同,則停止循環。
for (int binCount = 0; ; ++binCount) {//binCount是一個計數器,來計算當前鏈表的元素個數
if ((e = p.next) == null) {//next爲空,將添加的元素置爲next
p.next = newNode(hash, key, value, null);
//插入成功後,要判斷是否需要轉換爲紅黑樹,因爲插入後鏈表長度+1,而binCount並不包含新節點,所以判斷時要將臨界閥值-1.【鏈表長度達到了閥值TREEIFY_THRESHOLD=8,即鏈表長度達到了7】
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 如果鏈表長度達到了8,且數組長度小於64,那麼就重新散列resize(),如果大於64,則創建紅黑樹,將鏈表轉換爲紅黑樹
treeifyBin(tab, hash);
//結束循環
break;
}
//節點hash值和給定的hash值一致且key也相同,停止循環
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//如果給定的hash值不同或者key不同。將next值賦給p,爲下次循環做鋪墊。即結束當前節點,對下一節點進行判斷
p = e;
}
}
//如果e不是null,該元素存在了(也就是key相等)
if (e != null) { // existing mapping for key
// 取出該元素的值
V oldValue = e.value;
// 如果 onlyIfAbsent 是 true,就不用改變已有的值;如果是false(默認),或者value是null,將新的值替換老的值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//什麼都不做
afterNodeAccess(e);
//返回舊值
return oldValue;
}
}
//修改計數器+1,爲迭代服務
++modCount;
//達到了邊界值,需要擴容
if (++size > threshold)
resize();
//什麼都不做
afterNodeInsertion(evict);
//返回null
return null;
}
get方法源碼:
當 HashMap 只存在數組,而數組中沒有 Node 鏈表時,是 HashMap 查詢數據性能最好的時候。
一旦發生大量的哈希衝突,就會產生 Node 鏈表,這個時候每次查詢元素都可能遍歷 Node 鏈表,從而降低查詢數據的性能。
特別是在鏈表長度過長的情況下,性能明顯下降,使用紅黑樹就很好地解決了這個問題,
紅黑樹使得查詢的平均複雜度降低到了 O(log(n)),鏈表越長,使用紅黑樹替換後的查詢效率提升就越明顯。
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//數組不爲null,數組長度大於0,根據hash計算出來的槽位的元素不爲null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//查找的元素在數組中,返回該元素
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {//查找的元素在鏈表或紅黑樹中
if (first instanceof TreeNode)//元素在紅黑樹中,返回該元素
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {//遍歷鏈表,元素在鏈表中,返回該元素
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
//找不到返回null
return null;
}
remove方法源碼:
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
//數組不爲null,數組長度大於0,要刪除的元素計算的槽位有元素
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
//當前元素在數組中
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
//元素在紅黑樹或鏈表中
else if ((e = p.next) != null) {
if (p instanceof TreeNode)//是樹節點,從樹種查找節點
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
do {
//hash相同,並且key相同,找到節點並結束
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);//遍歷鏈表
}
}
//找到節點了,並且值也相同
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)//是樹節點,從樹中移除
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)//節點在數組中,
tab[index] = node.next;//當前槽位置爲null,node.next爲null
else//節點在鏈表中
p.next = node.next;//將節點刪除
++modCount;//修改計數器+1,爲迭代服務
--size;//數量-1
afterNodeRemoval(node);//什麼都不做
return node;//返回刪除的節點
}
}
return null;
}
containsKey方法:
public boolean containsKey(Object key) {
return getNode(hash(key), key) != null;//查看上面的get的getNode
}
containsValue方法:
public boolean containsValue(Object value) {
Node<K,V>[] tab; V v;
//數組不爲null並且長度大於0
if ((tab = table) != null && size > 0) {
for (int i = 0; i < tab.length; ++i) {//對數組進行遍歷
for (Node<K,V> e = tab[i]; e != null; e = e.next) {
//當前節點的值等價查找的值,返回true
if ((v = e.value) == value ||
(value != null && value.equals(v)))
return true;
}
}
}
return false;//找不到返回false
}
putAll方法:
public void putAll(Map<? extends K, ? extends V> m) {
putMapEntries(m, true);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();//獲得插入整個m的元素數量
if (s > 0) {
if (table == null) { // pre-size,當前map還沒有初始化數組
float ft = ((float)s / loadFactor) + 1.0F;//m的容量
//判斷容量是否大於最大值MAXIMUM_CAPACITY
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
//容量達到了邊界值,比如插入的m的定義容量是16,但當前map的邊界值是12,需要對當前map進行重新計算邊界值
if (t > threshold)
threshold = tableSizeFor(t);//重新計算邊界值
}
else if (s > threshold)//存放的數量達到了邊界值,擴容
resize();
//對m進行遍歷,放到當前map中
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
clear方法:
public void clear() {
Node<K,V>[] tab;
modCount++;//修改計數器+1,爲迭代服務
if ((tab = table) != null && size > 0) {
size = 0;//將數組的元素格式置爲0,然後遍歷數組,將每個槽位的元素置爲null
for (int i = 0; i < tab.length; ++i)
tab[i] = null;
}
}
replace方法:
public boolean replace(K key, V oldValue, V newValue) {
Node<K,V> e; V v;
//根據hash計算得到槽位的節點不爲null,並且節點的值等於舊值
if ((e = getNode(hash(key), key)) != null &&
((v = e.value) == oldValue || (v != null && v.equals(oldValue)))) {
e.value = newValue;//覆蓋舊值
afterNodeAccess(e);
return true;
}
return false;
}
public V replace(K key, V value) {
Node<K,V> e;
//根據hash計算得到槽位的節點不爲null
if ((e = getNode(hash(key), key)) != null) {
V oldValue = e.value;//節點的舊值
e.value = value;//覆蓋舊值
afterNodeAccess(e);
return oldValue;//返回舊值
}
return null;//找不到key對應的節點
}
HashMap要點分析
HashMap允許鍵值對爲null;
HashMap允許鍵值對爲null;
HashTable則不允許,會報空指針異常;
HashMap<String, String> map= new HashMap<>(2);
map.put(null,null);
map.put("1",null);
HashMap是由一個 Node 數組組成的,每個 Node 包含了一個 key-value 鍵值對:
transient Node<K,V>[] table;
Node 類作爲 HashMap 中的一個內部類,除了 key、value 兩個屬性外,還定義了一個next 指針,當有哈希衝突時,
HashMap 會用之前數組當中相同哈希值對應存儲的 Node 對象,通過指針指向新增的相同哈希值的 Node 對象的引用。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
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 int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
..........
}
HashMap 初始容量是16,擴容方式爲2N:
在 JDK1.7 中,HashMap 整個擴容過程就是:
分別取出數組元素,一般該元素是最後一個放入鏈表中的元素,然後遍歷以該元素爲頭的單向鏈表元素,依據每個被遍歷元素的 hash 值計算其在新數組中的下標,然後進行交換。
這樣的擴容方式,會將原來哈希衝突的單向鏈表尾部,變成擴容後單向鏈表的頭部。
而在 JDK1.8 後,HashMap 對擴容操作做了優化。
由於擴容數組的長度是2倍關係,
所以對於假設初始 tableSize=4 要擴容到8來說就是 0100 到 1000 的變化(左移一位就是2倍),
在擴容中只用判斷原來的 hash 值和 oldCap(舊數組容量)按位與操作是 0 或 1 就行:
-
0的話索引不變,
-
1的話索引變成原索引加擴容前數組。
之所以能通過這種“與”運算來重新分配索引,
是因爲 hash 值本來是隨機的,而 hash 按位與上 oldCap 得到的 0 和 1 也是隨機的,
所以擴容的過程就能把之前哈希衝突的元素再隨機分佈到不同的索引中去。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16,默認大小
//元素的位置要麼是在原位置,要麼是在原位置再移動2次冪的位置
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;
}
//進行2倍擴容後的新數組容量小於最大容量和舊數組長度大於等於16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold,重新計算閥值爲原來的2倍
}
//初始化數組
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;
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)//當前節點是在數組上,後面沒有鏈表,重新計算槽位
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)//當前節點是紅黑樹,紅黑樹重定位
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
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;
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) {//不需要移位
loTail.next = null;
newTab[j] = loHead;//原位置
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;//移動到當前hash槽位 + oldCap的位置,即在原位置再移動2次冪的位置
}
}
}
}
}
return newTab;
}
當前節點是數組,後面沒有鏈表,重新計算槽位:位與操作的效率比效率高
定位槽位:e.hash & (newCap - 1)
我們用長度16, 待插入節點的hash值爲21舉例:
(1)取餘: 21 % 16 = 5
(2)位與:
21: 0001 0101
&
15: 0000 1111
5: 0000 0101
遍歷鏈表,對鏈表節點進行移位判斷:(e.hash & oldCap) == 0
比如oldCap=8,hash是3,11,19,27時,
(1)JDK1.8中(e.hash & oldCap)的結果是0,8,0,8,這樣3,19組成新的鏈表,index爲3;而11,27組成新的鏈表,新分配的index爲3+8;
(2)JDK1.7中是(e.hash & newCap-1),newCap是oldCap的兩倍,也就是3,11,19,27對(16-1)與計算,也是0,8,0,8,但由於是使用了單鏈表的頭插入方式,即同一位置上新元素總會被放在鏈表的頭部位置;這樣先放在一個索引上的元素終會被放到Entry鏈的尾部(如果發生了hash衝突的話),這樣index爲3的鏈表是19,3,index爲3+8的鏈表是 27,11。
也就是說1.7中經過resize後數據的順序變成了倒敘,而1.8沒有改變順序。
HashMap總結
HashMap 通過哈希表數據結構的形式存儲鍵值對,這種設計的好處就是查詢鍵值對的效率高;
我們在編碼中可以優化 HashMap的性能,例如重寫 key 的 hashCode 方法,降低哈希衝突,從而減少鏈表的產生,高效利用哈希表,達到提高性能的效果。
我們在使用 HashMap 時,可以結合自己的場景來設置初始容量和加載因子兩個參數。當查詢操作較爲頻繁時,可以適當地減少加載因子;如果對內存利用率要求比較高,可以適當的增加加載因子;
我們可以在預知存儲數據量的情況下,提前設置初始容量(初始容量=預知數據量/加載因子),這樣做的好處是可以減少 resize() 操作,提高 HashMap 的效率;
HashMap 使用了數組+鏈表這兩種數據結構相結合的方式實現了鏈地址法,當有哈希值衝突時,就可以將衝突的鍵值對鏈成一個鏈表。但這種方式存在一個性能問題,如果鏈表過長,查詢數據的時間複雜度就會增加。所以 HashMap 在JDK1.8中使用了紅黑樹來解決鏈表過長導致的查詢性能下降問題。
HashMap的面試題
hash的基本概念就是把任意長度的輸入通過一個hash算法之後,映射成固定長度的輸出
問:hash衝突可以避免麼?
理論上是沒有辦法避免的,就類比“抽屜原理”,
比如說一共有10個蘋果,但是咱一共有9個抽屜,最終一定會有一個抽屜裏的數量是大於1的,
所以hash衝突沒有辦法避免,只能儘量避免。
問:好的hash算法考慮的點,應該是哪些呢?
首先這個hash算法,它一定效率得高,要做到長文本也能高效計算出hash值,
這二點就是hash值不能讓它逆推出原文吧;
兩次輸入,只要有一點不同,它也得保證這個hash值是不同的。
其次,就是儘可能的要分散吧,因爲,在table中slot中slot大部分都處於空閒狀的,要儘可能降低hash衝突。
問:HashMap中存儲數據的結構,長什麼樣啊?
JDK1.7 是 數組 + 鏈表;
JDK1.8是 數組 + 鏈表 + 紅黑樹,每個數據單元都是一個Node結構,Node結構中有key字段、有value字段、還有next字段、還有hash字段。
Node結構next字段就是發生hash衝突的時候,當前桶位中node與衝突的node連成一個鏈表要用的字段。
問:hashmap中的這個散列表數組長度,那初始長度是多少啊?
初始長度默認是16
問:那這個散列表,new HashMap() 的時候就創建了,還是說在什麼時候創建的?
散列表是懶加載機制,
只有第一次put數據的時候,它才創建的
問:默認的負載因子是多少? 並且這個負載因子有啥用?
默認負載因子0.75,就是75%,
負載因子它的作用就是計算擴容閾值用的,
比如使用無參構造方法創建的hashmap對象,它默認情況下擴容閾值就 16*0.75 = 12
問:鏈表它轉化爲這個紅黑樹需在達到什麼條件?
鏈表轉紅黑樹,主要是有兩個指標,其中一個就是鏈表長度達到8,還有一個指標就是當前散列表數組長度它已經達到64。
如果前散列表數組長度它已經達到64,就算slot內部鏈表長度到了8,它也不會鏈轉樹,
它僅僅會發生一次resize,散列表擴容。
問:Node對象hash值與key對象的hashcode() 有什麼關係?
Node對象hash值是key.hashcode二次加工得到的。
加工原則是:
key的hashcode 高16位 ^ 低16位,得到的一個新值。
問:hashCode值爲什麼需要高16位 ^ 低16位
主要爲了優化hash算法,近可能的分散得比較均勻,儘可能的減少 碰撞
因爲hashmap內部散列表,它大多數場景下,它不會特別大。
hashmap內部散列表的長度,也就是說 length - 1 對應的 二進制數,實際有效位很有限,一般都在(低)16位以內,
注意:2的16次方爲 64K
這樣的話,key的hash值高16位就等於完全浪費了,沒起到作用。
所以,node的hash字段才採用了 高16位 異或 低16位 這種方式來增加隨機的概率,近可能的分散得比較均勻,儘可能的減少 碰撞
問:hashmap Put寫數據的具體流程,儘可能的詳細點去說
主要爲4種情況:
前面這個,尋址算法是一樣的,都是根據key的hashcode 經過 高低位 異或 之後的值,然後再 按位與 & (table.length -1),得到一個槽位下標,然後根據這個槽內狀況,狀況不同,情況也不同,大概就是4種狀態,
第一種是slot == null,直接佔用slot就可以了,然後把當前put方法傳進來的key和value包狀成一個Node 對象,放到這個slot中就可以了
第二種是slot != null 並且 它引用的node 還沒有鏈化;需要對比一下,node的key 與當前put 對象的key 是否完全相等;
如果完全相等的話,這個操作就是replace操作,就是替換操作,把那個新的value替換當前slot -> node.value 就可以了;
否則的話,這次put操作就是一個正兒八經的hash衝突了,slot->node 後面追加一個node就可以了,採用尾插法。
第三種就是slot 內的node已經鏈化了;
這種情況和第二種情況處理很相似,首先也是迭代查找node,看看鏈表上的元素的key,與當前傳來的key是不是完全一致。如果一致的話,還是repleace操作,替換當前node.value,否則的話就是我們迭代到鏈表尾節點也沒有匹配到完全一致的node,把put數據包裝成node追加到鏈表尾部;
這塊還沒完,還需要再檢查一下當前鏈表長度,有沒有達到樹化閾值,如果達到閾值的話,就調用一個樹化方法,樹化操作都在這個方法裏完成
第四種就是衝突很嚴重的情況下,就是那個鏈已經轉化成紅黑樹了
問:jdk8 HashMap爲什麼要引入紅黑樹呢?
其實主要就是解決hash衝突導致鏈化嚴重的問題,如果鏈表過長,查找時間複雜度爲O(n),效率變慢。
本身散列表最理想的查詢效率爲O(1),但是鏈化特別嚴重,就會導致查詢退化爲O(n)。
嚴重影響查詢性能了,爲了解決這個問題,JDK1.8它才引入的紅黑樹。紅黑樹其實就是一顆特殊的二叉排序樹,這個時間複雜度是log(N)
問:那爲什麼鏈化之後性能就變低了呀?
因爲鏈表它畢竟不是數組,它從內存角度來看,它沒有連續着。
如果我們要往後查詢的話,要查詢的數據它在鏈表末尾,那隻能從鏈表一個節點一個節點Next跳躍過去,非常耗費性能。
問:再聊聊hashmap的擴容機制吧?你說一下,什麼情況下會觸發這個擴容呢?
在寫數據之後會觸發擴容,可能會觸發擴容。hashmap結構內,我記得有個記錄當前數據量的字段,這個數據量字段達到擴容閾值的話,下一個寫入的對象是在列表纔會觸發擴容
問:擴容後會擴容多大呢?這塊算法是咋樣的呢?
因爲table 數組長度必須是2的次方數嘛,擴容其實,每次都是按照上一次的tableSize位移運算得到的。就是做一次左移1位運算,假設當前tableSize是16的話,16 << 1 == 32
問:這裏爲什麼要採用位移運算呢?咋不直接tableSize乘以2呢?
主要是因爲性能,因爲cpu畢竟它不支持乘法運算,所有乘法運算它最終都是在指令層面轉化爲加法實現的。
效率很低,如果用位運算的話對cpu來說就非常簡潔高效
問:創建新的擴容數組,老數組中的這個數據怎麼遷移呢?
遷移其實就是,每個桶位推進遷移,就是一個桶位一個桶位的處理;
主要還是看當前處理桶位的數據狀態吧
聊聊:HashMap爲什麼從鏈表換成了樹? 爲啥不用AVL樹?
上一節我們在閱讀源碼的時候,發現這樣一句話:
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
當鏈表節點的計數超過TREEIFY_THRESHOLD - 1則將該鏈表樹化,爲什麼要這樣呢?
其實比較一下鏈表和樹的優缺點就能大致明白該優化的目的。
我們假設一條鏈表上有10個節點,在查詢時,最壞情況需要查詢10次,N(10)。
對於樹而言不同的樹複雜度不同,但是對於最基本的二叉樹:
左子樹一定比root小,右子樹一定比root大,
相當於是通過二分法在進行查找,查詢速度絕大部分時候比鏈表要快。
完美的情況下二叉搜索樹 BST
一般人們理解的二叉樹(又叫二叉搜索樹 BST)會出現一個問題,完美的情況下,它是這樣的:
但是也有可能出現這樣一種情況:
樹的節點正好從大到小的插入,此時樹的結構也類似於鏈表結構,這時候的查詢或寫入耗時與鏈表相同。
退化成爲了 鏈表的特殊BST
一顆特殊BST,退化成爲了 鏈表,如下圖:
爲了避免這種特殊的情況發生,引入了平衡二叉樹(AVL)和紅黑樹(red-black tree)。
它們都是通過本身的建樹原則來控制樹的層數和節點位置,因爲rbtree是由AVL演變而來,所以我們從瞭解AVL開始。
從平衡二叉樹到紅黑樹
平衡二叉樹
平衡二叉樹也叫AVL(發明者名字簡寫),也屬於二叉搜索樹的一種,與其不同的是AVL通過機制保證其自身的平衡。
平衡二叉樹的原則有以下幾點:
-
對於根結點而言,它的左子樹任何節點的key一定比其小而右子樹任何節點的key一定比其大;
-
對於AVL樹而言,其中任何子樹仍然是AVL樹;
-
每個節點的左右子節點的高度之差的絕對值最多爲1;
在插入、刪除樹節點的時候,如果破壞了以上的原則,AVL樹會自動進行調整使得以上三條原則仍然成立。
舉個例子,下左圖爲AVL樹最長的2節點與最短的8節點高度差爲1;
當插入一個新的節點後,根據上面第一條原則,它會出現在2節點的左子樹,但這樣一來就違反了原則3。
此時AVL樹會通過節點的旋轉進行調整,AVL調整的過程稱之爲左旋和右旋,
旋轉之前,首先確定旋轉點,
這個旋轉點就是失去平衡這部分樹,在自平衡之後的根節點——pivot,
因爲我們要根據它來進行旋轉。
我們在學習AVL樹的旋轉時,不要將失衡問題擴大到整個樹來看,這樣會擾亂你的思路,
我們只關注失衡子樹的根結點及它的子節點和孫子節點即可。
事實上,AVL樹的旋轉,我們權且叫“AVL旋轉”是有規律可循的,因爲只要聚焦到失衡子樹,那麼場景就是有限的4個:
場景1 左左結構(右旋):
場景2 右右結構(左旋)
場景3 左右結構(左旋+右旋):
場景4 右左結構(右旋+左旋):
可見無論哪種情況的失衡,都可以通過旋轉來調整。
不難看出,旋轉在圖上像是將pivot節點向上提(將它提升爲root節點),而後兩邊的節點會物理的分佈在新root節點的兩邊,
接下來按照二叉樹的要求:
左子樹小於root,右子樹大於root進行調整。
從圖左左結構可以看出,當右旋時原來pivot(7)的右子樹會轉變到用root點(9)的左子樹處;
從圖右右結構可見,當左旋時,原來pivot(18)的左子樹會分佈到原root點(9)的右子樹。
對於左右結構和右左結構無非是經過多次旋轉達到穩定,旋轉的方式並沒有區別,
AVL樹平衡總結
既然AVL樹可以保證二叉樹的平衡,這就意味着它最壞情況的時間複雜度O(logn) 要低於普通二叉樹和鏈表的最壞情況O(n)。
那麼HashMap就直接使用AVL樹來替換鏈表就好了,爲什麼選擇用紅黑樹呢?
我們會發現,由於AVL樹必須保證Max(最大樹高-最小樹高) <= 1所以在插入的時候很容易出現不平衡的情況,一旦這樣,就需要進行旋轉以求達到平衡。
正是由於這種嚴格的平衡條件,導致需要花大量時間在調整上,故AVL樹一般使用場景在於查詢而弱於增加刪除。
紅黑樹繼承了AVL可自平衡的優點,同時在查詢速率和調整耗時中尋找平衡,放寬了樹的平衡條件,在實際應用中,紅黑樹的使用要多得多。
紅黑樹(RBTree)
紅黑樹也是一種自平衡二叉查找樹,它與AVL樹類似,都在添加和刪除的時候通過旋轉操作保持二叉樹的平衡,以求更高效的查詢性能。
與AVL樹相比,紅黑樹犧牲了部分平衡性以換取插入/刪除操作時少量的旋轉操作,整體來說性能要優於AVL樹。
紅黑樹的原則有以下幾點:
-
節點非黑即紅
-
整個樹的根節點一定是黑色
-
葉子節點(包括空葉子節點)一定是黑色
-
每個紅色節點的兩個子節點都爲黑色。(從每個葉子到根的所有路徑上不能有兩個連續的紅色節點)
-
從任一節點到其每個葉子的所有路徑都包含相同數目的黑色節點。
基於上面的原則,我們一般在插入紅黑樹節點的時候,會將這個節點設置爲紅色,原因參照最後一條原則,紅色破壞原則的可能性最小,如果是黑色很可能導致這條支路的黑色節點比其它支路的要多1。
一旦紅黑樹上述原則有不滿足的情況,我們視爲平衡被打破,紅黑樹會通過變色、左旋、右旋的方式恢復平衡。
前文已經詳細解釋過什麼是左旋和右旋,這裏就不贅述;變色這個概念很好理解,就是紅變黑或黑變紅。
紅黑樹的平衡過程
但是我們會好奇,紅黑樹的平衡會不會和上文的AVL樹一樣,也有可以歸納的平衡場景呢?
答案是肯定的:
場景1 第一次插入:
RBTree第一次插入節點時,新節點會是紅色,違背了原則二,直接將顏色變黑即可。
場景2 父節點爲黑色:
當插入時節點爲紅色且父節點爲黑色,滿足RBTree所有原則,已經平衡。
場景3 父節點爲紅色且叔叔節點爲紅色:
父節點叔叔節點都爲紅色
在平衡的過程中,要注意紅黑樹的規定原則。
插入紅節點,不能僅僅將父節點由紅變黑,因爲這樣會增加這條支路的黑節點數,從而違反“從任一節點到其每個葉子的所有路徑都包含相同數目的黑色節點”。
將父節點和叔叔節點都變黑,再將祖父節點由黑變紅,這樣一來,以13爲root的紅黑樹對外黑色節點數沒變,對內各條支路節點數一致。
場景4 父節點爲紅色,叔叔節點爲黑色且新節點爲右子樹:
節點8的父節點爲紅,叔叔節點爲黑,且通過左旋的方式,讓整個情況變成下一個場景:父節點紅色,叔叔節點爲黑色且新節點爲左子樹。
場景5 父節點爲紅色,叔叔節點爲黑色且新節點爲左子樹:
問:紅黑樹寫入操作 ,是如何找到它的父節點的?
說清楚紅黑樹,的節點 TreeNode它就是繼承Node結構,
TreeNode在Node基礎上加了幾個字段,分別指向父節點parent,然後指向左子節點left,還有指向右子節點的right,然後還有表示顏色red/black,這個就是TreeNode的基本結構
紅黑樹的插入操作:
首先是找到一個合適的插入點,就是找到插入節點的父節點,然後這個紅黑樹 它又滿足二叉樹的所有排序特性…(滿足二叉排序樹的所有特性),這個找父節點的操作和二叉樹是完全一致的。
二叉查找樹,左子節點小於當前節點,右子節點大於當前節點,然後每一次向下查找一層就可以排除掉一半的數據,插入效率在log(N)
查找的過程也是分情況的,
第一種情況就是一直向下探測,直到查詢到左子樹或者右子樹爲null,
說明整個樹中,它沒有發現node.key與當前put key 一致的這個TreeNode。此時探測節點就是插入父節點所在了,這就找到了父節點;將當前插入節點插入到父節點的左子樹或者右子樹,,
當然,插入後會破壞平衡,還需要一個紅黑樹的平衡算法。
第二種情況就是根節點向下探測過程中,發現這個TreeNode.key 與當前 put.key 完全一致。這就不需要插入,替換value就可以了,父節點就是當前節點的父節點
紅黑樹那幾個原則,你還記得麼?
- 節點非黑即紅
- 整個樹的根節點一定是黑色
- 葉子節點(包括空葉子節點)一定是黑色
- 每個紅色節點的兩個子節點都爲黑色。(從每個葉子到根的所有路徑上不能有兩個連續的紅色節點)
- 從任一節點到其每個葉子的所有路徑都包含相同數目的黑色節點。
問:紅黑樹的有那些內部操作
變色
把一個紅色的節點變成黑色,或者把一個黑色的節點變成紅色,就是對這個節點的變色
。
左旋
與平衡二叉樹的旋轉操作類似。
問:什麼是AVL左旋和右旋?
加入節點後,左旋和右旋 ,維護AVL平衡性
右旋轉
場景: 插入的元素在不平衡元素的左側的左側
x.right = y
y.left = xxx(原x.right)
對節點y進行向右旋轉操作,返回旋轉後新的根節點x
y x
/ \ / \
x T4 向右旋轉 (y) z y
/ \ - - - - - - - -> / \ / \
z T3 T1 T2 T3 T4
/ \
T1 T2
場景:插入的元素在不平衡元素的右側的右側
// 向左旋轉過程
x.left = y;
y.right =(原x.left )
對節點y進行向左旋轉操作,返回旋轉後新的根節點x
y x
/ \ / \
T1 x 向左旋轉 (y) y z
/ \ - - - - - - - -> / \ / \
T2 z T1 T2 T3 T4
/ \
T3 T4
問:聊下ConcurrentHashMap
首先它的數據結構在JDK1.7 版本底層是個分片數組
爲了保證線程安全它有個Segment分片鎖,這個Segment繼承於ReentrantLock,來保證它的線程安全的,它每次只能一段加速來保證它的併發度。
在JDK1.8版本,它改成了與HashMap一樣的數據結構,
數組 + 單鏈表 或者 紅黑樹的數據結構,
在1.8它逐漸放棄這種Segment分片鎖機制,而使用Synchronized和CAS來操作。
因爲在1.6版本的時候JVM對Synchronized的優化非常大。
現在也是用這種方法保證它的線程安全。
問:說說HashMap底層原理,ConcurrentHashMap與HashMap的區別
HashMap結構及原理
HashMap是基於哈希表的Map接口的非同步實現。實現HashMap對數據的操作,允許有一個null鍵,多個null值。
HashMap底層就是一個數組結構,數組中的每一項又是一個鏈表。數組+鏈表結構,新建一個HashMap的時候,就會初始化一個數組。
Entry就是數組中的元素,每個Entry其實就是一個key-value的鍵值對,它持有一個指向下一個元素的引用,這就構成了鏈表,HashMap底層將key-value當成一個整體來處理,這個整體就是一個Entry對象。
HashMap底層採用一個Entry數組來保存所有的key-value鍵值對,當需要存儲一個Entry對象時,會根據hash算法來決定在其數組中的位置,在根據equals方法決定其在該數組位置上的鏈表中的存儲位置;
當需要取出一個Entry對象時,也會根據hash算法找到其在數組中的存儲位置, 在根據equals方法從該位置上的鏈表中取出Entry;
ConcurrentHashMap與HashMap的區別
1.HashMap**
我們知道HashMap是線程不安全的,在多線程環境下,使用Hashmap進行put操作會引起死循環,導致CPU利用率接近100%,所以在併發情況下不能使用HashMap。
2.HashTable
HashTable和HashMap的實現原理幾乎一樣,差別無非是
HashTable不允許key和value爲null
HashTable是線程安全的
但是HashTable線程安全的策略實現代價卻太大了,簡單粗暴,get/put所有相關操作都是synchronized的,這相當於給整個哈希表加了一把大鎖。
多線程訪問時候,只要有一個線程訪問或操作該對象,那其他線程只能阻塞,相當於將所有的操作串行化,在競爭激烈的併發場景中性能就會非常差。
3.ConcurrentHashMap
主要就是爲了應對hashmap在併發環境下不安全而誕生的,ConcurrentHashMap的設計與實現非常精巧,大量的利用了volatile,final,CAS等lock-free技術來減少鎖競爭對於性能的影響。
我們都知道Map一般都是數組+鏈表結構(JDK1.8該爲數組+紅黑樹)。
ConcurrentHashMap避免了對全局加鎖改成了局部加鎖操作,這樣就極大地提高了併發環境下的操作速度,由於ConcurrentHashMap在JDK1.7和1.8中的實現非常不同,接下來我們談談JDK在1.7和1.8中的區別。
JDK1.7版本的CurrentHashMap的實現原理
1)在JDK1.7中ConcurrentHashMap採用了數組+Segment+分段鎖的方式實現。
1.Segment(分段鎖)
ConcurrentHashMap中的分段鎖稱爲Segment,它即類似於HashMap的結構,即內部擁有一個Entry數組,數組中的每個元素又是一個鏈表,同時又是一個ReentrantLock(Segment繼承了ReentrantLock)。
2.內部結構
ConcurrentHashMap使用分段鎖技術,將數據分成一段一段的存儲,然後給每一段數據配一把鎖,當一個線程佔用鎖訪問其中一個段數據的時候,其他段的數據也能被其他線程訪問,能夠實現真正的併發訪問。如下圖是ConcurrentHashMap的內部結構圖:
從上面的結構我們可以瞭解到,ConcurrentHashMap定位一個元素的過程需要進行兩次Hash操作。
第一次Hash定位到Segment,第二次Hash定位到元素所在的鏈表的頭部。
3.該結構的優劣勢
壞處
這一種結構的帶來的副作用是Hash的過程要比普通的HashMap要長
好處
寫操作的時候可以只對元素所在的Segment進行加鎖即可,不會影響到其他的Segment,這樣,在最理想的情況下,ConcurrentHashMap可以最高同時支持Segment數量大小的寫操作(剛好這些寫操作都非常平均地分佈在所有的Segment上)。
所以,通過這一種結構,ConcurrentHashMap的併發能力可以大大的提高。
JDK1.8版本的CurrentHashMap的實現原理
JDK8中ConcurrentHashMap參考了JDK8 HashMap的實現,採用了數組+鏈表+紅黑樹的實現方式來設計,內部大量採用CAS操作,這裏我簡要介紹下CAS。
CAS是compare and swap的縮寫,即我們所說的比較交換。cas是一種基於鎖的操作,而且是樂觀鎖。
在java中鎖分爲樂觀鎖和悲觀鎖。悲觀鎖是將資源鎖住,等一個之前獲得鎖的線程釋放鎖之後,下一個線程纔可以訪問。
而樂觀鎖採取了一種寬泛的態度,通過某種方式不加鎖來處理資源,比如通過給記錄加version來獲取數據,性能較悲觀鎖有很大的提高。
CAS 操作包含三個操作數 —— 內存位置(V)、預期原值(A)和新值(B)。如果內存地址裏面的值和A的值是一樣的,那麼就將內存裏面的值更新成B。
CAS是通過無限循環來獲取數據的,若果在第一輪循環中,a線程獲取地址裏面的值被b線程修改了,那麼a線程需要自旋,到下次循環纔有可能機會執行。
JDK8中徹底放棄了Segment轉而採用的是Node,其設計思想也不再是JDK1.7中的分段鎖思想。
Node:保存key,value及key的hash值的數據結構。其中value和next都用volatile修飾,保證併發的可見性。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
Java8 ConcurrentHashMap結構基本上和Java8的HashMap一樣,不過保證線程安全性。
在JDK8中ConcurrentHashMap的結構,由於引入了紅黑樹,使得ConcurrentHashMap的實現非常複雜,
我們都知道,紅黑樹是一種性能非常好的二叉查找樹,其查找性能爲O(logN),但是其實現過程也非常複雜,而且可讀性也非常差,
DougLea的思維能力確實不是一般人能比的,早期完全採用鏈表結構時Map的查找時間複雜度爲O(N),
JDK8中ConcurrentHashMap在鏈表的長度大於某個閾值的時候會將鏈表轉換成紅黑樹進一步提高其查找性能。
總結
其實可以看出JDK1.8版本的ConcurrentHashMap的數據結構已經接近HashMap,相對而言,ConcurrentHashMap只是增加了同步的操作來控制併發,從JDK1.7版本的ReentrantLock+Segment+HashEntry,到JDK1.8版本中synchronized+CAS+HashEntry+紅黑樹。
1.數據結構:取消了Segment分段鎖的數據結構,取而代之的是數組+鏈表+紅黑樹的結構。
2.保證線程安全機制:JDK1.7採用segment的分段鎖機制實現線程安全,其中segment繼承自ReentrantLock。JDK1.8採用CAS+Synchronized保證線程安全。3.鎖的粒度:原來是對需要進行數據操作的Segment加鎖,現調整爲對每個數組元素加鎖(Node)。4.鏈表轉化爲紅黑樹:定位結點的hash算法簡化會帶來弊端,Hash衝突加劇,因此在鏈表節點數量大於8時,會將鏈表轉化爲紅黑樹進行存儲。5.查詢時間複雜度:從原來的遍歷鏈表O(n),變成遍歷紅黑樹O(logN)。
參考文獻:
https://blog.csdn.net/longsq602/article/details/114165028
https://www.jianshu.com/p/d7024b52858c