從原始碼深入理解HashMap(附加HashMap面試題)
HashMap向來是面試中的熱點話題,深入理解了HashMap的底層實現後,才能更好的掌握它,也有利於在專案中更加靈活的使用。
本文基於JDK8進行解析
一、HashMap解析
1. 結構
HashMap結構由陣列加**連結串列(或紅黑樹)**構成。主幹是Entry陣列,Entry是HashMap的基本單位,每一個Entry包含key-value鍵值對。每個Entry可以看成是一個連結串列(或紅黑樹),但是比較特殊的是,當連結串列中的節點個數超過8時,連結串列會轉為紅黑樹進行元素儲存。
####2. 原理
HashMap中的每個數通過雜湊函式計算得出的hash值作為陣列下標,儲存著一對key-value鍵值對。當對一個元素進行查詢時,若該下標處Entry為空,則表示元素不存在,否則通過遍歷連結串列方式進行查詢。
雜湊衝突:雜湊衝突也叫雜湊碰撞,當對兩個不同數進行hash函式計算後可能會得到相同的hash值,即存入陣列下標相同,此時就發生了碰撞,hashMap使用鏈地址法儲存具有相同hash值的元素。
除此之外,解決hash衝突的辦法還有開放地址法(發生hash衝突,尋找下一塊未被佔用的儲存地址)、再雜湊函式法(對求得的hash值再進行一遍hash運算)。
注意:HashMap是執行緒不安全,當多個執行緒進行put操作時,可能會造成put死迴圈。
3. 原始碼分析
HashMap位於java.util.HashMap
部分成員變數
//預設陣列大小16,即2的4次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//陣列最大容量,2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
//當連結串列轉換為紅黑樹時,若陣列大小小於64,即為32、16或更小時,需要對陣列進行擴容
static final int MIN_TREEIFY_CAPACITY = 64;
//負載因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//用於判斷陣列是否需要進行擴容,即當陣列元素 >= 陣列容量*負載因子時,需要對陣列進行擴容
int threshold;
HashMap預設構造器大小為16,並且要求陣列長度必須為2的次冪。HashMap中有負載因子,預設值為0.75.當HashMap中元素個數超過陣列長度*0.75時,會對陣列進行擴容,擴容後會重新計算每個元素的雜湊值,即涉及到陣列元素的遷移。
Node節點
在JDK7中使用Entry表示陣列中的每個元素,JDK8中使用Node,這兩者並沒有什麼區別,為了更好的理解陣列中的每個元素,下面將以Node進行描述。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
...
}
每一個節點處都存有key的hash值、key、value和next鏈四個屬性,其中next鏈用來指向相同hash值不同key值的下一個節點。
put操作
public V put(K key, V value) {
return putVal(hash(key), key, value, 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;
//table為空則建立
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//通過hash值找到對應的陣列下標,如果該下標處元素為空,則直接插入值
//通過(n - 1) & hash運算,可以保證計算出的下標在陣列大小範圍內
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//該下標處元素不為空
else {
Node<K,V> e; K k;
//判斷陣列下標處的元素是否就是要插入的元素,判斷key值是否相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果該下標處元素表示紅黑樹的節點,則進行紅黑樹的插入
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);
//當連結串列中節點數超過8個時,將連結串列轉換成紅黑樹儲存
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//存在該key則跳出迴圈
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//e != null表示存在與插入值相同的key,直接進行覆蓋
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;
}
put流程:
- 判斷陣列是否為空,為空則初始化陣列
- 計算該key的hash值作為key存入陣列的下標
- 判斷該下標處有無元素,沒有則直接插入
- 若有元素存在,則首先判斷該元素是否為待插入的元素,是則直接覆蓋,否則判斷該元素是否為紅黑樹的節點,是則進行紅黑樹的插入,否則遍歷連結串列進行插入,如果遍歷過程中找到key相同的元素則替換,否則在連結串列尾部插入該節點
在程式碼93行可以發現,在put操作完成之後需要對陣列大小進行判斷,若超過陣列容量閾值,則需要對陣列進行擴容。
treeifyBin操作
JDK8之前對於陣列元素以連結串列的形式儲存,對於陣列的索引通過hash值可以直接定位,時間複雜度為O(1),因此對於HashMap查詢的時間複雜度取決於遍歷連結串列所花費的時間。當連結串列長度過長時會對索引帶來不必要的麻煩,因此在JDK8開始,採用連結串列或者紅黑樹的方式進行相同hash值不同key值元素的儲存。
treeifyBin(Node,int)
方法則就是當連結串列長度過長時,將連結串列轉為紅黑樹進行儲存。
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//判斷陣列長度是否大於64,小於則擴容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
//將Node節點轉換成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);
}
}
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;
}
get流程:
- 計算key值的hash值
- 通過hash值找到陣列下標,若該下標元素為空則返回null,否則判斷該元素是否是待查詢的值,是就直接返回
- 若不是判斷該元素節點是否為紅黑樹節點,是則進行紅黑樹的查詢,否則進行連結串列的查詢
resize操作
在構造hash表時如果不指定陣列的大小,則陣列預設初始化大小為16,當陣列元素超過最大容量的75%時就需要對陣列進行擴容,而且擴容是件非常耗時的操作。
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) {
//如果舊陣列容量大於等於最大容量,則修改threshold的值,並返回舊陣列
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//否則將陣列大小擴大為原來的兩倍(二進位制左移一位),並且將threshold也擴大為原來的兩倍
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) {
//新陣列長度乘以負載因子作為新陣列的threshold
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
//建立新陣列
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
//建立兩條鏈,lo鏈跟hi鏈,也就是將原先本應該在一條連結串列上的節點如今分成兩條鏈存放
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
//將(e.hash & oldCap)計算出值為偶數的放在lo鏈上
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
//奇數的放在hi鏈上
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
//將lo鏈放在新陣列的原位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//將hi鏈放在新陣列的原位置+oldCap的下標處
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
總結
HashMap底層通過陣列加連結串列或紅黑樹(JDK8開始)進行資料儲存,當連結串列中儲存過長時會導致查詢效率降低,因此當連結串列長度超過8個時將採用紅黑樹的方式進行儲存。
當陣列元素超過陣列容量*負載因子大小時,將對陣列進行擴容,擴容也可以使得其中的連結串列長度降低,從而提高查詢速度。
對於key值的使用推薦使用不可變類,比如Integer
、String
等等。因為對於key值的定位是通過計算它的hash值找到在陣列中的下標,再通過key的equals
方法找到在連結串列中的位置。因此要求兩個key值相等的物件,它們的hash值必須相等,而相同的hash值並不要求一定equals。
二、HashMap面試題
1.HashMap的工作原理,其中get()方法的工作原理?
2.我們能否讓HashMap同步?
3.關於HashMap中的雜湊衝突(雜湊碰撞)以及衝突解決辦法?
4.如果HashMap的大小超過負載因子定義的容量會怎麼辦?
5.你瞭解重新調整HashMap大小存在什麼問題嗎?
6.為什麼String, Interger這樣的wrapper類適合作為鍵?
7.我們可以使用自定義的物件作為鍵嗎?
8.我們可以使用CocurrentHashMap來代替Hashtable嗎?
9.HashMap擴容問題?
10.為什麼HashMap是執行緒不安全的?如何體現出不安全的?
11.能否讓HashMap實現執行緒安全,如何做?
12.HashMap中hash函式是怎麼實現的?
13.HashMap什麼時候需要重寫hashcode和equals方法?
長期收錄HashMap面試題,答案也會在下篇文章中給出。
更多瞭解,還請關注我的個人部落格:www.zhyocean.cn