Java集合高階(一)HashMap
目錄
- HashMap容器簡介
- HashMap原始碼及資料結構深入分析
- 注意問題及效能優化
HashMap容器簡介 HashMap以K/V形式來儲存資料,基於雜湊表結構,本質上是一個數組+連結串列的結構,提供了高效率的新增和檢索。影響HashMap效能的主要有兩個因素,一個是桶的數量,另外一個就是載入因子,桶數*載入因子就是HashMap擴容的臨界值。如果擴容臨界值設定過小,實際儲存資料又過多,擴容次數就會很頻繁,從時間成本上就會影響效能。相反如果臨界值設定過大,get和迭代操作效能就會降低,這是由空間成本引起的。 與Hashtable相比,HashMap允許使用 null 值和 null 鍵,除了非同步和允許使用 null 之外,它與 Hashtable 大致相同,此外HashMap的迭代器也是快速失敗的,因為迭代過程中不會檢測對集體結構上的修改(比如put一個新k/v,或remove已有值,但不包括替換),從而會出併發修改異常HashMap原始碼及資料結構分析(版本JDK1.8.0_131)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16,預設容量 static final int MAXIMUM_CAPACITY = 1 << 30; // 最大容量 static final float DEFAULT_LOAD_FACTOR = 0.75f; //預設載入因子 transient Node<K,V>[] table; //Map.Entry陣列(桶陣列) transient int modCount; //結構被修改的次數 int threshold; //下次擴容臨界值 final float loadFactor; //載入因子(不允許修改) 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); //首次擴容臨界值=大於給定cap的第一個滿足2的N次冪的值(如15則首次擴容為臨界值為2的4次方=16) } public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted }
從原始碼可以看出,在JDK1.8中HashMap預設的初始容量仍然是16,載入因子是0.75,擴容臨界值threshold仍然等於【桶數*載入因子 =(int)8*0.75 = 6】(雖然在1.8的構造器中把threshold設定成了取大於capacity的第一個等於2的N次冪【8】,但第一次put後又會把threshold變成桶數*載入因子,所以目前來看構造器中的設定並沒有意義)。此外在JDK1.8中,new HashMap<>()時不會建立Node<K,V>[] table桶陣列,而是在第一次put()的時候去建立,也就是說首次擴容操作(resize())發生在put()時候
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)
n = (tab = resize()).length; //構造時沒有建立table,所以首次擴容發生在第一次put。
if ((p = tab[i = (n - 1) & hash]) == null) //校驗table[i]位置是否被佔用,對於key==null的鍵,其hash==0,永遠處於第一個元素)
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;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) { //迭代,如果在迭代過程中發現相同Node,則記錄該Node。否則把新的Node新增到連結末尾
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash); //連結串列長度達到8個即轉為紅黑樹結構
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key (如果存在相同Node,則進行值的替換)
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize(); //重調Map
afterNodeInsertion(evict);
return null;
}
/**
* 重調map
*/
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) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && //newCap調整為oldCap的兩倍
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold //大於=16時,調整為oldThr兩倍,其實等價於newCap * 載入因子,比如newThr(8*0.75) = oldThr(4*0.75)*2 = 3 * 2
}
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;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; //建立新Node陣列
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) { //複製元素,並刪除舊Node陣列元素
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 保證原連結串列順序,由於擴容後table長度會發生變化,所以需要重新計算每個Entry<K,V>的儲存位置, 這裡拆分Entry鏈。
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;
}
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;
}
從原始碼可以看出HashMap的儲存結構與儲存過程:HashMap內部維護了一個儲存資料的Entry陣列(並保證該陣列不管是初始化還是擴容後其大小永遠是),Entry本質上是一個單向連結串列,HashMap採用該連結串列來解決key衝突的情況,key為null的鍵值對永遠都放在以table[0]為頭結點的連結串列中。當put一個key-value對時,首先通過hash(key)計算得到key的hash值,然後結合陣列長度通過計算(table.length - 1) & hash得到儲存位置table[n](使得所有二進位制位都為1),如果table[n]位置未被佔用則建立一個Entry並插入到位置n;否則產生碰撞,迭代Entry鏈依次比較新Key與每個Entry.K,如果key.equals(Entry.K)==true則替換舊的value,否則建立新Entry並插入到連結串列的末尾(1.7中在頭部),預設情況下當HashMap大小達到容量的75%時,會執行擴容操作建立一個新的Entry陣列,長度是原來的2倍,在1.8中當某個Entry鏈長度>= 8 時,還會把該Entry鏈轉換為紅黑樹結構。在擴容過程中由於Node<K,V> table陣列容量發生的變化,所以需要重新計算所有元素的儲存位置,如有必要拆分Entry鏈。下面程式碼雖然簡單但卻非常全面的測試了map初始化、碰撞及擴容過程
public static void main(String[] args) throws Exception {
HashMap<Integer, Object> map = new HashMap<>(1);
map.put(1, 1);
map.put(3, 3);
}
從原始碼角度分析程式碼:(1) new HashMap<>(1):最終結果capacity == 1、threshold == 2的0次 == 1、Node<K,V>[] table == null、size == 0 (2) map.put(1,1);這裡執行了兩次resize()操作。
- Entry[] table==null時執行第一次resize(),結果capacity == 1、threshold == (int)1*0.75 == 0、table = new Node<K,V>[1])
- put(1,1)之後,由於++size(++0) > threshold(0),需要擴容,所以此時執行第二次resize()操作,擴容結果:capacity = 1*2、 threshold = (int)2*0.75 = 1、table = new Node<K,V>[2]。所以Entry<1,1>位於(tab.length - 1 & 1) == 1 & 1 == tab[1]
(3) map.put(3,3) 同樣由於(tab.length - 1 & 3) == 1 & 3 == 1, 所以Entry<3,3>同樣應處於tab[1]位於並且作為Entry<1,1>的next元素而存在。但此時由於++size(++1)又大於了threshold(1),將再次執行resize(),最終capacity == 4、threshold==-3,由於Entry[] table的容量發生了變化,所以此時需要迭代每個Entry鏈,對Entry鏈中的每個元素進行重新計算,得出新的儲存位置。所以這裡tab[1]處的Entry鏈(Entry<1,1>-->Entry<3,3>)將會被拆分,因為(tab.length - 1) & 3 = 3 & 3 = 3,不再是1,所以最終會把Entry<3,3>儲存在tab[3]。而Entry<1,1>仍然處於tab[1]處。最終結果及對應變化可以用下圖表示資料結構 上方的原始碼其實已經說明了HashMap底層的資料結構,圖解的話大致如下注意問題及效能優化 HashMap並不是執行緒安全的,如果需要執行緒安全的話可以考慮通過Collections.synchronizeMap(hmap)來包裝,但這種方式本質上和HashTable沒什麼區別,都是以map本身為鎖物件,效率並不高。併發情況下應該使用ConcurrentHashMap<K,V>。 HashMap擴容時,由於Node<K,V>[] table長度發生變化,所以會重新計算每個元素的位置,在這個過程中可能會拆分Entry 鏈。另外隨著table陣列的增長,每次擴容的時間和空間成本都會增加。所以初始化時應儘可能設定合理的大小,如果計劃儲存20個元素,在載入因子不變的情況下,容量設為 =32最為合理(20 / 0.75 = 26)。 不考慮衝突的情況下,HashMap的複雜度為O(1),不管資料量多大,一次計算就可以找到目標;當然實際應用中隨著Key的增加,衝突的可能性越大, 如果請求大量key不同,但是hashCode相同的資料甚至可以造成Hash攻擊,讓HashMap不斷髮生碰撞,硬生生的變成一個單鏈表,這樣put/get效能就從O(1)變成了O(N)。從這點出發應儘量減少碰撞的機率,使用比較高率的hashCode,比如可以採用Integer、String等final變數作為Key,這種型別的hash值產生衝突的可能性很少,比如1的hashCode就是1,2的就是2。 另外一種常見的問題就是HashMap的擴容時死迴圈問題
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; //若執行緒A執行此行被掛起,執行緒B整個更新連結串列。執行緒A繼續執行,則很可能產生死迴圈或者put丟失。
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
死迴圈問題在1.8中已經被解決,1.8中在擴容時聲明瞭兩對指標,維護兩個連結串列,依次在末端新增新的元素,在多執行緒操作的情況下,不會出現交叉的情況,頂多也就是每個執行緒重複同樣操作。
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;
}
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;
}
}
HashMap迭代過程中不會檢測對map結構的修改,所以併發訪問情況下(或單執行緒下在迭代中修改)很容易丟擲