HashMap源碼解析和設計解讀
HashMap源碼解析
想要理解HashMap底層數據的存儲形式,底層原理,最好的形式就是讀它的源碼,但是說實話,源碼的注釋說明全是英文,英文不是非常好的朋友讀起來真的非常吃力,我基本上看了差不多七八遍,還結合網上的一些解析,才覺得自己有點理解。
我先畫了一個圖,HashMap數據存儲的結構圖,先有個理解,再來看看下面的程式碼解析可能會好理解些。
HashMap的數據結構
HashMap靜態屬性
/**
* The default initial capacity - MUST be a power of two.
* 默認的數組容量16
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* 最大的容量
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* The load factor used when none specified in constructor.
* 負載因子,用於擴容,當數組的容量大於等於 0.75*DEFAULT_INITIAL_CAPACITY時,就要擴容
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 每個桶中數據結構轉變為樹的鏈表長度界限,當鏈表長度為為8時,轉成紅黑樹
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 當樹的結點等於小於等於6時,又轉會鏈表
*/
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
存儲的對象
/**
* Basic hash bin node, used for most entries. (See below for
* TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
*/
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;
}
……省略
}
這就是HashMap數據存儲的形式,以一個Node節點對象存儲。而且還是靜態的。
普通屬性
/**
* 這就是HashMap存儲數據的本質容器,數組,只不過是Node數組,裡面存儲的是
* Node節點
*/
transient Node<K,V>[] table;
/**
* Holds cached entrySet(). Note that AbstractMap fields are used
* for keySet() and values().
*/
transient Set<Map.Entry<K,V>> entrySet;
/**
* The number of key-value mappings contained in this map.
*/
transient int size;
/**
* 這是用來標記HashMap結構變化的次數的
*/
transient int modCount;
如何存放數據
構造器和其他的一些方法就先不看了,重點了解下put方法底層樹如何來存放數據的
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
一層套一層,接著看putVal方法,這裡有一個hash(key),是將每個key生成一個hash值,用處是用來尋找存儲的位置
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 這 hashCode是一個本地方法,不管,那為什麼又要和無符號右移16位做異或運算呢?
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// tab是用來操作存儲容器的,p是存儲的節點,n是數組的長度,i是數組位置下標
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
// 數組容器初始化resize(),這個方法後面重點看,就是重新設置容量大小
n = (tab = resize()).length;
// 如果找到的這個位置的Node節點為null,就直接new一個,將put的數據存放進來
// (table.length-1)&hash 是HashMap中確定數組存放位置的方式
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// 進到這裡,就說明出現了hash碰撞,生成的hash值一樣了,找到的位置已經有值了,p不為null
// e是一個臨時節點變數
Node<K,V> e; K k;
// 如果hash值一樣,key也一樣,那就是覆蓋嘛,直接吧p給e,到下面或進行value的新舊替換
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果key不一樣,判斷是否是樹節點了,就用樹的增加節點方法,這裡我們先不研究
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);
// 如果已經大於等於7了,就轉成紅黑樹
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;
}
}
// 到這裡就是key相同的時候,新舊值替換
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);// 這個是LinkedHashMap才會用到,HashMap不用管
return oldValue;
}
}
++modCount; // 修改記錄,這個只有當數組新增了值才會到這裡,想上面只增加再同一個桶中的鏈表後,不會加一
if (++size > threshold)
resize(); // 擴容
afterNodeInsertion(evict);
return null;
}
到這裡可以再翻到上面去看下存儲數據結構的圖,可能更好理解
putVal方法整體概括下邏輯應該分為以下幾點:
- 首先先根據key得到hashCode值,確認存放的數組位置
- 該位置如果沒有值,就直接new一個Node節點存放進去
- 該位置有值,又分為兩種情況
- 如果key相同,則就是替換嘛,把這個key所對應的value給換成新的
- 如果key不相同,則就是hash衝突,就需要增加該桶的鏈表長度了,將該數據增加到該鏈表的後邊。
注意:這裡沒有討論變成紅黑樹,臨界值8的情況
擴容機制
final Node<K,V>[] resize() {
// 定義一個Node[]
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; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
// 這裡是最開是初始化的時候,默認容量是16,默認的閾值時0.75*16=12
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;
……#############這下面才是擴容機制中重新確定每個Node節點所在的位置的精髓所在,單獨講#################
}
為什麼每次擴容設置成前一次的2倍?
我們再putVal方法中看到,HashMap確定數組存儲的下標是這樣確定的:(table.length-1) & hash,與運算是二進位的運算,都是1則為1,否則為0。
因為table的容量總是2的倍數,所以換成二進位時全部都是1後面都是0,比如16,二進位就是10000,32就是100000,那麼減一之後它的二進位就都會是1,比如15的二進位是1111,31的二進位11111,這樣再與hash值做與運算的時候離散性會更好,降低hash碰撞
為什麼hash計算需要右移16位做異或運算
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 首先需要確定的一點是,用hash值是為了快速檢索定位,但是需要好的hash演算法去減少hash衝突,提高離散性。以這點作為基礎,猜想上面這樣演算法是因為降低hash衝突,提高離散性。
(table.length-1) & hash
還是再看確定bin下表的方式,table的長度和hash值做與運算,在實際中table的長度並不會特別大,2的16次方都比較少,更何況是32位,所以如果直接用hashCode()
方法生成的hash值做運算,其實大概率只用到了後16位,前十六位就浪費了。所以(h = key.hashCode()) ^ (h >>> 16)
先用右移16位做異或運算,其實就是後16位和前16位做異或運算,這樣在確定下標中hash的32位都參與了運算,這樣既就增加了隨機性,提高了離散性。- 為什麼是做”異或”運算呢?因為相比於「與」運算和「或」運算,「異或」運算生成0和1的概率是相等的,沒有偏重哪一個。
@SuppressWarnings({"rawtypes","unchecked"})
// 確定完容量後,new一個新的數組,將新的數組賦給table這個數組
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 這裡就是來確定老的table中每個桶中每個Node在新的table中的位置是否要移動,怎麼移動
// 這裡如果不明白為什麼需要在新表中重新確定位置的,看下面的圖解可能好理解
for (int j = 0; j < oldCap; ++j) {
// 臨時節點e
Node<K,V> e;
// 取出第j個位置的節點給e
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 這說明在這個原來的j位置上不存在hash碰撞,就直接放到新table中相同的位置就行
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
// 進到這個else就說明在原來j位置存在hash碰撞,形成鏈表了,e.next不為null
// 低位節點,loHead地位節點的head,loTail地位節點的尾部
Node<K,V> loHead = null, loTail = null;
// 高位節點
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 循環遍歷這個桶的鏈表的每個節點,重新弄確定位置
do {
next = e.next;
// 這個條件的&運算非常巧妙,如果為0,就說明這個節點不需要移動位置,在新table中也是j這個位置的桶中
// 這裡這個&運算的結果不為0就為1,下面詳細介紹
if ((e.hash & oldCap) == 0) {
// 這裡是理清節點的關係,相當於這裡是這個桶中所有不需要移位的Node,又要形成一個新的鏈表
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;
// 這裡就是低位節點,不需要移位,在新的table中還是j位置
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
// 高位節點在新的table中的位置就變成 (原來的位置+原來的容量)
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
高位低位節點說明:
上面的程式碼中 e.hash & oldCap 這個結果為什麼不是0就是1呢?
首先要明白為什麼多個Node會存到同意的桶中,也就是在尋找數組位置的時候找到了同一個。
確定桶位置的計算方式是:(table.length-1) & hash,因為table.length-1的二進位全是1,在和hash作&運算時如果位數不夠,就在前面補0,這時(table.length-1)的二進位中後面的1視為低位,前面的0就是高位,所以這個運算,高位算出來全是0,所以主要看低位。
現在看 e.hash & oldCap,oldCap因為時2的倍數,所以二進位都是1000……的形式,1視為高位,那麼所以這個運算中,e.hash的低位不用管,高位可能是1也可能是0,所以運算的結果只能是0或1
那麼為什麼e.hash高位時1就要移位,是0就不需要呢?
現在已經清楚確定桶位置的計算方式是:(table.length-1) & hash,例如原本容量是16,那麼(table.length-1) 二進位就是1111,hash是11111,則在原來的位置是 01111 & 11111 = 15,但是現在擴容之後為32了,現在(table.length-1)二進位為11111,那現在的位置就是11111 & 11111 = 31,改變的位置=原來的位置+原來的容量。
其實原因就是因為節點Node的hash沒有變化,可是容量變了,所以如果節點Node的高位為1就是計算出與之前不一樣的值,確定的位置當然要發生變化。
原來的數據存儲
擴容後的數據存儲
非執行緒安全
jdk1.8中的HashMap執行緒不安全主要是在多執行緒並發的時候出現數據覆蓋的情況,在putVal方法中
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// tab是用來操作存儲容器的,p是存儲的節點,n是數組的長度,i是數組位置下標
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
// 數組容器初始化resize(),這個方法後面重點看,就是重新設置容量大小
n = (tab = resize()).length;
// 如果找到的這個位置的Node節點為null,就直接new一個,將put的數據存放進來
// (table.length-1)&hash 是HashMap中確定數組存放位置的方式
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
看上面的程式碼,if ((p = tab[i = (n - 1) & hash]) == null)
,如果找定位到數組的位置節點為空的話,就直接new一個節點存數據,當多執行緒時候,出現hash衝突,都定位到同一個位置,當一個A執行緒進來這個if,還沒來得及存數據,另一個B執行緒進來搶先存了數據,可是A再去存數據的時候,已不會判斷是否有值了,就直接覆蓋了,所以就將B執行緒的數據覆蓋了。