容器類原始碼解析系列(三)—— HashMap 原始碼分析(最新版)
前言
本篇文章是《Java容器類原始碼解析》系列的第三篇文章,主要是對HashMap的原始碼實現進行分析。強烈建議閱讀本文之前,先看看該系列的前兩篇文章:
要點
- HashMap 內部是基於陣列加連結串列結構來實現資料儲存,這句話在jdk1.8版本之後,就不準確了。因為在JDK1.8版本之後,HashMap內部加入了紅黑樹的資料結構來提高資料查詢效率。所以現在應該改為陣列加連結串列(紅黑樹)。
- HashMap支援NULL 鍵(key)、NULL 值(value),HashTable不支援。
- HasMap 是非執行緒安全的,所以在多執行緒併發場景下,需要加鎖來保證同步操作;HashTable是執行緒安全的。
- HashMap具有fai-fast機制的,關於fail-fast機制,我在該系列第一篇文章有講解。容器類原始碼解析系列(一)ArrayList 原始碼分析——基於最新Android9.0原始碼
- HashMap的樹化條件是連結串列深度達到閥值8,同時陣列長度(capacity)要達到64.
準備
先了解一下分析HashMap原始碼,需要知道的一些內容。
DEFAULT_INITIAL_CAPACITY = 1 << 4 預設的capacity(容量)大小
MAXIMUM_CAPACITY = 1 << 30 最大的capacity
DEFAULT_LOAD_FACTOR = 0.75f 預設的載入因子
TREEIFY_THRESHOLD = 8 連結串列樹化閥值(連結串列長度)
UNTREEIFY_THRESHOLD = 6 反樹化,TreeNode->Node
MIN_TREEIFY_CAPACITY = 64 連結串列樹化閥值(capacity)
Node<K,V>[] table 儲存資料的容器
Node<K,V> 類,當資料量不大,沒有達到樹化條件時,HashMap的儲存節點結構。
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;
}
...
...
}
複製程式碼
TreeNode<K,V> 儲存數量較大,滿足樹化條件時,HashMap的儲存節點結構。
static final class TreeNode<K,V> extends LinkedHashMap.LinkedHashMapEntry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
...
...
}
複製程式碼
樹化前:
樹化後:
紅黑樹直接拿的wiki上面的圖,省事!?
圖可能畫的不準確,大概就是這個意思,幫助理解的,don’t care little things!
構造
HashMap提供四種構造方法,可以分為兩類,一類是單純設定capacity和loadFactor這兩個成員變數的,建立一個空的hashmap;一類是傳遞一個Map集合引數,來賦值的。 我們先看第一類構造方法。
/**
* Constructs an empty <tt>HashMap</tt> with the specified initial
* capacity and load factor.
*
* @param initialCapacity the initial capacity
* @param loadFactor the load factor
* @throws IllegalArgumentException if the initial capacity is negative
* or the load factor is nonpositive
*/
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);
}
/**
* Constructs an empty <tt>HashMap</tt> with the specified initial
* capacity and the default load factor (0.75).
*
* @param initialCapacity the initial capacity.
* @throws IllegalArgumentException if the initial capacity is negative.
*/
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
/**
* Constructs an empty <tt>HashMap</tt> with the default initial capacity
* (16) and the default load factor (0.75).
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
複製程式碼
我們主要看第一個構造方法,第二個第三個比較簡單,還有註釋就不提了。在第一個構造方法中,可以看到先是對傳進來的initialCapacity、loadFactor引數進行一個有效性判斷,然後在賦值initialCapacity的時候對其值進行了一個處理,然後賦值給threshold變數,這個threshold是HashMap擴容時的閥值。在table陣列沒有初始化的時候這個threshold表示初始陣列的capacity。 剛說了,對initialCapacity值做了一個處理,我們看看是什麼處理;
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;
}
複製程式碼
上面的處理是對傳進來的引數進行位操作處理,來實現return出去的資料是2的n次方。舉個例子: 傳進的值是11,減一後變成10;10的二進位制表示是1010,進過位操作後,變成1111;1111+1 變成10000 轉成10進位制是16;是2的4次方。 一般來說通過這個方法實際賦的值都是大於等於傳進來,期望的值的。 接著看第二類構造方法:
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
複製程式碼
它傳進來一個Map容器,capacity和loadFactor都是用的預設值,分別是16和0.75f。這裡提一嘴,預設的loadFactor值0.75f是經過測試比較合適的一個平衡點,如果傳入的loadFactor值比較大,雖然可以減少記憶體空間的消耗但是會增加資料查詢的複雜度。因為擴容操作是很耗效能的,所以在構造HashMap時,應該根據自己需要儲存的資料量大小來設定合適的capacity,避免出現擴容操作。
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
if (table == null) { // pre-size
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold)
threshold = tableSizeFor(t);
}
else if (s > threshold)
resize();
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
複製程式碼
如果table陣列沒有初始化就先計算容量,然後在呼叫putVal方法,在執行putVal會有擴容判斷處理,來對table進行初始化操作。這個在講解put操作的時候在詳解putVal方法的是實現邏輯。
擴容機制
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;//得到table陣列的長度
int oldThr = threshold;//如果table陣列還沒有初始化,threshold代表initial capacity,否則代表擴容閾值。
int newCap, newThr = 0;
if (oldCap > 0) {//table陣列長度大於0
if (oldCap >= MAXIMUM_CAPACITY) {//table陣列長度達到最大值,不做擴容處理,一般不會達到這個條件
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) // 這是通過構造方法設定了capacity,還沒有初始化table陣列時
newCap = oldThr;//註釋一
else { // 用了無參的構造方法,還沒初始化table陣列呢
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {//通過上面的分析,可以看出來,只有在“註釋一”的case下,沒有給newThr賦值了
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) {//表示該索引下有資料節點
oldTab[j] = null;
if (e.next == null)//該索引下只有一個節點,沒成鏈呢
newTab[e.hash & (newCap - 1)] = e;//直接把節點賦值到新的陣列索引下,新陣列的新索引通過“e.hash & (newCap - 1)”這種“與操作”來確定。
else if (e instanceof TreeNode)//如果節點是樹節點,走紅黑樹的擴容邏輯
((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;
if ((e.hash & oldCap) == 0) {//等於0,擴容後索引的計算依然與擴容前一致
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else { //不為0,擴容後的索引值是舊的索引值加舊的陣列大小
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;
}
複製程式碼
通過上面的程式碼,我們知道正常情況下,擴容後的Capacity是之前容量的兩倍。
上面的擴容邏輯,在每行程式碼後面已經給了註釋講解,比較簡單,接著我們看*"註釋二”*,可能看到這裡會比較疑惑,為什麼會有個等於零的判斷,而且出現這麼多Node變數作用感覺很相似,重複。之所以出現等於0 的判斷是因為HashMap在擴容的時候,有一個特點是,如果節點的hash值&擴容前陣列大小的值等於0表示該節點在擴容後新陣列下的index索引跟之前的陣列索引一致;不等於則新的陣列索引為舊的陣列索引+oldCapacity。
為什麼又會這個結論?這根HashMap的索引計算有關,HashMap 中,索引的計算方法為 (n - 1) & hash,n表示陣列長度。假如有一個Node節點的hash值為111001,OldCapacity是16(預設值)。那麼:
擴容前:
111001 & (16-1)—> 111001&1111 = 001001(9)
擴容後:(Capacity變成了之前的兩倍為32)
111001&(32-1)—> 111001&11111 = 011001(25)
擴容後節點的索引變了。這裡我們注意下16的二進位制表示:10000
假如hash值是101001,再看下結果:
擴容前:
101001 & (16-1)—> 101001&1111 = 001001(9)
擴容後:(Capacity變成了之前的兩倍為32)
101001&(32-1)—> 101001&11111 = 001001(9)
這次擴容後節點的索引還是之前的索引,原因體現在我上面加粗字型,我們記住陣列長度的二進位制表示中1的位置,如果hash值對應的位置是0的話表示擴容後索引不變,是1的話擴容後索引是原來的索引加上原陣列長度。
常規操作
put操作
官方介紹HashMap的"put","get"操作,說是時間複雜度是O(1),其實這是不準確的,他是假設hash雜湊操作能完全均勻分散到容器中去,現實中很難達到。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
複製程式碼
當呼叫put方法時,會進而呼叫內部的putVal方法,putVal接收四個引數。
Parameter1 是傳進來的key的hash值;
Parameter4 fasle表示相同key的情況下替換value值,true的話就不改變原來的value
Parameter5 只有在初始化table陣列的時候才是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;
if ((tab = table) == null || (n = tab.length) == 0)//如果table陣列還沒有建立,那就先通過resize建立,並記錄陣列長度與引用
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)//傳進來的key對應的陣列索引下沒有資料
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))))
e = p;//如果key的hash值相同,key也相同,那麼替換原來value即可。
else if (p instanceof TreeNode)//是樹節點的話,說明已經樹化了,要走紅黑樹的對應put邏輯。
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) // 達到樹化條件,鏈的長度為8
treeifyBin(tab, hash);//進入這個方法後,還有一個樹化條件判斷,陣列長度有沒有到達閾值。
break;
}
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);//LinkedHashMap重寫了這個方法,感興趣可以去看看
return oldValue;//返回舊值
}
}
++modCount;
if (++size > threshold)//判斷是否需要擴容
resize();
afterNodeInsertion(evict);//LinkedHashMap重寫了這個方法,感興趣可以去看看
return null;
}
複製程式碼
HashMap的預設put操作在遇到相同key,hash的時候,是會替換原來的value的,原因在onlyIfAbsent為false;
如果節點是普通節點則會把資料插入鏈尾,如果是樹化節點TreeNode則會有樹的相應插入邏輯。在作為普通節點插入資料至鏈尾的過程中會檢測是否達到(可能)樹化條件,達到的話會走樹化邏輯。把普通Node節點變成TreeNode。
get操作
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))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
複製程式碼
程式碼比較少,首先先進行table陣列有效性判斷,獲取目標索引下的頭結點。如果頭結點就滿足key相等的要求,那自然是皆大歡喜,省事了。直接返回頭結點即可。
不是頭結點的話,它會接著判斷是不是TreeNode,是TreeNode的話則走樹對應的get操作;否則走普通節點的查詢操作,即遍歷尋找,找到後就返回對應的值沒找到就返回null。
remove操作
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
複製程式碼
remove方法會返回刪除節點的value。我們看removeNode的邏輯。
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {//table陣列有效性判斷,獲取對應索引的頭結點
Node<K,V> node = null, e; K k; V v;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))//巧了,頭結點就是要找到節點O(∩_∩)O哈哈~
node = p;
else if ((e = p.next) != null) {//頭節點不是要找到,那就接著看它的next節點
if (p instanceof TreeNode)//頭結點是TreeNode,那就走樹的查詢目標節點邏輯
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {//普通節點就遍歷查詢
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
//如果找到了,再根據節點的型別,執行對應的邏輯
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)//如果頭結點就是要找到節點,直接把頭節點的next節點指向index索引即可
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
複製程式碼
結語
在分析原始碼邏輯的時候,可以發現主要分為兩部分,一種是如果節點是TreeNode要走紅黑樹的查詢,新增等邏輯;另外一種是走普通的連結串列邏輯。
為什麼要在新的JDK中新增紅黑樹的資料結構,是為了提交效率,當連結串列過長,會拖慢效率,而紅黑樹的效能很好,對插入時間、刪除時間和查詢時間提供了最好可能的最壞情況擔保。時間複雜度是O(log n)。而連結串列是最壞情況下的時間複雜度是O(n)。
本文主要是對HashMap的原始碼進行整體的分析,對於紅黑樹的演算法邏輯細節沒有提及。如果對紅黑樹這種結構有興趣研究的話可以自行研究。
掃碼加入我的個人微信公眾號:Android開發圈 ,一起學習Android知識!!