Map綜述
Java爲數據結構中的映射提供了一個接口java.util.Map
,這個接口有四個常用的實現類:HashMap
、LinkedHashMap
、TreeMap
以及HashTable
,繼承關係如下:
四個類的簡單說明
HashMap
- 根據鍵的
hashCode
值來存儲數據,大多數情況下可以直接定位到它的值,因而具有很快的訪問速度,但是遍歷順序卻是不確定的 - 最多允許一條記錄的鍵(key)爲
null
,允許多條記錄的值(value)爲null
- 是線程不安全的,即任意時刻有多個線程可以同時操作
HashMap
,可能會導致數據不一致以及訪問數據時的無限循環 - 如果需要滿足線程安全,可以使用
Collections.synchronizedMap()
來使HashMap
成爲線程安全的類,或者使用ConcurrentHashMap
HashTable
HashTable
是遺留類(源自JDK1.0),與此相同的還有Vector
、Stack
,目前不推薦使用HashTable
的大部分功能與HashMap
相似,不同的是HashTable
還繼承了Dictionary
類HashTable
不允許鍵和值爲null
- 是線程安全的,所有的公有方法均是同步方法,同一時間只能有一個線程訪問
HashTable
,併發性不如ConcurrentHashMap
。不需要線程安全的環境下可以使用HashMap
替換,需要線程安全的環境可以使用ConcurrentHashMap
替換
LinkedHashMap
LinkedHashMap
是HashMap
的子類,底層使用雙向鏈表來維護插入順序- 在使用
Iterator
遍歷LinkedHashMap
時,默認情況下先得到的記錄肯定是先插入的,也可以在構造時指定是使用LRU算法,將最近訪問的元素移動到鏈表的尾部 - 是線程不安全的,目前JUC包下沒有對應的併發容器,可以採用
Collections.synchronizedMap()
來獲取一個線程安全的LinkedHashMap
TreeMap
TreeMap
實現了SortedMap
接口,能夠把它保存的記錄根據鍵排序,默認是升序,也可以指定Comparator
來進行比較TreeMap
構造時未指定比較器,則不允許鍵(key)爲null
。如果指定了比較器,則由比較器的實現決定TreeMap
是一個有序的key-value集合,通過紅黑樹實現的- 使用
Iterator
遍歷TreeMap
時,得到的記錄是排過序的,與前面的三種Map
一樣,都是fail-fast
(快速失敗)的 TreeMap
同樣是線程不安全的,除了使用Collections.synchronizedMap()
以外,還可以使用ConcurrentSkipListMap
來代替
總結
對於上述四種Map
接口的實現類,要求映射中的key是在創建以後它的哈希值不會改變,如果哈希值發生變化,則很有可能在Map
中定位不到對應的位置
HashMap
類圖
存儲實現
從存儲結構上來講,HashMap
採用了鏈地址法實現,數組+鏈表+紅黑樹,如下圖所示
具體實現
從源碼可知,HashMap
中定義了
transient Node<K,V>[] table;
即哈希桶數組,而具體對象則是Node
,下面是Node
對象的定義
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;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
Node
是HashMap
的一個內部類,實現了Map.Entry
接口,本身就是一個映射(鍵值對)
存儲優勢
HashMap
使用的是哈希表來存儲。哈希表爲了解決衝突,可以採用開放地址法和鏈地址法等來解決問題。而在HashMap
中採用的是鏈地址法,簡單來說就是數組+鏈表的結合。在每個數組元素上都是一個鏈表,當數據插入時,通過哈希值計算出數據所在數組的下標,然後直接將數據置於對應鏈表的末尾
爲了減少哈希衝突以及將哈希桶數組控制在較少的情況下,HashMap
實現了一套比較好的Hash
算法和擴容機制
Hash算法和擴容機制
從HashMap
的構造方法來看,主要是對以下幾個字段進行初始化
transient Node<K,V>[] table; // 哈希桶數組
int threshold; // 當前哈希桶數組最多容納的key-value鍵值對的個數
final float loadFactor; // 負載因子,可以知道負載因子在HashMap創建以後就不能被修改
transient int size;
transient int modCount;
- 在初始化
table
時,可以通過構造函數指定初始容量,如果不指定則使用默認的的長度(16)。此時除了傳入Map
參數的構造函數以外,都不會進行Node
數組的創建 loadFactor
爲負載因子,默認值爲0.75threshold
是HashMap
所能容納的最多Node
的個數,threshold = table.length * loadFactor
,也就是說,在數組定義好長度以後,負載因子越大,所能容納的鍵值對個數越多size
字段用於記錄HashMap
中實際存在的鍵值對數量modCount
用於記錄HashMap
內部結構變化的次數,主要用於迭代時的快速失敗。需要注意的是,內部結構變化是指結構發生變化,例如新增一個結點、刪除一個結點、改變哈希桶數組,但是在put()
方法中,鍵對應的值被覆蓋則不屬於結構變化
具體分析
負載因子與threshold
- 結合負載因子的計算公式可知,
threshold
是在此負載因子與當前數組對應下所允許的最大數目 - 當
Node
結點的個數超過threshold
時就需要resize
(擴容),擴容後的容量是之前的兩倍 - 默認的負載因子值爲0.75,這個值是對空間和時間效率的一個平衡選擇,一般來說不需要修改
- 如果內存空間很多而又對時間效率要求很高,可以降低負載因子的值,這個值必須大於0
- 如果內存空間緊張而對時間效率要求不高,可以增加負載因子的值,這個值可以大於1
哈希桶數組的長度
- 在
HashMap
中,哈希桶數組table
的長度必須爲$ 2^n $
(即一定爲合數),這是一種非常規的設計
- 常規的設計是把桶的個數設計爲素數,相對來說素數導致衝突的概率小於合數
- 在
HashTable
中,初始化桶的個數爲11,這是桶個數設計爲素數的應用(當然,HashTable
不保證擴容以後還是素數)
HashMap
採用這種非常規設計,主要是爲了在取模和擴容時做優化- 爲了減少衝突,
HashMap
在定位哈希桶索引時,加入了高位參與運算的過程
紅黑樹的加入
- 負載因子與Hash算法即使設計得再合理,也有可能出現鏈表過長的情況,一旦出現鏈表過長,則會嚴重影響
HashMap
的性能 - 在JDK1.8中,引入了紅黑樹,在鏈表長度太長(默認超過8)時,會將鏈表轉換爲紅黑樹。利用紅黑樹插入、刪除、查找都爲
$ logN $
的時間複雜度來提升HashMap
的性能
功能實現
主要從根據鍵獲取哈希桶數組的索引位置、put()
方法的詳細執行以及如何擴容來分析
確定哈希桶數組的索引位置
在實現過程,HashMap
採用了兩步來鍵映射到對應的哈希桶數組的索引上
對鍵的哈希值進行再哈希,將鍵的哈希值的高位參與運算
static final int hash(Object key) { int h; // h = key.hashCode() 第一步獲取hashCode // h ^ (h >>> 16) 第二步將高位參與運算 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
取模運算定位索引
// n爲哈希桶數組的長度 // hash值爲再哈希的結果 // (table.length - 1) & hash p = tab[i = (n - 1) & hash]);
對於第一步:
- 對於任意對象,只要它的
hashCode()
方法返回的哈希值一樣,經過hash()
這個方法,那麼再哈希的結果都是一樣的。 - 在JDK1.8中,優化了高位運算的算法,通過
hashCode()
的高16位異或低16位實現的:h = key.hashCode()) ^ (h >>> 16
,主要是從速度、功效、質量來考慮的,這麼做可以在哈希桶數組較小時,也能保證高低位都參與到哈希值的計算中,同時不會有太大開銷
對於第二步:
* 一般在計算哈希值對應的數組索引時,會採用取模運算,但是模運算的消耗是比較大
* 在HashMap
中是通過(table.length - 1) & hash
的方式來計算對應的數組索引。這是一個很巧妙的做法,前面提到過哈希桶數組的長度始終爲$ 2^n $
,在優化了操作速度以外,(table.length - 1) & hash
這個運算等同於對table.length
取模,即hash % table.length
,但是&
比%
運算效率更高
舉例說明,n爲哈希桶數組的長度
put() 方法的實現
- 判斷哈希桶數組是否爲
null
或者長度爲0,否則執行resize()
進行擴容 - 根據鍵值
key
計算得到的數組索引i
,如果table[i] == null
,直接新建結點進行添加,然後執行6。如果table[i] != null
,繼續執行3 - 判斷
table[i]
的首個元素是否與key
相等(指的hashCode
、地址以及equals()
),如果相等,直接覆蓋value
,然後返回舊值。否則繼續執行4 - 判斷
table[i]
是否爲TreeNode
,即table[i]
是否是紅黑樹,如果是紅黑樹,則在樹中插入新的結點,同時如果樹中存在相應的key
,也會直接覆蓋value
,然後返回舊值。否則繼續執行5 - 遍歷
table[i]
,判斷鏈表長度是否大於8,
- 大於8的話會將鏈表轉換爲紅黑樹,在紅黑樹中執行插入操作
- 否則進行鏈表的插入操作
- 遍歷過程中若發現
key
已經存在,直接覆蓋value
後,返回舊值即可
- 插入成功後,判斷實際存在的鍵值對數是否超過了最大容量
threshold
,如果超過進行擴容
put() 方法源碼
public V put(K key, V value) {
// 對key的hashCode進行再哈希
return putVal(hash(key), key, value, false, true);
}
/**
* 插入時如果key已存在,對值進行替換,然後返回舊值
* 如果onlyIfAbsent爲true,則不會對已存在的值進行替換
* 如果不存在,直接插入,然後返回null
*/
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. table爲空或者長度爲0則進行創建
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2. 計算key在哈希桶數組中的下標,如果這個位置沒有節點則直接進行插入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 3. 如果key存在,直接覆蓋value
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 4. 判斷鏈表是否是紅黑樹
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 5. 該鏈是鏈表
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;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 6. 超過最大容量就進行擴容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
擴容機制
擴容resize()
就是重新計算容量,並將結點重新置於哈希桶數組。Java中的數組是無法自動擴容的,方法是使用各新數組代替已有的容量小的數組,然後將結點重新放入哈希桶中
final Node<K,V>[] resize() {
// 1. 記錄擴容前的哈希桶數組
Node<K,V>[] oldTab = table;
// 2. 記錄舊哈希桶數組的長度,如果爲null就是0
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 3. 記錄舊的最大限制
int oldThr = threshold;
int newCap, newThr = 0;
// 4. 舊長度大於0
if (oldCap > 0) {
// 判斷舊的長度是否已經達到最大的容量,
// 如果是,則修改鍵值對的最大限制爲Integer.MAX_VALUE
// 並在以後的操作,都不在會擴容
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
}
// 5. 此時是首次創建哈希桶數組,但是在創建HashMap時指定了鍵值對的最大限制
// 那麼哈希桶數組的長度爲這個最大限制(這個值是 2^n)
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 6. 此時是首次創建哈希桶數組,將容量與限制設置爲默認值
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 7. 如果新的限制是0,也就是還沒有計算過
// 則通過新的長度與負載因子來計算出
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 8. 將新的限制保存到threshold
threshold = newThr;
// 9. 創建新的哈希桶數組並賦值給table
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 10. 舊的哈希桶數組裏面存有結點,需要將結點置於新的哈希桶數組
if (oldTab != null) {
// 從頭到尾遍歷舊的哈希桶數組
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 如果是空的就跳過
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 這個索引位置只有一個結點,直接再hash後置於新的哈希桶數組
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 這個鏈是紅黑樹,對紅黑樹進行再hash
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 直接是一個單向鏈表,將單向鏈表的每個結點進行再hash存入新的哈希桶數組
// 相比JDK1.7(1.7是會在擴容後將之前的鏈表倒置)會保留結點之前的順序
else { // preserve order
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;
}
// 舊的索引+oldCap
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;
}
// 將舊索引+oldCap放置新的哈希桶數組中
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
在JDK1.8中,HashMap
的哈希桶數組長度爲$ 2^n $
(擴容是之前的2倍),所以元素的位置要麼是在原位置,要麼就是原位置+之前容量的位置
舉個例子來說明,n是哈希桶數組的長度,
- 圖(a)表示擴容前的key1和key2兩種key確定索引位置的示例
- 圖(b)表示擴容後的key1和key2兩種key確定索引位置的實例
- hash1是key1對應的哈希值與高位的運算結果(hash2同理)
元素在重新計算哈希值以後,因爲哈希桶數組長度n變爲2倍,那麼n-1的mask範圍在高位多1bit,因此新的索引變化就如下:
因此,在將已有的哈希桶結點放入新的哈希桶數組時,就不需要每個結點都重新計算hash,只需要看原來的hash值新增的1bit是1還是0,是0的話索引不變,是1的話索引就變成“舊索引+oldCap”
線程安全性
HashMap
是線程不安全的,在多線程場景下使用HashMap
可能造成無限循環而導致CPU使用100%
無限循環的出現是因爲如果有多個線程在HashMap
中插入新值,並同時觸發了resize()
對哈希桶數組進行擴容,在對同一條鏈的所有結點進行再hash分配到新的哈希桶數組的過程中,可能會使鏈上的某個結點指向自身前面的結點,而不是後面的結點,那麼在後面的get()
/put()
操作中,對相應的鏈訪問時就會出現無限循環
總結
- 擴容是一個很消耗性能的操作,所以在使用
HashMap
時,最好能估算一下大致的容量,避免HashMap
頻繁的擴容 - 負載因子是可以自己修改的,值可以大於1,但不能小於等於0。默認值0.75是一個權衡空間與時間效率的值,一般沒有特殊需求最好不要輕易修改
HashMap
是線程不安全的,在併發環境中使用HashMap
可能會出現數據不一致、數據丟失以及無限循環等問題,建議使用ConcurrentHashMap
- 紅黑樹的引入優化了
HashMap
的性能,同時擴容機制也相比JDK1.7更爲優化