前言: HashMap是Java程序員使用頻率最高的用於映射(鍵值對)處理的數據類型。隨着JDK(Java Developmet Kit)版本的更新,JDK1.8對HashMap底層的實現進行了優化,例如引入紅黑樹的數據結構和擴容的優化等。最近剛好有時間,剛好把HashMap相關的內容和之前做唯品會網關的一些經驗整理一下。
一.HashMap的概述
1.1 HashMap的數據結構
HashMap的內存結構和原理,以及線程安全都是面試的熱點問題。Java中的數據結構基本可以用數組+鏈表的解決。
- 數組的優缺點:通過下標索引方便查找,但是在數組中插入或刪除一個元素比較困難。
- 鏈表的優缺點:由於在鏈表中查找一個元素需要以遍歷鏈表的方式去查找,而插入,刪除快速。因此鏈表適合
快速插入和刪除的場景,不利於查找
。
而HashMap就是綜合了上述的兩種數據結構的優點,HashMap由Entry數組+鏈表組成
,如下圖所示:
從上圖我們可以發現HashMap是由Entry數組+鏈表
組成的,一個長度為16的數組中,每個元素存儲的是一個鏈表的頭結點。那么這些元素是按照什么樣的規則存儲到數組中呢。一般情況是通過hash(key)%len
獲得,也就是元素的key的哈希值對數組長度取模得到。比如上述哈希表中,12%16=12,28%16=12,108%16=12,140%16=12。所以12、28、108
以及140都存儲在數組下標為12
的位置。
1.2 HashMap的存取實現簡單說明
1.2.1 HashMap put方法實現
1.首先HashMap里面實現一個靜態內部類Entry
,其重要的屬性有 key , value, next
,從屬性key,value我們就能很明顯的看出來Entry就是HashMap鍵值對實現的一個基礎bean
,我們上面說到HashMap的基礎就是一個線性數組,這個數組就是Entry[],Map里面的內容都保存在Entry[]里面。
static class Entry<K,V> implements Map.Entry<K,V> { final K key;//Key-value結構的key V value;//存儲值 Entry<K,V> next;//指向下一個鏈表節點 final int hash;//哈希值 }
2.既然是線性數組,為什么能隨機存取?這里HashMap用了一個小算法,大致是這樣實現:
//存儲時: // 這個hashCode方法這里不詳述,只要理解每個key的hash是一個固定的int值 int hash = key.hashCode(); int index = hash % Entry[].length; Entry[index] = value; //取值時: int hash = key.hashCode(); int index = hash % Entry[].length; return Entry[index];
到這里我們輕松的理解了HashMap通過鍵值對實現存取的基本原理
3.疑問:如果兩個key通過hash%Entry[].length得到的index相同,會不會有覆蓋的危險?
這里HashMap里面用到鏈式數據結構的一個概念。上面我們提到過Entry類里面有一個next屬性,作用是指向下一個Entry。打個比方, 第一個鍵值對A進來,通過計算其key的hash得到的index=0,記做:Entry[0] = A。一會后又進來一個鍵值對B,通過計算其index也等於0,現在怎么辦?HashMap會這樣做:B.next = A,Entry[0] = B,如果又進來C,index也等於0,那么C.next = B,Entry[0] = C;這樣我們發現index=0的地方其實存取了A,B,C三個鍵值對,他們通過next這個屬性鏈接在一起。所以疑問不用擔心。也就是說數組中存儲的是最后插入的元素。到這里為止,HashMap的大致實現,我們應該已經清楚了。
當然HashMap里面也包含一些優化方面的實現,這里也說一下。比如:Entry[]的長度一定后,隨着map里面數據的越來越長,這樣同一個index的鏈就會很長,會不會影響性能?HashMap里面設置一個因素(也稱為因子),隨着map的size越來越大,Entry[]會以一定的規則加長長度。
二.HashMap非線程安全
2.1 HashMap進行Put操作
2.1.1 Jdk8以下HashMap的Put操作
put操作主要是判空,對key的hashcode執行一次HashMap自己的哈希函數,得到bucketindex位置,還有對重復key的覆蓋操作。
在HashMap做put操作的時候會調用到以下的方法,addEntry和createEntry
public V put(K key, V value) { if (key == null) return putForNullKey(value); //得到key的hashcode,同時再做一次hash操作 int hash = hash(key.hashCode()); //對數組長度取余,決定下標位置 int i = indexFor(hash, table.length); /** * 首先找到數組下標處的鏈表結點, * 判斷key對一個的hash值是否已經存在,如果存在將其替換為新的value */ for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; //Hash碰撞的解決 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; addEntry(hash, key, value, i); return null; }
涉及到的幾個方法:
static int hash(int h) { h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); } static int indexFor(int h, int length) { return h & (length-1); }
現在假如A線程和B線程同時進入addEntry
,然后計算出了相同的哈希值對應了相同的數組位置
,因為此時該位置還沒數據,然后對同一個數組位置調用createEntry
,兩個線程會同時得到現在的頭結點,然后A寫入新的頭結點之后,B也寫入新的頭結點,那B的寫入操作就會覆蓋A的寫入操作造成A的寫入操作丟失。
2.1.2 jdk8中HashMap的Put操作
①.判斷鍵值對數組table[i]是否為空或為null,否則執行resize()進行擴容;
②.根據鍵值key計算hash值得到插入的數組索引i,如果table[i]==null,直接新建節點添加,轉向⑥,如果table[i]不為空,轉向③;
③.判斷table[i]的首個元素是否和key一樣,如果相同直接覆蓋value,否則轉向④,這里的相同指的是hashCode以及equals;
④.判斷table[i] 是否為treeNode,即table[i] 是否是紅黑樹,如果是紅黑樹,則直接在樹中插入鍵值對,否則轉向⑤;
⑤.遍歷table[i],判斷鏈表長度是否大於8,大於8的話把鏈表轉換為紅黑樹,在紅黑樹中執行插入操作,否則進行鏈表的插入操作;遍歷過程中若發現key已經存在直接覆蓋value即可;
⑥.插入成功后,判斷實際存在的鍵值對數量size是否超多了最大容量threshold,如果超過,進行擴容。
JDK1.8HashMap的put方法源碼如下:
public V put(K key, V value) { // 對key的hashCode()做hash 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; // 步驟①:tab為空則創建 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 步驟②:計算index,並對null做處理 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; // 步驟③:節點key存在,直接覆蓋value 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已經存在直接覆蓋value 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; // 步驟⑥:超過最大容量 就擴容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
2.2 HashMap進行Get操作
public V get(Object key) { if (key == null) return getForNullKey(); int hash = hash(key.hashCode()); /** * 先定位到數組元素,再遍歷該元素處的鏈表 * 判斷的條件是key的hash值相同,並且鏈表的存儲的key值和傳入的key值相同 */ for (Entry<K,V> e = table[indexFor(hash, table.length)];e != null;e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) return e.value; } return null;
看一下鏈表的結點數據結構,保存了四個字段,包括key,value,key對應的hash值以及鏈表的下一個節點:
static class Entry<K,V> implements Map.Entry<K,V> { final K key;//Key-value結構的key V value;//存儲值 Entry<K,V> next;//指向下一個鏈表節點 final int hash;//哈希值 }
2.3 HashMap擴容的時候
擴容(resize)就是重新計算容量,向HashMap對象里不停的添加元素,而HashMap對象內部的數組無法裝載更多的元素時,對象就需要擴大數組的長度,以便能裝入更多的元素。當然Java里的數組是無法自動擴容的,方法是使用一個新的數組代替已有的容量小的數組,就像我們用一個小桶裝水,如果想裝更多的水,就得換大水桶。
還是上面那個addEntry方法中,有個擴容的操作,這個操作會新生成一個新的容量的數組,然后對原數組的所有鍵值對重新進行計算和寫入新的數組,之后指向新生成的數組。來看一下擴容的源碼:
//用新的容量來給table擴容 void resize(int newCapacity) { Entry[] oldTable = table; //引用擴容前的Entry數組 int oldCapacity = oldTable.length; //保存old capacity // 如果舊的容量已經是系統默認最大容量了(擴容前的數組大小如果已經達到最大(2^30)了 ),那么將閾值設置成整形的最大值,退出 , if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return; } //初始化一個新的Entry數組 Entry[] newTable = new Entry[newCapacity]; //將數據轉移到新的Entry數組里 transfer(newTable, initHashSeedAsNeeded(newCapacity)); //HashMap的table屬性引用新的Entry數組 table = newTable; //設置閾值 threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); }
這里就是使用一個容量更大的數組來代替已有的容量小的數組,transfer()
方法將原有Entry數組
的元素拷貝到新的Entry數組
里。
那么問題來了,當多個線程同時進來,檢測到總數量超過門限值的時候就會同時調用resize操作,各自生成新的數組並rehash后賦給該map底層的數組table,結果最終只有最后一個線程生成的新數組被賦給table變量,其他線程的均會丟失。而且當某些線程已經完成賦值而其他線程剛開始的時候,就會用已經被賦值的table作為原始數組,這樣也會有問題。所以在擴容操作的時候也有可能會引起一些並發的問題。
2.4 刪除數據的時候
//根據指定的key刪除Entry,返回對應的value public V remove(Object key) { Entry<K,V> e = removeEntryForKey(key); return (e == null ? null : e.value); } //根據指定的key,刪除Entry,並返回對應的value final Entry<K,V> removeEntryForKey(Object key) { if (size == 0) { return null; } int hash = (key == null) ? 0 : hash(key); int i = indexFor(hash, table.length); Entry<K,V> prev = table[i]; Entry<K,V> e = prev; while (e != null) { Entry<K,V> next = e.next; Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { modCount++; size--; if (prev == e) //如果刪除的是table中的第一項的引用 table[i] = next;//直接將第一項中的next的引用存入table[i]中 else prev.next = next; //否則將table[i]中當前Entry的前一個Entry中的next置為當前Entry的next e.recordRemoval(this); return e; } prev = e; e = next; } return e; }
刪除這一塊可能會出現兩種線程安全問題,第一種是一個線程判斷得到了指定的數組位置i並進入了循環,此時,另一個線程也在同樣的位置已經刪掉了i位置的那個數據了,然后第一個線程那邊就沒了。但是刪除的話,沒了倒問題不大。
再看另一種情況,當多個線程同時操作同一個數組位置的時候,也都會先取得現在狀態下該位置存儲的頭結點,然后各自去進行計算操作,之后再把結果寫會到該數組位置去,其實寫回的時候可能其他的線程已經就把這個位置給修改過了,就會覆蓋其他線程的修改。
總之HashMap是非線程安全的,在高並發的場合使用的話,要用Collections.synchronizedMap進行包裝一下。
三.參考文章
https://zhuanlan.zhihu.com/p/21673805
http://www.importnew.com/7099.html
http://www.admin10000.com/document/3322.html
http://www.cnblogs.com/chenssy/p/3521565.html
http://xujin.org/java/hm01/