HashMap原始碼解析
1. 概述
HashMap是基於hash表的Map介面實現,允許null key、null value
2. 成員變數
/**
* [1] 預設初始容量,16(必須是2的冪)
* The default initial capacity - MUST be a power of two.
*/
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.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 連結串列轉紅黑樹的閾值
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* [2] 紅黑樹轉列表的閾值
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 最小樹形化閾值
* 當HashMap中的table的長度大於64的時候,這時候才會允許桶內的連結串列轉成紅黑樹(要求桶內的連結串列長度達到8)
* 如果只是桶內的連結串列過長,而table的長度小於64的時候
* 此時應該是執行resize方法,將table進行擴容,而不是連結串列轉紅黑樹
* 最小樹形化閾值至少應為連結串列轉紅黑樹閾值的四倍
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* 存放具體元素的集
* Holds cached entrySet(). Note that AbstractMap fields are used
* for keySet() and values().
*/
transient Set<Map.Entry<K,V>> entrySet;
/**
* HashMap的負載因子
* 負載因子控制這HashMap中table陣列的存放資料的疏密程度
* 負載因子越接近1,那麼存放的資料越密集,導致查詢元素效率低下
* 負載因子約接近0,那麼存放的資料越稀疏,導致陣列空間利用率低下
* The load factor for the hash table.
*
* @serial
*/
final float loadFactor;
/**
* 修改次數
*/
transient int modCount;
/**
* 鍵值對的個數
* The number of key-value mappings contained in this map.
*/
transient int size;
/**
* 儲存元素的陣列
*/
transient Node<K,V>[] table;
/**
* 當{@link HashMap#size} >= {@link HashMap#threshold}的時候,陣列要進行擴容操作
* The next size value at which to resize (capacity * load factor).
*
* @serial
*/
int threshold;
複製程式碼
[1] 處,為什麼會要求HashMap
的容量必須是2的冪,可以看看
3. 構造方法
java.util.HashMap#HashMap()
public HashMap() {
// 使用預設的引數
// 預設的負載因子、預設的容量
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
複製程式碼
預設的建構函式裡面並沒有對table
陣列進行初始化,這個操作是在java.util.HashMap#putVal
方法進行的
java.util.HashMap#HashMap(int)
public HashMap(int initialCapacity) {
// 呼叫過載建構函式
// 指定初始容量,預設的負載因子
this(initialCapacity,DEFAULT_LOAD_FACTOR);
}
複製程式碼
java.util.HashMap#HashMap(int,float)
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;
// 手動指定HashMap的容量的時候,HashMap的閾值設定跟負載因子無關
this.threshold = tableSizeFor(initialCapacity); // [1]
}
複製程式碼
tableSizeFor
這個方法的作用是,根據指定的容量,大於指定容量的最小的2冪的值
比如說,給定15,返回16;給定30,返回32
tableSizeFor
方法是一個很牛逼的方法,5行程式碼看得我一臉懵逼
java.util.HashMap#HashMap(java.util.Map<? extends K,? extends V>)
public HashMap(Map<? extends K,? extends V> m) {
// 使用預設的負載因子
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m,false);
}
final void putMapEntries(Map<? extends K,? extends V> m,boolean evict) {
int s = m.size();
if (s > 0) {
// 判斷table是否已經例項化
if (table == null) { // pre-size
// 計算m的擴容上限
float ft = ((float)s / loadFactor) + 1.0F;
// 檢查擴容上限是否大於HashMap的最大容量
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold) {
// m的擴容上限大於當前HashMap的擴容上限,則需要重新調整
threshold = tableSizeFor(t);
}
}
else if (s > threshold)
// m.size大於擴容上限,執行resize方法,擴容table
resize();
for (Map.Entry<? extends K,? extends V> e : m.entrySet()) {
// 將m中所有的鍵值對新增到HashMap中
K key = e.getKey();
V value = e.getValue();
putVal(hash(key),key,value,false,evict);
}
}
}
複製程式碼
putMapEntries
方法的流程:
- 如果table為空,則重新計算擴容上限
- 如果HashMap的擴容上限小於指定Map的
size
,那麼執行resize
進行擴容 - 將指定Map中所有的鍵值通過
putVal
方法放到HashMap中
這裡使用到的
hash
函式實際上是一個擾動函式,下文會介紹的
4. Very重要的方法
java.util.HashMap#tableSizeFor
/**
* 靜態工具方法
* 根據指定的容量,大於指定容量的最小的2冪的值
* 備註:牛逼的演演算法
* Returns a power of two size for the given target capacity.
*/
static final int tableSizeFor(int cap) {
/*
演演算法的原理請看
HashMap原始碼註解 之 靜態工具方法hash()、tableSizeFor()(四) - 程式設計師 - CSDN部落格
https://blog.csdn.net/fan2012huan/article/details/51097331
*/
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;
}
複製程式碼
演演算法的流程如下
位移的那五句程式碼是真的很牛逼!!如果看完流程圖以後,還不懂,那就看看註釋裡面的文章吧
java.util.HashMap#hash
// 擾動函式
static final int hash(Object key) {
int h;
// 混合原始Hash值的高位和地位,減少低位碰撞的機率
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
複製程式碼
為什麼會有這個函式出現?
首先我們要了解,HashMap是根據key的hash值中幾個低位的值來確定key在table中對應的index
這句話怎麼理解呢?我舉個栗子
有一個32位的hash值如下
如果取Hash值的低4位,則index = 0101 = 5
如果出現大量的低4位為0101的hash值,那麼所有鍵值對都會放在table的index = 5的地方
這樣就會導致key無法均勻分佈在table中
那麼HashMap為瞭解決這個問題,就搞出了這個方法java.util.HashMap#hash
把一個32位的hash值的高16位 & 低16位,那麼低位就會攜帶高位的資訊
說白了就是,即使有大量hash值低位相同的key,經歷過hash方法後,計算得到的index會不一樣
通過hash方法降低hash衝突的概率
java.util.HashMap#resize
/**
* 對table初始化操作或擴容操作
*
* @return the table
*/
final Node<K,V>[] resize() {
// 拿出舊table快照
Node<K,V>[] oldTab = table;
// 檢查舊table容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 舊的擴容上限
int oldThr = threshold;
// 新table的容量和擴容上限
int newCap,newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
// 如果舊table的容量大於HashMap的最大容量,則不進行擴容操作了
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 沒有超過HashMap的最大容量,則擴容兩倍(newCap,newThr)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
// 只設定了擴容上限,沒有初始化table,將初始容量設定為擴容上限
newCap = oldThr;
else { // zero initial threshold signifies using defaults
// 沒有設定擴容上限,沒有初始化table,則使用預設的容量(16)和擴容上限(12)
// 比如:java.util.HashMap.HashMap()
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
// newThr在oldCap > 0的條件擴容兩倍後仍然等於0,那就說明,oldThr原本就是0
// 重新計算新的擴容上限
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 更新HashMap的擴容上限
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
// 初始化table
table = newTab;
// 如果old table不為空,則需要將裡面的
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)
// 連結串列上只有一個節點,根據節點hash值,重新計算e在newTab中的index
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 連結串列已經轉成紅黑樹,拆分樹
((TreeNode<K,V>)e).split(this,newTab,j,oldCap);
else { // preserve order
// 定義兩個連結串列,lo連結串列和hi連結串列
Node<K,V> loHead = null,loTail = null;
Node<K,V> hiHead = null,hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 通過hash值&oldCap判斷連結串列上的節點是否應該停留新table中的原位置
if ((e.hash & oldCap) == 0) {
// 節點仍然停留在位置j
// 插入lo連結串列(尾插法)
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
// 節點轉移到位置j+oldCap
// 插入hi連結串列(尾插法)
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
// 如果lo連結串列非空,把整個lo連結串列放到新table的j位置上
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
// 如果hi連結串列非空,把整個hi連結串列放到新table的j+oldCap位置上
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
複製程式碼
resize
流程並不複雜,大致如下
個人認為,比較關鍵的一點是重新分配鍵值對到新table
這個時候要考慮三種情況:
- table中index位置只有一個元素
- table中index位置上是一棵紅黑樹
- table中index位置上是一條連結串列(重點看這個)
如果是第3種情況,table中index位置上是一條連結串列,再重新分配的時候,會把這個連結串列拆分成兩條連結串列
一條lo連結串列,留在原來的index位置
另一條hi連結串列,會被移動到index+oldCapacity
的位置
此時,需要判斷節點是留在lo連結串列,還是放在hi連結串列?
推薦看一下這篇文章 深入理解HashMap(四): 關鍵原始碼逐行分析之resize擴容
在jdk1.7裡,table的擴容在多執行緒併發執行下會形成環,牆裂推薦仔細閱讀這篇文章?? HashMap連結串列成環的原因和解決方案
java.util.HashMap#treeifyBin
final void treeifyBin(Node<K,V>[] tab,int hash) {
int n,index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
// [1] table的長度小於最小樹形化閾值,執行resize方法擴容
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
// 將連結串列轉成紅黑樹
// hd頭節點、tl尾節點
TreeNode<K,V> hd = null,tl = null;
// do-while迴圈將單向連結串列轉成雙向連結串列
do {
// 將節點轉成TreeNode
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);
}
}
複製程式碼
treeifyBin
方法的作用是將table中某個index位置上的連結串列轉成紅黑樹
這個方法一般是在新增或合併元素後,發現連結串列的長度大於TREEIFY_THRESHOLD
的時候呼叫
[1]處可以看到,如果當前table.length
小於最小樹形化閾值(64),那麼會呼叫resize方法進行擴容,而不是將連結串列樹形化
java.util.HashMap#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)
// [1] table還沒有初始化,通過resize方法初始化table
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
// 如果key的hash值在table上對應的位置沒有元素,則直接將建立節點
tab[i] = newNode(hash,null);
else {
// table上對應的位置已經存在節點
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 指定的key與已存在的節點的key相等
e = p;
else if (p instanceof TreeNode)
// 連結串列節點已變成紅黑樹節點
e = ((TreeNode<K,V>)p).putTreeVal(this,tab,hash,value);
else {
// 遍歷連結串列,插入或更新節點
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
// 在連結串列的尾部新新增一個節點
p.next = newNode(hash,null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// TREEIFY_THRESHOLD - 1的原因是binCount是從0開始,連結串列上有8個節點的時候,binCount=7
// 新增節點後,當連結串列的節點數量大於等於8的時候,將連結串列樹形化
// [2]
treeifyBin(tab,hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 在連結串列中發現了key對應的節點
break;
p = e;
}
}
if (e != null) { // existing mapping for key
// 發現了key對應的節點,則更新節點上的value
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
// 更新節點對應的值
e.value = value;
afterNodeAccess(e);
// 返回節點的舊值
return oldValue;
}
}
++modCount;
if (++size > threshold)
// [3]
resize();
afterNodeInsertion(evict);
return null;
}
複製程式碼
putVal
方法是新增鍵值對相關方法的實現
上圖可以看到,新增鍵值對的方法內部都會呼叫putVal
方法
[1]處可以看到,putVal
方法內部通過呼叫resize
方法對table進行初始化
整體邏輯並不複雜,但是要注意一下[2]、[3]處
[2]處,如果新增節點後,連結串列過長,要將連結串列轉成紅黑樹
[3]處,如果新增節點後,整個HashMap的鍵值對數量達到了擴容上限,那麼要對table進行擴容操作
5. 總結
如果說ArrayList的原始碼閱讀難度是一星半,那麼我覺得HashMap的原始碼閱讀難度至少有三顆星
這篇文章省略了一些內容,比如HashMap裡面的紅黑樹實現,不寫上去的原因主要是我也不是很懂紅黑樹?,後續的時間如果我弄懂了,我會再補一篇
最起碼這篇文章把HashMap最重要的幾個方法的實現講得比較明白,還是可以的?
6. 推薦閱讀
HashMap原始碼註解 之 靜態工具方法hash()、tableSizeFor()