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容易很多