0x00 前言
HashMap 是最常用的容器之一,應該沒什麼疑問了。可你到底瞭解他嗎?網上已經有很多文章來總結 HashMap 了,我來寫這篇,主要是為了記錄自己閱讀之後的一點點小感悟,如若有錯誤的地方,請大家指正。下文分析基於 jdk1.8
。
0x01 一句話介紹
HashMap 內部是一個 Node 類陣列,每個節點存放對應的資料。
0x02 概述
先來介紹下 HashMap ,主要依據來自 HashMap 的註釋(熟悉的同學可以直接跳過到0x03
部分)。
1、HashMap 實現了 Map 介面,擁有 Map 的所有操作,具有以下特點:
- 允許
null
的 key 和 value 。 - 大致上,他和 HashTable 相同,但是 HashTable 是執行緒安全的,而 HashMap 非執行緒安全。出於此原因,HashMap 在效能上明顯會優於 HashTable 。
- 不保證順序,原因在於其內在的原理,他是根據 key 的 hash 值來計算位置的,所以,順序自然是無法保證的了。(到底怎麼算的,往下看。)
2、HashMap 的 get , put 在hash值比較均勻的情況下,操作都是常數級別的時間複雜度。一個非常重要的點是,capacity 不能設定太高,load factor 不能設定的太低。(這兩個變數又是幹嘛的呢,這裡先賣個關子✧(≖ ◡ ≖✿)嘿嘿)。
3、因為他不是執行緒安全的,所以可以通過 Collections.synchronizedMap 來包裝,從而變成一個執行緒安全的 Map。
4、擁有 fail-fast 特性。簡單來說,就是在遍歷的時候,發現元素被改變,就丟擲異常。
0x03 解釋幾個變數
建構函式裡面的 initialCapacity
這個引數的意思比較明顯,就是初始的 Map 長度。預設是 16。
Node<K,V>[] table
Map 中真正存放元素的地方,可以看到他是一個 Node 陣列。Node 結構比較簡單,就是一個 key-value 組成的一個連結串列,其中還有 hash變數,和 next 變數。
float loadFactor
顧名思義,負載因子。預設值是0.75,是一個空間和時間上的權衡。具體怎麼來的,可能是一個複雜的邏輯推算。
int threshold
閾值,Map 所能容納的鍵值對數量。是根據 Map 中的陣列長度*loadFactor計算出來的。看到這個,應該就可以想到,如果 loadFactor設定的太小,會有什麼問題了。沒錯,如果設定太小,容量就會很小,導致空間上的一個浪費,大部分的位置都是空的,沒有被充分利用。反之,如果設定太大,就會導致元素放置非常擁擠,查詢起來效率就會變低。
0x04 方法分析
建構函式
HashMap 有好幾個建構函式,來看一個比較重要的吧。
public HashMap(int initialCapacity, float loadFactor) {
// 如果傳遞進來的初始化陣列的大小小於0,就是不合法,直接拋異常。
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;
// 根據 tableSizeFor 方法進行陣列長度的對齊。
this.threshold = tableSizeFor(initialCapacity);
}
// 陣列長度的對齊。
static final int tableSizeFor(int cap) {
int n = cap - 1;
// 經過以下的變化,陣列的長度一定是2^n了。
n |= n >>> 1; // 1
n |= n >>> 2; // 2
n |= n >>> 4; // 3
n |= n >>> 8; // 4
n |= n >>> 16; // 5
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
複製程式碼
在這裡給方法tableSizeFor
舉個?:
如果我設定cap為13,則13-1的二進位制是:
0000 0000 0000 1100
此時進行第一步:
0000 0000 0000 1100
>>>1 0000 0000 0000 0110
|= 0000 0000 0000 1110
第二步:
0000 0000 0000 1110
>>>2 0000 0000 0000 0011
|= 0000 0000 0000 1111
第三步:
0000 0000 0000 1111
>>>4 0000 0000 0000 0000
|= 0000 0000 0000 1111
……
可以看出,後面應該全是 1111(2)了。最後加個1,就是16,2^4.
有的同學可能不信,所以再舉個更大的?:
0100 0110 0101 0110
此時進行第一步:
0100 0110 0101 0110
>>>1 0010 0011 0010 1011
|= 0110 0111 0111 1111
第二步:
0110 0111 0111 1111
>>>2 0001 1001 1101 1111
|= 0111 1111 1111 1111
第三步:
0111 1111 1111 1111
>>>4 0000 0111 1111 1111
|= 0111 1111 1111 1111
……
可以看到,最終結果還是一樣。二進位制有很多好玩的特性,如果能利用好,效能上的提升絕對不止一點半點。
複製程式碼
Node節點類
包含一個 hash,key, value。
put
public V put(K key, V value) {
// 實際呼叫 putVal 方法。此時可能有個疑問,key他自己不是有hashcode方法嗎?為什麼還要自己寫一個?暫且按下,先看看 putVal 方法。 ①
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 如果 tab 還沒有被初始化,或者是空,則先進行 resize .
if ((tab = table) == null || (n = tab.length) == 0)
// n 就是tab的長度。 ③
n = (tab = resize()).length;
// 這裡是重點,怎麼定位? (n-1)&hash 來定位的。 ②
if ((p = tab[i = (n - 1) & hash]) == null)
// 如果是null,則建立一個新的節點。
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 如果舊節點就是需要被put的節點,則將值直接進行替換。
// 可以看到他是根據 == 判斷是否是同一個物件,或者 equals 方法來判斷是否相等。
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果是 紅黑樹,則呼叫紅黑樹的putTreeVal。
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 如果是連結串列,則一個一個往下尋找,
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
// 如果一直找到結尾,都沒找到,則建立一個新節點
p.next = newNode(hash, key, value, null);
// 如果連結串列的長度,已經大於了需要轉成紅黑樹的長度,則將其轉為紅黑樹
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 = e;
}
}
// 如果找到了對應的節點,進行一個替換
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
// 如果標誌檢查符合,或者原值為null,則進行賦值。
e.value = value;
// 節點可用完成通知
afterNodeAccess(e);
return oldValue;
}
}
// 修改變更標誌加一
++modCount;
// 如果總的節點數量,大於了閾值,則進行擴容
if (++size > threshold)
resize();
// 插入完畢通知
afterNodeInsertion(evict);
return null;
}
複製程式碼
講講②處,為什麼這樣定位呢? (n - 1) & hash 。n 是 table 的長度,是一個 2^n 的數字。一般情況下,如果有一個大於陣列長度的位置,我們怎麼來將其放入陣列中呢?很簡單,取模。對這個位置取模,得到的值肯定都是小於陣列長度的。劃重點!所以這個 (n - 1) & hash 也是取模!我們都知道,n-1 的二進位制,都是高位0 + 低位多個1組成的。此時和 hash 值相與,與出來的值,肯定是小於 n-1 的,這就達到了一個取模的效果。
空說無憑,還是舉個?。
假設 n = 16, n-1的二進位制即為 1111 。再隨便寫個32位的hash。
0000 0000 0000 1111
& 1001 1101 1110 0110
= 0000 0000 0000 0110 = 6 < 16
複製程式碼
方法非常巧妙,避免了取模,大大提升了索引的速度。
此時引出了①的原因。先看看hash的尊容。
// hash 方法。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
複製程式碼
來說說這個 hash 方法。可以看到如果是 null,則返回的是0.非null,則呼叫了key#hashCode方法,並且異或了hash值右移16位。分析②的時候,可以看到定位是根據hash值和n-1相與來確定位置的,這就是為什麼要重新一個hash的原因。哈?為什麼?根據②的?,可以看出來,定位和hash值的前幾位都沒有關係,只和 n-1 的二進位制長度的位數有關。這就帶來一個問題,非常容易產生衝突,隨機性被降低了,畢竟高xx位都沒有參與運算,就那麼幾位,肯定容易產生衝突。異或這個操作,將高位也拉了進來,大大提高了參與度,hash雜湊也會更好。還是舉一個?。
1110 0010 0011 1101 1110 0000 0011 1101
>>16 0000 0000 1110 0010 0000 0000 1110 0000
^= 1110 0010 1101 1111 1110 0000 1101 1101
可以看到,如果不進行這個操作,這兩個元素肯定是在一個定位上的,如果加上高位操作,則被分散了。
複製程式碼
resize
resize是用來對map進行擴容的方法。對應上面的註釋③。
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;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 閾值翻倍。
}
else if (oldThr > 0)
// 如果閾值大於0&舊的陣列長度小於1,則將新陣列長度設定為閾值的大小。
newCap = oldThr;
else { // 上述條件都不符合,則使用初始值。。
newCap = DEFAULT_INITIAL_CAPACITY;
// 新的閾值是預設負載因子*預設陣列長度的值。
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 如果新閾值為0
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) {
// e 已經引用了就資料,所以將陣列對應位置清空,利於垃圾回收。
oldTab[j] = null;
if (e.next == null) // ① 如果連結串列只有一個元素,則將其放入新陣列對應的位置,計算方法已經說過。
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode) //② 如果是一顆紅黑樹,則利用 split 方法來進行拆樹。
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // 如果還是連結串列。 ③
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
// next 是 e 的下一個元素
next = e.next;
if ((e.hash & oldCap) == 0) { // e 的 hash 值和舊的陣列長度相與為0 ③-1
if (loTail == null) // 如果低位尾部是null,則低位頭是 e.
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) { // 將低位連結串列放置到 j 的位置上。
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) { // 將高位組成的連結串列放置到 j + oldCap 位置上。
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
複製程式碼
上面一部分都很好理解。重點看看①-③這幾個地方。先畫個圖吧。比如一個 map 的的長度是4,裡面如此放置元素(為了舉例,假設可以放置這麼多):
[0] -> (0) //第①種,元素0的後面沒有任何元素了,所以直接進行放置
[1] -> ( 1
5 25
9 17 21 29
13 33 ) // 這就屬於②了,連結串列已經被轉化為一顆紅黑樹了。所以需要將樹給拆掉。
[2] -> (2 -> 6 -> 10 -> 14) // 這就屬於第③種情況了,需要將此連結串列拆開,放置到新的陣列當中。
[3] -> (3)
複製程式碼
在此著重講講第三種情況。還是上圖。
[0] [0]
[1] [1]
[2]-> (2 -> 6 -> 10 -> 14) -----> [2] -> (2 -> 10)
[3] [3]
[4]
[5]
[6] -> (6 -> 14)
[7]
[8]
此處看看怎麼遷移。先看看他們和舊長度4相與是否為0。
2 6 10 14
二進位制 0010 0110 1010 1110
& 0100 0100 0100 0100
= 0000 0100 0000 0100
列出4和8的二進位制數:
4 8
二進位制 0100 1000
n-1 0011 0111
複製程式碼
可以看出一些規律,長度的二進位制總是隻有一個1,其餘位都是0。而位置計算是 hash&n-1,可以發現,新的位置不過是hash&2n-1,用二進位制來看,就是左移了一位補1.所以和原來位置唯一的差別在哪呢,就在這個左移出來的1身上。這就是為什麼③-1中為什麼判斷e.hash & oldCap
。如果長度二進位制為1的那個位置是0的元素,就留在原地,反之,則放置到 j +oldCap
位置。因為擴容是兩倍,所以就是原來的位置加上一個原陣列長度。
get
get 方法和 put 方法非常類似。只不過 get 是 get 返回,put 是 set 值進去。內部呼叫了 getNode 方法。
remove
remove 和 put 方法也非常類似,就是找到對應的元素,進行刪除而已。
containsKey
public boolean containsKey(Object key) {
// 也是呼叫了get方法呼叫的內部方法,判斷返回的值是否為null。所以和 get 方法只是一個用不用返回值的區別。
return getNode(hash(key), key) != null;
}
複製程式碼
size
直接返回了記錄元素個數的 size 變數。
clear
遍歷陣列,挨個進行 null 賦值。
containsValue
遍歷陣列和對應的連結串列,檢視 value 是否相等。
紅黑樹相關的函式
這些函式是java8新增的,如果連結串列過長,一個個遍歷非常影響效率,所以 map 內部將他變成了一顆紅黑樹,此文就不進行詳解了。這部分放到 TreeMap 分析的時候再進行描述。
0x05 喝口水,來個總結
本文講解了 HashMap 中的一部分核心問題,沒有全部都講下來。還有resize執行緒安全問題,紅黑樹相關的部分沒有講解。執行緒安全這個,後面也會單獨來一篇進行講解。紅黑樹則放到 TreeMap 的分析當中。如果文中有誤,請大家指出,感激不盡。