HashMap的組成
首先了解數組和鏈表兩個數據結構
1.數組 尋址容易,插入和刪除元素困難
數組由於是緊湊連續存儲,可以隨機訪問,通過索引快速找到對應元素,而且相對節約存儲空間。
但正因爲連續存儲,內存空間必須一次性分配夠,所以說數組如果要擴容,需要重新分配一塊更大的空間,再把數據全部複製過去,時間複雜度 O(N);
而且你如果想在數組中間進行插入和刪除,每次必須搬移後面的所有數據以保持連續,時間複雜度 O(N)。
2.鏈表 尋址困難,插入和刪除元素容易
鏈表因爲元素不連續,而是靠指針指向下一個元素的位置,所以不存在數組的擴容問題;如果知道某一元素的前驅和後驅,操作指針即可刪除該元素或者插入新元素,時間複雜度O(1)。
但是正因爲存儲空間不連續,你無法根據一個索引算出對應元素的地址,所以不能隨機訪問;而且由於每個元素必須存儲指向前後元素位置的指針,會消耗相對更多的儲存空間。
另外值得驚醒的一句話是:
數據結構的存儲方式只有兩種:數組(順序存儲)和鏈表(鏈式存儲)
Hash表的實現就是結合了數組和鏈表:https://blog.csdn.net/hadues/article/details/105384914
如下圖:左邊是一個數組,每個數組指向一個鏈表
鍵值對插入Map的過程
首先map的key拿到之後,通過Hash函數計算出它的hashCode, 結合數組長度進行無符號右移(>>>)、按位異或、按位與(&)計算出索引,得到數組的Position,繼而找到數組Position所指向的鏈表。
如果兩個key的HashCode相同,我們會比較equals方法
- 如果equals相同:則將後添加的value覆蓋之前的value
- 如果equals不同:則產生了Hash衝突,會劃出一個節點存儲數據,鏈接到鏈表後面
JDK1.8以後,如果數組長度大於64,並且鏈表長度大於8,則鏈表會進化成紅黑樹
HashMap集合的成員變量
1.集合的初始化容量(必須是2的n次冪)
/** * The default initial capacity - MUST be a power of two. */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
計算hashCode在數組中的哪個位置,實際上就是取餘,hash&(length-1)計算機中直接求餘運算不如位移運算。
參考:高效取餘運算 https://www.cnblogs.com/gne-hwz/p/10060260.html
如果數組長度不是2的n次冪,計算出的索引特別容易相同,及其容易發生hash碰撞
數組長度爲9的時候 3&(9-1)=0 2&(9-1)=0 發生了hash碰撞
數組長度爲8的時候 3&(8-1)=3 2&(8-1)=2 沒發生哈希碰撞
2.默認的負載因子,默認是0.75
/** * The load factor used when none specified in constructor. */ static final float DEFAULT_LOAD_FACTOR = 0.75f;
Map擴容的時,並不是把集合存滿在擴容,集合數量達到加載因子*數組長度(默認16*0.75=12),纔會擴容
負載因子是0.75的時候,空間利用率比較高,而且避免了相當多的Hash衝突,使得底層的鏈表或者是紅黑樹的高度比較低,提升了空間效率。
3.當鏈表長度超過8時,會轉變成紅黑樹
/** * The bin count threshold for using a tree rather than list for a * bin. Bins are converted to trees when adding an element to a * bin with at least this many nodes. The value must be greater * than 2 and should be at least 8 to mesh with assumptions in * tree removal about conversion back to plain bins upon * shrinkage. */ static final int TREEIFY_THRESHOLD = 8;
紅黑樹的查詢效率雖然比鏈表高,但是佔用空間是鏈表的兩倍,之所以臨界值是8 是根據數學的泊松分佈概率 鏈表長度超過8的概率非常小
這是時間和空間的一個取捨
HashMap擴容機制
JDK1.7擴容時,會伴隨一次重新的hash分配,並且會遍歷Hash表中的所有元素,是非常耗時的。
JDK1.8擴容時,因爲每次擴容都是翻倍,與原來計算的(n-1)&hash的結果相比,只是多了一個bit位,
所以節點要麼就在原來的位置(e.hash&oldCap結果是0),要麼就被分配到 原位置+舊容量 這個位置(e.hash&oldCap結果不等於0)
https://blog.csdn.net/zlp1992/article/details/104376309
JDK1.7 鏈表採用頭插的方式 擴容時,在多線程的情況下可能出現循環鏈表
JDK1.8 鏈表採用的是尾插(不在倒序處理)
ConCurrentHashMap
JDK7:ConcurrentHashMap採用了分段鎖的,把容器默認分成16段,put值的時候 只是鎖定16斷中的一個部分,就是把鎖給細化了
JDK8:採用的CAS自旋
JDK1.7 對於ConCurrentHashMap的size統計,當經過了兩次計算(3次對比)之後,發現每次統計時Hash都有結構性的變化
這時它就會氣急敗壞的把所有Segment都加上鎖;而當自己統計完成後,纔會把鎖釋放掉,再允許其他線程修改哈希中的個數
JDK1.8 對於ConCurrentHashMap的size統計,JDK1.8藉助了baseCount和counterCells兩個屬性,並配合多次CAS的方法,避免的鎖的使用
/** * Base counter value, used mainly when there is no contention, * but also as a fallback during table initialization * races. Updated via CAS. */ private transient volatile long baseCount;/** * Table of counter cells. When non-null, size is a power of 2. */ private transient volatile CounterCell[] counterCells;
過程
1.當併發量較小的時,優先使用CAS的方式直接更新baseCount
2.當更新baseCount衝突,則會認爲進入到比較激烈的競爭狀態,通過啓用counterCells減少競爭,通過CAS的方式把總數更新情況記錄在counterCells對應的位置上
3.如果更新counterCells上的某個位置出現了多次失敗,則會通過擴容counterCells的方式減少衝突
4.當counterCells在擴容期間,會嘗試更新baseCount的值
對於元素總數的統計,邏輯就非常簡單了,只需要讓baseCount加上各counterCells內的數據,就可以得出哈希內的總數,整個過程完全不需要藉助鎖。
疑問:HashMap有線程安全的ConcurrentHashMap 但是TreeMap爲什麼沒有ConcurrentTreeMap
因爲CAS操作用在紅黑樹實現起來太複雜
所以用ConcurrentSkipListMap用CAS實現排序(跳錶代替Tree)
跳錶:在鏈表的基礎上一層一層的加一些個關鍵元素的鏈表,加了個索引。跳錶的查找效率比鏈表本身要高,同時它的CAS實現難度比TreeMap容易很多