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執行緒的資料覆蓋了。