jdk-HashMap-1.8
由於jdk版本的升級導致原始碼的更新,因此hashmap的原始碼需要重新讀一下,不過在本文記錄時jdk的版本早就不是8版本了,只不過是1.7和1.8發生了本質的變化,因此才記錄一下的。至於9,10版本,暫時不管了。
為了重新去讀1.8版本的hashmap原始碼,特此做了些前期準備:
1.總述
關於之前學習的1.7版本,我著重學習了幾個點,建構函式(容量大小,載入因子),put(),get(),擴容機制,擴容時機,hashcode的產生,hash衝突,執行緒安全問題,當然還有最重要的底層結構(陣列+連結串列)。可以說關於hashmap,可學習和可關注的點太多太多。因此,文章不可能所有的都能涉及到,只能儘可能的去學習和理解。
那麼在學習之前呢,我已經瞭解到底層結構的變成了陣列+連結串列+紅黑樹。so,著重關注下紅黑樹部分應該說就能將1.8的原始碼拿下了。
1.原始碼分析
建構函式部分:
//建構函式1
public HashMap(int initialCapacity, float loadFactor) {
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;
this.threshold = tableSizeFor(initialCapacity); //1
}
//建構函式2
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//建構函式3
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
//建構函式4
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
建構函式3:常用建構函式,一般不指定大小,建構函式3只是指定了loadFactory(載入因子)的值,其他的值都沒賦值。應該是後續由初始化的操作。建構函式2和建構函式1:內部呼叫建構函式1指定一些常用值的初始值,這和1.7一致,不同點在於threshold的值的確定。
threshold(閾值)的確定
在1.7內,通過建構函式的操作就確定了,如下:
例如,建構函式是new HashMap(7),那麼capacity就是8,而threshold就是8*0.75 = 6。
//設定capacity為大於initialCapacity且是2的冪的最小值
while (capacity < initialCapacity)
capacity <<= 1;
this.loadFactor = loadFactor;
threshold = (int)(capacity * loadFactor);
而在1.8內,確不是如此,而是經歷過兩次操作,但是本質是還是一致的。首先建構函式內得到一個threshold的值,例如構造韓式是new HashMap(7),那麼此處的threshold的值就是8。
this.threshold = tableSizeFor(initialCapacity);
但是在擴容resize()函式內,還存在一部分額外的初始化動作,threshold的值也在其內,最終threshold的值依然是8*0.75=6;
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
可以說tableSizeFor()函式的作用其實可以認為是capacity的獲取的作用(得到大於initialCapacity且是2的冪的最小值)。
tableSizeFor()
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
此處 n |= n >>>1 等價於 n = n | n>>>1; >>>是無符號右移動。
例如 new HashMap(53);圖解如下:
put()
先拋開紅黑樹邏輯不看,put的邏輯和之前還是有點區別的,不過本質也還是沒變,主要還是採用不同的理念將元素插入到連結串列中。 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
//初始化陣列table,初始化操作延遲到有新資料插入時並且合併到擴容邏輯內
n = (tab = resize()).length;//返回table桶的大小,預設還是16
if ((p = tab[i = (n - 1) & hash]) == null)
//定位key的hash值和桶的大小進行按位與操作,確定在桶內位置
//如果沒有發生衝突,構造新的Node節點,進行插入
tab[i] = newNode(hash, key, value, null);
else {
//存在衝突
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//當前key和桶內的第一個Node的key相等,則指向它
e = p;
else if (p instanceof TreeNode)
//如果桶內元素是紅黑樹,則進行紅黑樹邏輯
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//此邏輯段均是在當期key和第一個key不相等的時候迴圈的
//遍歷找到的當前桶內元素,並記錄當前元素個數
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//如果不存在相同的key,則將元素連線到此連結串列後面
p.next = newNode(hash, key, value, null);
//如果數量超過閾值,則轉成紅黑樹
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果遍歷的時候找到某個key和當前key相等,則跳出迴圈
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//指標後移
p = e;
}
}
//此段就是覆蓋當前值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//記錄結構變更
++modCount;
if (++size > threshold)//超過閾值,擴容
resize();
afterNodeInsertion(evict); //暫時不清楚
return null;
}
轉成紅黑樹的條件()
條件1:如果當前桶內的連結串列長度大於等於8個時,進入轉變流程。 if (binCount >= TREEIFY_THRESHOLD - 1)
條件2:當table的長度超過64時,才會將這一部分連結串列結構轉成紅黑樹,不然依然是擴容。 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
treeifyBin()
轉成紅黑樹的程式碼也是比較重點的一個部分,在文章的開頭,關於紅黑樹的插入,刪除和理論知識已經給出,不熟悉的可以先去練練手。
final void treeifyBin(HashMap.Node<K,V>[] tab, int hash) {
int n, index; HashMap.Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
//桶的長度小於64,只擴容,不轉紅黑樹
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
//hd頭結點,tl尾節點
HashMap.TreeNode<K,V> hd = null, tl = null;
do {
//先轉成樹型節點
HashMap.TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);//將連結串列結構轉成樹型節點連結串列結構
if ((tab[index] = hd) != null)
hd.treeify(tab); //轉成紅黑樹
}
}
上面的put原始碼中已經分析過轉成紅黑樹的兩個條件了,連結串列長度>=8以及桶的大小超過64時才會轉。
個人猜測原因:桶的容量在比較小時,hash衝突會比較高,擴容會非常頻繁,如果此時就轉成紅黑樹,那麼優先擴容的話會減小不必要的樹化過程,另一個減小擴容時的紅黑樹的重新對映的複雜度。
treeify()
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null;
for (TreeNode<K,V> x = this, next; x != null; x = next) {
next = (TreeNode<K,V>)x.next;
x.left = x.right = null;
if (root == null) {
x.parent = null;
x.red = false;
root = x;
}
else {
K k = x.key;
int h = x.hash;
Class<?> kc = null;
//遍歷根節點,執行插入x節點操作,然後進行紅黑樹的修正操作
for (TreeNode<K,V> p = root;;) {
int dir, ph;
K pk = p.key;
//比較hash值,確定是左節點還是右節點
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
dir = tieBreakOrder(k, pk); //hash值不能確定的,執行tieBreakOrder再次確認大小
TreeNode<K,V> xp = p;
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
if (dir <= 0)
xp.left = x;
else
xp.right = x;
//修正操作
root = balanceInsertion(root, x);
break;
}
}
}
}
moveRootToFront(tab, root);
}
HashMap在設計之初可以發現,鍵物件可以是任意物件,因此可能自定義的鍵物件沒有實現comparable介面,因此如何比較鍵物件的大小就變得複雜的多。
所以在比較鍵物件大小時,1.8的程式碼中採取了3個步驟:
1. 比較hashcode的大小;
2. 檢測鍵物件是否實現了comparable介面,如果實現了則呼叫compareTo比較;
3. 都沒法比較則進行tieBreakOrder(class物件層面和system層面)比較;
balanceInsertion()
修正操作,關於修正操作我們去分析一下,場景的話我們借鑑文章最上面紅黑樹的理論分析來進行。基本和演算法導論裡的虛擬碼是一致的,看起來不費力。 static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
TreeNode<K,V> x) {
//待插入節點是紅色的
x.red = true;
//xp=x.parent 待插入節點的父節點
//xpp=xp.parent 待插入節點的祖父節點
//xppl=xpp.left 待插入節點的祖父節點的左孩子節點
//xppr=xpp.left 待插入節點的祖父節點的右孩子節點
for (TreeNode<K,V> xp, xpp, xppl, xppr;;) {
if ((xp = x.parent) == null) {
//待插入節點就是根節點,設定為黑色就行
x.red = false;
return x;
}
else if (!xp.red || (xpp = xp.parent) == null)
//如果待插入節點的父節點是根節點或者父節點是黑色,結束
return root;
if (xp == (xppl = xpp.left)) {
//如果待插入節點的父節點是紅色且是祖父節點的左孩子
if ((xppr = xpp.right) != null && xppr.red) {
//待插入節點的父節點是紅色(總條件) 且 叔叔節點是紅色
xppr.red = false; //設定叔叔節點是黑色
xp.red = false; //設定父節點是黑色
xpp.red = true; //設定祖父節點為紅色
x = xpp; //設定祖父節點為當前節點,進行下一次修正
}
else {
if (x == xp.right) {
//待插入節點的父節點是紅色(總條件) 且 叔叔節點是黑色 且 待插入節點是父節點的右孩
root = rotateLeft(root, x = xp); //設定父節點為當前節點進行左旋
xpp = (xp = x.parent) == null ? null : xp.parent; //設定新的祖父節點
}
if (xp != null) {
//設定父節點為黑色
xp.red = false;
if (xpp != null) {
//設定祖父節點為紅色
xpp.red = true;
//以祖父節點為支點進行右旋
root = rotateRight(root, xpp);
}
}
}
}
else {
//映象操作,全部相反,left變更為right,right變更為left
if (xppl != null && xppl.red) {
xppl.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
}
else {
if (x == xp.left) {
root = rotateRight(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}
if (xp != null) {
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateLeft(root, xpp);
}
}
}
}
}
}
插入整體流程圖
樹化簡易流程圖
樹化前
樹化後(樹化之前根據程式碼可知,是先轉成的樹型雙向連結串列,因此prev和next關係就保留下來了),這也是和1.7不同之處,1.7內只有單連結串列的結構。此處保留prev和next的關鍵因素我覺得應該是和後續如果再次轉成連結串列有關。
get()
get的主要流程其實和1.7沒什麼區別,在1.8的程式碼中,桶內第一個元素的重要性被提升了,主要還是因為紅黑樹的存在。 public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
//比較桶內第一個元素的key是否相等,相等則直接返回
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
//不相等的時候判斷是否是紅黑樹節點,進入紅黑樹流程
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
//否則迴圈找到key相等的Node節點
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
resize()
最後一個關注點就是擴容,1.7的擴容針對元素就是重新rehash定位在新的桶裡面的位置。而1.8的程式碼發生了一些思路上的改變。
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; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
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;
//以上和1.7一致,確定新桶大小和閾值大小等等常規引數的設定
@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) {
oldTab[j] = null;
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
//如果都不是,那就還是單連結串列結構
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
//遍歷資料,進行分組,分組的條件是看該元素的位置在新陣列內是否發生改變
if ((e.hash & oldCap) == 0) {
//位置不變
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;
newTab[j] = loHead;
}
if (hiTail != null) {
//位置改變的需要計算(原下標位置+原陣列長度)
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
針對單連結串列的擴容
也就是上述程式碼進行do while迴圈的地方,此處的思路和1.7發生了一些改變。
1.8的思想是針對這一條單連結串列做一下歸類的操作,把位置沒有發生改變的歸成一類,位置發生改變的歸成另一類。具體是怎麼操作的呢?我們列舉一些簡單的例子一看便知:
例如我們現在有下面的一個基礎hashmap結構,大小是16;閾值是12=16*0.75;桶內位置=15 & key;整體過程如下圖:
小結一下:
這樣看來1.8裡,元素之間的相對位置並沒有發生改變,由於是分組的關係,所以最終只要將head節點接到新桶內即可。
但是1.7裡,如果單連結串列中的元素在新桶內具有相同的位置話,元素會倒置。
針對紅黑樹的擴容
//紅黑樹整體思路和單連結串列思路一致,也是先分組,然後判斷是否需要轉化
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
// Relink into lo and hi lists, preserving order
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
for (TreeNode<K,V> e = b, next; e != null; e = next) {
//之前程式碼可知,在單鏈錶轉成紅黑樹之前保留了next和prev指標,因此可以通過這種方式遍歷
next = (TreeNode<K,V>)e.next;
e.next = null;
if ((e.hash & bit) == 0) {
//位置不變的分成一組
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
}
else {
//位置改變的分成一組
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}
if (loHead != null) {
if (lc <= UNTREEIFY_THRESHOLD)
//如果位置不變的元素個數小於6個,則轉成單連結串列
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
if (hiHead != null) // (else is already treeified)
//如果hiHead不為null,表明有元素從紅黑樹中移除,結構發生改變了,需要修正
loHead.treeify(tab);
}
}
//下面同理
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
//如果個數小於6個,則轉成單連結串列
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
紅黑樹的擴容部分和單連結串列方式一致,但是在此間還存在了紅黑樹向單連結串列的轉化,判斷個數是6。程式碼如下:就不做圖了。
final Node<K,V> untreeify(HashMap<K,V> map) {
Node<K,V> hd = null, tl = null;
for (Node<K,V> q = this; q != null; q = q.next) {
//從TreeNode向Node節點轉變,然後連線成單連結串列
Node<K,V> p = map.replacementNode(q, null);
if (tl == null)
hd = p;
else
tl.next = p;
tl = p;
}
return hd;
}
1.7和1.8擴容問題的比較
1.7:
問題:連結串列的死迴圈(由於執行緒A操作了執行緒B擴容之後的正常的table陣列導致死迴圈)。
現象:同一個位置的元素如果擴容後還是相同的位置,會出現倒置的現象,當然這不是問題,只是演算法導致的。
1.8:
問題:不會出現連結串列的死迴圈(不針對紅黑樹的場景,只討論單連結串列),可能造成資料丟失。
現象:元素之間的相對位置不會發生改變。
程式碼的不同
//1.7
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i]; //針對每個元素的next指標連線到新的位置的後續元素之前
newTable[i] = e; //針對每一個元素都連線到新的位置上
e = next;
}
}
}
//1.8
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
//將位置上的元素賦值給e,然後針對每一個Node節點置成null
oldTab[j] = null;
//...省略中間無關程式碼
Node<K, V> loHead = null, loTail = null;
Node<K, V> hiHead = null, hiTail = null;
Node<K, V> next;
//迴圈分組
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
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;
newTab[j] = loHead; //位置不變的一組
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead; //位置改變的一組
}
}
}
}
比較程式碼就能發現,1.8內由於棧變數e儲存了此連結串列中的資料然後進行分組的關係,所以不可能出現死迴圈了,唯一的問題就是oldTab[j] = null;這個操作導致了元素被清空,也就是null的問題。所以在多執行緒下容易出現元素丟失。
總結:
1.8HashMap的正篇就到此為止吧,還有很多細節都沒涉及到,就留給以後補充吧,一下子也沒法方方面面的顧全到,一開始以為這一篇幅應該花不了多長時間,結果花了3天時間才整理了這麼點東西。主要沒有想到的是作者的處理思路發生了質的變化了。