前言: HashMap是Java程序員使用頻率最高的用於映射(鍵值對)處理的數據類型。隨着JDK(Java Developmet Kit)版本的更新,JDK1.8對HashMap底層的實現進行了優化,例如引入紅黑樹的數據結構和擴容的優化等。最近恰好有時間,恰好把HashMap相關的內容和以前作惟品會網關的一些經驗整理一下。html
HashMap的內存結構和原理,以及線程安全都是面試的熱點問題。Java中的數據結構基本能夠用數組+鏈表的解決。java
快速插入和刪除的場景,不利於查找
。而HashMap就是綜合了上述的兩種數據結構的優勢,HashMap由Entry數組+鏈表組成
,以下圖所示:程序員
從上圖咱們能夠發現HashMap是由Entry數組+鏈表
組成的,一個長度爲16的數組中,每一個元素存儲的是一個鏈表的頭結點。那麼這些元素是按照什麼樣的規則存儲到數組中呢。通常狀況是經過hash(key)%len
得到,也就是元素的key的哈希值對數組長度取模獲得。好比上述哈希表中,12%16=12,28%16=12,108%16=12,140%16=12。因此十二、2八、108
以及140都存儲在數組下標爲12
的位置。面試
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[]會以必定的規則加長長度。 app
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的寫入操做丟失。
①.判斷鍵值對數組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; }
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;//哈希值 }
擴容(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做爲原始數組,這樣也會有問題。因此在擴容操做的時候也有可能會引發一些併發的問題。
//根據指定的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/