僅供自己復(fù)習(xí)
數(shù)據(jù)結(jié)構(gòu)分析
| JDK1.7 | JDK1.8 | |
|---|---|---|
| 底層數(shù)據(jù)結(jié)構(gòu) | 數(shù)組 + 鏈表 | 數(shù)組+鏈表/紅黑樹 |
| 沖突解決方法 | 拉鏈法 | “改進的”拉鏈法。當(dāng)鏈表長度大于TREEIFY_THRESHOLD時,鏈表轉(zhuǎn)為紅黑樹 |
// jdk1.8
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
transient Node<K,V>[] table;
/**
* Basic hash bin node, used for most entries. (See below for
* TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
*/
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
....
}
static final class TreeNode<K,V> extends LinkedHashMap.Entry<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);
}
}
重點參數(shù)講解
| 參數(shù) | 作用 |
|---|---|
size |
HashMap已經(jīng)插入了鍵值對的個數(shù),當(dāng)size>threshold時,執(zhí)行擴容 |
initialCapacity |
初始化容量。該參數(shù)并不是HashMap的成員變量 |
threshold |
擴容的閾值。該值=capacity * load factor
|
loadFactor |
負(fù)載因子 |
TREEIFY_THRESHOLD |
鏈表轉(zhuǎn)樹的閾值,默認(rèn)是8,類型是static final int
|
UNTREEIFY_THRESHOLD |
樹轉(zhuǎn)鏈表的閾值,默認(rèn)是6 |
modCount |
HashMap發(fā)生結(jié)構(gòu)性修改的次數(shù)。作用于fast-fail機制:在進行序列化或者迭代等操作時,比較操作前后 modCount 是否改變,若改變,則拋出 ConcurrentModificationException。 |
關(guān)于紅黑樹和鏈表轉(zhuǎn)換一些細節(jié)
1)鏈表轉(zhuǎn)紅黑樹
- 時機:發(fā)生在鏈表長度大于等于 8 時
- 原因:此時,紅黑樹的平均查找長度為 O(log n)=3 < 鏈表平均查找長度 O(n/2)=4。
- 發(fā)生:插入時進行判斷與轉(zhuǎn)換
2)紅黑樹轉(zhuǎn)鏈表
- 時機:紅黑樹節(jié)點數(shù)小于等于 6 時
- 原因:鏈表平均查找長度更小。
- 發(fā)生:刪除節(jié)點時進行判斷和轉(zhuǎn)換
如果留心點,會發(fā)現(xiàn)兩個臨界值之間隔了一個 7。原因是由于轉(zhuǎn)換操作比較耗時,中間隔個 7,可以防止轉(zhuǎn)換操作過于頻繁。
假設(shè)一下,如果設(shè)計成鏈表個數(shù)超過8則鏈表轉(zhuǎn)換成樹結(jié)構(gòu),鏈表個數(shù)小于8則樹結(jié)構(gòu)轉(zhuǎn)換成鏈表,如果一個HashMap不停的插入、刪除元素,鏈表個數(shù)在8左右徘徊,就會頻繁的發(fā)生樹轉(zhuǎn)鏈表、鏈表轉(zhuǎn)樹,效率會很低。
重點函數(shù)分析
哈希值計算hash()
在這方面,JDK1.8 也作為優(yōu)化。
JDK1.7 的 hash() = hashCode() + 擾動操作(包括4次位運算 + 5次異或運算)
JDK1.8 的 hash() = hashCode() + 擾動操作(包括1次位運算 + 1次異或運算)
PS:擾動操作的作用是「加大哈希碼低位的隨機性,使得分布更均勻,從而提高對應(yīng)數(shù)組存儲下標(biāo)位置的隨機性 & 均勻性,最終減少Hash沖突」
// JDK 1.7實現(xiàn):將 key 轉(zhuǎn)換成 哈希碼(hash值)操作 = 使用hashCode() + 4次位運算 + 5次異或運算(9次擾動)
static final int hash(int h) {
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
// JDK 1.8實現(xiàn):將 鍵key 轉(zhuǎn)換成 哈希碼(hash值)操作 = 使用hashCode() + 1次位運算 + 1次異或運算(2次擾動)
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
插入操作putVal()

源碼節(jié)選:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 1)第一次執(zhí)行put,才為hashmap分配空間
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2)插入的key在數(shù)組的下標(biāo)值 i=(n-1)&hash
// 如果tab[i]==null,直接插入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 3)如果tab[i]!=null,則向鏈表或紅黑樹中插入
else {
Node<K,V> e; K k;
// 4)判斷對應(yīng)數(shù)組上的元素的key與插入的key相等,
// 相等則代替。
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 5)不相等,且數(shù)組上的元素是樹節(jié)點,則插入到樹中
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 6)不相等,且數(shù)組上的元素是鏈表節(jié)點,則插入到鏈表中
else {
// 7)遍歷鏈表,找相等的節(jié)點。找不到就指向最后一個節(jié)點
for (int binCount = 0; ; ++binCount) {
// i.說明走到了鏈表尾部,則代表沒找到,就直接插入
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 插入完成后,計算鏈表長度,如果大于閾值,轉(zhuǎn)換為樹
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// ii. 找到相等的節(jié)點,就推出循環(huán)
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 針對上面遍歷中,找到了相等的節(jié)點,現(xiàn)在執(zhí)行替換
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e); // 替換完成后會調(diào)用該方法。默認(rèn)實現(xiàn)是空
return oldValue;
}
}
++modCount; // 記錄map的結(jié)構(gòu)性修改
// 8)當(dāng)map的鍵值對大于閾值,則擴容。
if (++size > threshold)
resize();
afterNodeInsertion(evict); // 插入成功后會調(diào)用該方法。默認(rèn)實現(xiàn)為空
return null;
}
擴容操作resize()
final Node<K,V>[] resize() {
// 1)保存當(dāng)前數(shù)組以及相關(guān)參數(shù)。下面稱為舊數(shù)組
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 2)容量擴展的兩種情況:
// i. 舊容量已經(jīng)達到最大值,就不擴容了
// ii. 如果沒達到,就擴大 1 倍。
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
}
// 3)此處代表map尚未初始化(因為容量小于0),故此這兩個else都是為新map初始化
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;
// 4)創(chuàng)新新的數(shù)組,并指向它
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 5)遍歷舊數(shù)組,將元素搬遷到新數(shù)組中
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null; // 釋放舊數(shù)組的空間
// 6)根據(jù)當(dāng)前遍歷到的節(jié)點類型,選擇搬遷到新數(shù)組的方法
// i: 當(dāng)前節(jié)點后面沒有鏈表/樹,直接插入到新數(shù)組
// ii: 當(dāng)前節(jié)點是一棵樹,則遍歷每個節(jié)點計算他們的新位置。而鏈表中的節(jié)點新位置只有兩種可能
// iii: 當(dāng)前節(jié)點是鏈表,則遍歷每個節(jié)點計算他們的新位置。而鏈表中的節(jié)點新位置只有兩種可能
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap); // 將節(jié)點一分為二。因為新位置只有兩種可能。
else { // preserve order
Node<K,V> loHead = null, loTail = null; // 存放節(jié)點在新數(shù)組的下標(biāo)=原下標(biāo)
Node<K,V> hiHead = null, hiTail = null; // 存放節(jié)點在新數(shù)組的下標(biāo)=原下標(biāo)+舊容量
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;
}
還有一個關(guān)鍵點。
- 不管是同一棵樹或者同一個鏈表,每個節(jié)點的 hash() 一定相等的,只不過是每個節(jié)點之間 equals() 不相等。
- 擴容后元素的新數(shù)組下標(biāo)只有兩種情況:
- 擴容后,若hash() 新增參與運算的位=0,新數(shù)組下標(biāo)=原下標(biāo)
- 擴容后,若hash() 新增參與運算的位=1,新數(shù)組下標(biāo)=原下標(biāo)+擴容前的舊容量
- 相關(guān)證明見[2]
線程不安全原因
本質(zhì):其中一個線程擴容進行了一個部分被掛起了,另一個線程獲取資源并完成擴容后,鏈表的節(jié)點的 next 指針已經(jīng)發(fā)生了改變,導(dǎo)致切換回掛起的線程繼續(xù)擴容時,會出現(xiàn)數(shù)據(jù)丟失或循環(huán)鏈表的情況。
JDK1.7 和 JDK1.8 的 HashMap 都是線程不安全的。但只查閱到 JDK1.7 resize() 引發(fā)的死循環(huán)的例子,故此下例是基于 JDK1.7 的。
舉個例子說明一下,例子來源于[3]。
1)假設(shè) HashMap 現(xiàn)有數(shù)據(jù)如下圖所示,數(shù)組大小為2,故此插入到需要擴容。

2)在單線程情況,擴容后的結(jié)果如下:

3)在多線程,擴容就可能產(chǎn)生循環(huán)鏈表或者丟失數(shù)據(jù)。問題的根源在于 tranfer()。
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
Entry<K,V> next = e.next; // 最最最關(guān)鍵的代碼
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}


PS:
- 線程 1 和線程 2 操作的是同一個 HashMap,因此線程 2 先完成,修改了結(jié)構(gòu)。線程 1 最初記錄已經(jīng)不準(zhǔn)確了,再根據(jù)之前的記錄修改 HashMap,則發(fā)生錯誤。
- 當(dāng)發(fā)生死循環(huán)時,會造成CPU利用率達到100%
- JDK1.8 改為尾插入,能夠避免1.7的上述類型的死循環(huán)。但依舊會出現(xiàn)死循環(huán)
JDK1.8和JDK1.7對比
相比于 JDK1.7,JDK1.8 的 HashMap 做了如下的調(diào)整優(yōu)化:
| 主要區(qū)別 | JDK1.7 | JDK1.8 |
|---|---|---|
| 數(shù)據(jù)結(jié)構(gòu) | 數(shù)組+鏈表 | 數(shù)組+鏈表/紅黑樹 |
| 插入操作 | 當(dāng)發(fā)生沖突時,采用“頭插法”插入到鏈表中 | 當(dāng)發(fā)生沖突時,插入到樹中或者采用“尾插法”插入到鏈表中 |
| 初始化容量 | 調(diào)用inflateTable() | 集成到resize() |
| 計算哈希 | 擾動操作更多 | 擾動操作更少 |
PS:
- 頭插法即插入在鏈表的頭部;尾插法即插入在鏈表的尾部。
- 頭插法會導(dǎo)致鏈表倒序,即原本是 1->2->3,擴容后變成 3->2->1。
- 還有一些小的改動,比如說 JDK1.7 和 JDK1.8 對 key 的哈希值計算有些許的優(yōu)化調(diào)整,詳見[2]
常見問題講解
Q:HashMap容量為什么一定是2的冪次方?
前提:HashMap 容量指的是 table 的大小。
原因:提高 HashMap 的計算 key 的哈希值的速度,從而提高 HashMap 的存取速度。因為當(dāng) table 的大小是 2 的冪次方時,計算哈希用的取余操作,可換算為位運算,從而提高速度。
// 為什么hashMap大小一定是2的冪次方。
// 原因:提高存取速度(更細:提高搜索數(shù)組下標(biāo)的速度)
// 原本:一般計算數(shù)組下標(biāo):通過取余
int h = hash(key.hashCode());
int index = h % table.size();
// 改進:當(dāng)table.size()為2的冪次方時計算數(shù)組下標(biāo):通過位運算
int h = hash(key.hashCode());
int index = h & (table.size()-1);
Q:為什么String和Integer等包裝類適合作為 key?
原因:String和Integer等包裝類具有不變性,從而保證 key 具有不變性。
Q:如何自定義類要作為key,需要重寫哪些方法?
需要重寫的方法:hashcode() 和 equals()。這兩個函數(shù)在 get() 和 put() 都用到,用于判斷傳入的 key 與 HashMap 保存的 key 是否有相等。
比如,在 put() 的用法:
- 數(shù)組的索引:取決于 key 的 hashcode。
- 當(dāng)兩個元素的 hashcode 相同時,然后再判斷 equals() 返回值
- 當(dāng) equals() == true,覆蓋原有的值
- 當(dāng) equals() == false,插入到鏈表/紅黑樹。這就是 hash 沖突的解決方法。
Q:HashMap 實現(xiàn)同步的方法有哪些?
1) 使用 Collections.synchronizedMap(HashMap)
2) 使用 HashTable
3) 使用 ConcurrentHashMap
Q:Collections.synchronizedMap(HashMap)如何實現(xiàn)同步?
答案:Collections.synchronizedMap(HashMap) 得到的對象中有一個鎖變量 mutex,然后所有操作進行加鎖 synchronized(mutex)
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
return new SynchronizedMap<>(m);
}
/**
* @serial include
*/
private static class SynchronizedMap<K,V> implements Map<K,V>, Serializable {
private final Map<K,V> m; // Backing Map
final Object mutex; // Object on which to synchronize
public int size() {synchronized (mutex) {return m.size();} }
public boolean isEmpty() { synchronized (mutex) {return m.isEmpty();} }
public boolean containsKey(Object key) { synchronized (mutex) {return m.containsKey(key);} }
public boolean containsValue(Object value) { synchronized (mutex) {return m.containsValue(value);} }
public V get(Object key) { synchronized (mutex) {return m.get(key);} }
public V put(K key, V value) { synchronized (mutex) {return m.put(key, value);} }
public V remove(Object key) { synchronized (mutex) {return m.remove(key);} }
public void putAll(Map<? extends K, ? extends V> map) { synchronized (mutex) {m.putAll(map);} }
...
}
參考文獻
[1] Java:手把手帶你源碼分析 HashMap 1.7_Java,集合,HashMap_專注分享 Android開發(fā) 干貨-CSDN博客
[2] Java源碼分析:關(guān)于 HashMap 1.8 的重大更新_專注分享 Android開發(fā) 干貨-CSDN博客
[3] [HashMap]HashMap死循環(huán)與元素丟失(一) - 技術(shù)棧 - OSCHINA
[4] Java 8系列之重新認(rèn)識HashMap - 簡書