HashMap 1.8
一、HashMap的結構:陣列+連結串列
1、那麼陣列在哪裡?有多大?
我們來到HashMap的原始碼,可以發現它裡面有個陣列 transient Node<K,V>[] table;
陣列的初始大小為16,static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
陣列最大為2^30,static final int MAXIMUM_CAPACITY = 1 << 30;
2、陣列已經明確了,那麼連結串列呢?它的每個節點應該是怎麼樣的?
我們先自己想一想,應該有個key,有個value,有個next。再來看看原始碼中的實現,通過上面的陣列,我們可以知道數組裡面的每一個元素都是Node<K,V>,我們點進去,看看它的實現。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
//...
}
key,value,next都有了,很符合我們的預期。那麼這個hash是用來幹嘛的?是用來定位的,我要插入一個Node的話,它應該在陣列的哪一個位置。
二、HashMap的插入
我們先想一想,把一個節點插入HashMap中,需要考慮些什麼呢?
1)既然是陣列+連結串列的結構,那麼我插入的時候,陣列有沒有初始化呢?
2)定位。我這個節點應該放在陣列的哪個位置上?如果這個位置上已經有元素了,那麼跟在後面形成連結串列?
3)連結串列太長了,插入和查詢的效率都很低,怎麼辦?
1、我們找到它的插入方法,put
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
我們先忽略hash(key)方法,先看putVal()方法
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) n = (tab = resize()).length; if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); ... }
我們看到n = (tab = resize()).length這一行,繼續探索resize()方法。此方法中有很多if-else,此次我們只看陣列初始化時,即table == null時。因此resize()方法可以精簡如下:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
newCap = DEFAULT_INITIAL_CAPACITY; //初始容量
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); //陣列使用了多少,才開始擴容
threshold = newThr;//threshold是例項變數,用來記錄擴容閾值
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; //初始化陣列
table = newTab;
return newTab;
}
可以看到,正是在resize()方法裡面,初始化了陣列。解決了我們第一個問題,陣列什麼時候初始化。
2、resize()看了,我們再回到put方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
再來看看hash(key)這個方法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
我們來解釋一下 (h = key.hashCode()) ^ (h >>> 16) 這句話
key.hashCode() 呼叫的是Object的hash演算法,返回一個32位的數字。此時h有32位
h >>> 16,h向右移動16位
(h = key.hashCode()) ^ (h >>> 16) 將它們進行異或操作。作用是充分讓h的每一位都參與進來,讓Node節點儘可能地定位在陣列的不同位置上
這句話的作用還是不理解?沒關係,我們下面講如何定位,還會提到(h = key.hashCode()) ^ (h >>> 16)的作用
3、我們回到putVal()方法的定位操作上
如果讓我們自己想的話,我們可以會用 hash % (n-1) 來定位,而 %操作,並沒有&操作來得高效。那讓我們來解釋一下,這個&操作吧
此時hash值,是我們上面看到的(h = key.hashCode()) ^ (h >>> 16),是個32位的數
n - 1 是15 (由於我們是第一次初始化,所以取的是初始預設容量,n=16)
可以看到hash的值只有最後的幾位參與了運算,那多個不同的hash,只要最後幾位相同,他們的位置不就重複了嗎?陣列的空間就不能充分利用了。
這也就是,為什麼我們之前進行(h = key.hashCode()) ^ (h >>> 16)操作的原因了,讓它的每一位都參與進來,讓他們儘可能地定位在陣列的不同位置上。
為了維持n-1的值,是1111, 11111, 111111這種形式,HashMap的容量規定是2的倍數
4、 如何定位我們已經知曉了,那這個位置上已經存在元素的話,hashMap將會做什麼操作呢?
當陣列的這個位置上已經存在元素的時候,它會在後面形成連結串列。當連結串列太長的時候,它會轉化成紅黑樹。
static final int TREEIFY_THRESHOLD = 8; 這就是轉化成紅黑樹的閾值,當連結串列的節點數量達到8的時候,進行轉化
static final int UNTREEIFY_THRESHOLD = 6; 當紅黑樹的節點數量達到6的時候,又轉回連結串列
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
Node<K,V> e; K k;
//1、陣列上,如果hash值相等,key也相等,把這個位置的舊元素記下來,方便下面的新值取代舊值
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
// 2、如果是紅黑樹結構,那麼作為樹的節點,進行插入
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 3、如果是連結串列結構,那麼跟在連結串列的末尾
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1)
// 如果超過樹化閾值,那麼將連結串列轉成樹
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
//連結串列上,如果hash值相等,key也相等,把這個位置的舊元素記下來,方便下面的新值取代舊值
break;
p = e;
}
}
if (e != null) { // 是否hash相等,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;
}
5、我們已經知曉,當hash碰撞的時候,hashMap會形成連結串列或紅黑樹。那我們再來看看resize()方法,這次我們關注它的擴容操作,具體操作如下:
0)容量變為原來的兩倍
1)新陣列的建立
2)遍歷原來的陣列,將存在的元素,移到新的陣列
2.1)如果是單個元素的話,那麼hash值 & 新容量-1,定位到新陣列的位置上
2.2)如果是紅黑樹的話,那麼打散節點
2.3)如果是連結串列的話,根據hash值的不同,把連結串列拆成兩個連結串列
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
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//1、新容量陣列,建立
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
//2、遍歷原來的陣列,將存在的元素移位到新陣列
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//如果陣列的這個位置上有元素,那麼進行移位操作
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
//3、如果只有單個元素,不是連結串列和紅黑樹,那麼定位(hash值 & 新容量-1)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
//4、如果是紅黑樹的話,那麼打散節點
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { //5、是連結串列的話,根據hash值的不同,把連結串列拆成兩個連結串列
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;
}
6、待續
1)紅黑樹的結構
2)連結串列如何轉紅黑樹
3)紅黑樹如何轉連結串列
4)擴容的時候,紅黑樹如何打散