Java8 對 HashMap 進行了一些修改,最大的不同就是利用了紅黑樹,所以其由 數組+鏈表+紅黑樹 組成。
根據 Java7 HashMap 的介紹,我們知道,查找的時候,根據 hash 值我們能夠快速定位到數組的具體下標,但是之后的話,需要順着鏈表一個個比較下去才能找到我們需要的,時間復雜度取決於鏈表的長度,為 O(n)。
為了降低這部分的開銷,在 Java8 中,當鏈表中的元素超過了 8 個以后,會將鏈表轉換為紅黑樹,在這些位置進行查找的時候可以降低時間復雜度為 O(logN)。
來一張圖簡單示意一下吧:
先回答幾個問題:
1.HashMap的什么時候擴容,哪些操作會觸發
當向容器添加元素的時候,會判斷當前容器的元素個數,如果大於等於閾值,即當前數組的長度乘以加載因子的值的時候,就要自動擴容。默認容量為16,擴容因子是0.75,閾值為12。
有參構造方法和put、merge操作都會導致擴容。
2.HashMap push方法的執行過程?
最先判斷桶的長度是否為0,為0的話則需要先對桶進行初始化操作,接着,求出hashcode並通過擾動函數確定要put到哪個桶中,若桶中沒有元素直接插入,若有元素則判斷key是否相等,如果相等的話,那么就將value改為我們put的value值,若不等的話,那么此時判斷該點是否為樹節點,如果是的話,調用putreeval方法,以樹節點的方式插入,如果不是樹節點,那么就遍歷鏈表,如果找到了key那么修改value,沒找到新建節點插到鏈表尾部,最后判斷鏈表長度是否大於8 是否要進行樹化。
3.HashMap檢測到hash沖突后,將元素插入在鏈表的末尾還是開頭?
因為JDK1.7是用單鏈表進行的縱向延伸,采用頭插法就是能夠提高插入的效率,但是也會容易出現逆序且環形鏈表死循環問題。但是在JDK1.8之后是因為加入了紅黑樹使用尾插法,能夠避免出現逆序且鏈表死循環的問題。
4.JDK1.8還采用了紅黑樹,講講紅黑樹的特性,為什么人家一定要用紅黑樹而不是AVL、B樹之類的?
在CurrentHashMap中是加鎖了的,實際上是讀寫鎖,如果寫沖突就會等待,如果插入時間過長必然等待時間更長,而紅黑樹相對AVL樹B樹的插入更快,AVL樹查詢確實更快一些,但是對於操作密集型,紅黑樹的旋轉更少,效率更高。
5.HashMap get方法的執行過程?
首先和put一樣,確定對應的key在哪一個桶中,如果桶容量為0或者該桶內沒有元素直接返回空,反之會判斷該桶會檢查桶中第一個元素是否和要查的key相等,相等的話直接返回,不相等的話判斷該節點是否為樹節點,是的話以樹節點方式遍歷整棵樹來查找,不是的話那就說明存儲結構是鏈表,以遍歷鏈表的方式查找。
源碼與其中的算法技巧
1.構造方法
public HashMap(int initialCapacity, float loadFactor) { //當指定的 initialCapacity (初始容量) < 0 的時候會拋出 IllegalArgumentException 異常 if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); //當指定的 initialCapacity (初始容量)= MAXIMUM_CAPACITY(最大容量) 的時候 if (initialCapacity > MAXIMUM_CAPACITY) //初始容量就等於 MAXIMUM_CAPACITY (最大容量) initialCapacity = MAXIMUM_CAPACITY; //當 loadFactory(負載因子)< 0 ,或者不是數字的時候會拋出 IllegalArgumentException 異常 if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; //tableSizeFor()的主要功能是返回一個比給定整數大且最接近2的冪次方整數 //比如我們給定的數是12,那么tableSizeFor()會返回2的4次方,也就是16,因為16是最接近12並且大於12的數 this.threshold = tableSizeFor( initialCapacity); }
執行順序注釋寫的很清楚了,但是有些同學對最后對 tableSizeFor 方法很有疑問,這是用來求傳入容量的最小2的冪次方整數的。
static final int tableSizeFor(int cap) { int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }
這是一系列的或操作,舉個例子
n-=1;// n=1000000(二進制) ...//16、8無變化 n|=n>>>4;//n=n|(n>>>4)=1000000|0000100=1000100 n|=n>>>2;//n=n|(n>>>2)=1000100|0010001=1010101 ...
看出規律來了吧,右移多少位,就把最高位右邊的第x位設置為1;第二次,就把兩個為1的右邊xx位再設置為1;第n次,就把上一步出現的1右邊xxxx位置為1;
這樣執行完,原來是1000000,變成了1111111,最后加1,就變成2的整數次方數了。之所以先減一是因為有可能本身就是最小2的冪次方整數。
2.Put方法
put方法的核心就是 putVal ,源碼和執行過程如下。
//實現 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; //如果table為空或者長度為0,則進行resize()(擴容) if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //確定插入table的位置,算法是上面提到的 (n - 1) & hash,在 n 為 2 的時候,相當於取模操作 if ((p = tab[i = (n - 1) & hash]) == null) //找到key值對應的位置並且是第一個,直接插入 tab[i] = newNode(hash, key, value, null); //在table的 i 的位置發生碰撞,分兩種情況 //1、key值是一樣的,替換value值 //2、key值不一樣的 //而key值不一樣的有兩種處理方式:1、存儲在 i 的位置的鏈表 2、存儲在紅黑樹中 else { Node<K,V> e; K k; //第一個Node的hash值即為要加入元素的hash 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 { //如果不是TreeNode的話,即為鏈表,然后遍歷鏈表 for (int binCount = 0; ; ++binCount) { //鏈表的尾端也沒有找到key值相同的節點,則生成一個新的Node //並且判斷鏈表的節點個數是不是到達轉換成紅黑樹的上界達到,則轉換成紅黑樹 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; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } //如果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; }
為了方便理解,配圖
putVal中有一段代碼提到了resize(),也就是擴容,我們來看下源碼
final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; //判斷Node的長度,如果不為零 if (oldCap > 0) { //判斷當前Node的長度,如果當前長度超過 MAXIMUM_CAPACITY(最大容量值) if (oldCap >= MAXIMUM_CAPACITY) { //新增閥值為 Integer.MAX_VALUE threshold = Integer.MAX_VALUE; return oldTab; } //如果小於這個 MAXIMUM_CAPACITY(最大容量值),並且大於 DEFAULT_INITIAL_CAPACITY (默認16) else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) //進行2倍擴容 newThr = oldThr << 1; // double threshold } else if (oldThr > 0) // initial capacity was placed in threshold //指定新增閥值 newCap = oldThr; //如果數組為空 else { // zero initial threshold signifies using defaults //使用默認的加載因子(0.75) newCap = DEFAULT_INITIAL_CAPACITY; //新增的閥值也就為 16 * 0.75 = 12 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數組 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; //如果數組不為空,將原數組中的元素放入擴容后的數組中 if (oldTab != null) { for (int j = 0; j < oldCap; ++j) { 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 //如果節點不為空,且為單鏈表,則將原數組中單鏈表元素進行拆分 Node<K,V> loHead = null, loTail = null;//保存在原有索引的鏈表 Node<K,V> hiHead = null, hiTail = null;//保存在新索引的鏈表 Node<K,V> next; do { next = e.next; //哈希值和原數組長度進行&操作,為0則在原數組的索引位置 //非0則在原數組索引位置+原數組長度的新位置 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;
為什么會hash沖突?
就是根據key即經過一個函數f(key)得到的結果的作為地址去存放當前的key,value鍵值對(這個是hashmap的存值方式),但是卻發現算出來的地址上已經有數據。這就是所謂的hash沖突。
hash沖突的幾種情況:
1兩個節點的key值相同(hash值一定相同),導致沖突
2 兩個節點的key值不同,由於hash函數的局限性導致hash值相同,導致沖突
3 兩個節點的key值不同,hash值不同,但hash值對數組長度取模后相同,導致沖突
如何解決hash沖突?解決hash沖突的方法主要有兩種,一種是開放尋址法,另一種是鏈表法 。
開放尋址法--線性探測
開放尋址法的原理很簡單,就是當一個Key通過hash函數獲得對應的數組下標已被占用的時候,我們可以尋找下一個空檔位置
比如有個Entry6通過hash函數得到的下標為2,但是該下標在數組中已經有了其它的元素,那么就向后移動1位,看看數組下標為3的位置是否有空位
比如有個Entry6通過hash函數得到的下標為2,但是該下標在數組中已經有了其它的元素,那么就向后移動1位,看看數組下標為3的位置是否有空位
但是下標為3的數組也已經被占用了,那么久再向后移動1位,看看數組下標為4的位置是否為空
數組下標為4的位置還沒有被占用,所以可以把Entry6存入到數組下標為4的位置。這就是開放尋址的基本思路,尋址的方式有很多種,這里只是簡單的一個示例
鏈表法
鏈表法也正是被應用在了HashMap中,HashMap中數組的每一個元素不僅是一個Entry對象,還是一個鏈表的頭節點。每一個Entry對象通過next指針指向它的下一個Entry節點。當新來的Entry映射到與之沖突的數組位置時,只需要插入到對應的鏈表中即可
JDK7和JDK8中的HashMap都是線程不安全的,主要體現在哪些方面?
只要是對於集合有一定了解的一定都知道HashMap是線程不安全的,我們應該使用ConcurrentHashMap。但是為什么HashMap是線程不安全的呢,之前面試的時候也遇到到這樣的問題,但是當時只停留在***知道是***的層面上,並沒有深入理解***為什么是***。於是今天重溫一個HashMap線程不安全的這個問題。
首先需要強調一點,HashMap的線程不安全體現在會造成死循環、數據丟失、數據覆蓋這些問題。其中死循環和數據丟失是在JDK1.7中出現的問題,在JDK1.8中已經得到解決,然而1.8中仍會有數據覆蓋這樣的問題。
擴容引發的線程不安全
HashMap
的線程不安全主要是發生在擴容函數中,即根源是在transfer函數中,JDK1.7中HashMap
的transfer
函數如下:
void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; for (Entry<K,V> e : table) { while(null != e) { Entry<K,V> next = e.next; if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } } }
這段代碼是HashMap的擴容操作,重新定位每個桶的下標,並采用頭插法將元素遷移到新數組中。頭插法會將鏈表的順序翻轉,這也是形成死循環的關鍵點。理解了頭插法后再繼續往下看是如何造成死循環以及數據丟失的。
擴容造成死循環和數據丟失的分析過程
假設現在有兩個線程A、B同時對下面這個HashMap進行擴容操作:
正常擴容后的結果是下面這樣的:
但是當線程A執行到上面transfer
函數的第11行代碼時,CPU時間片耗盡,線程A被掛起。即如下圖中位置所示:
此時線程A中:e=3、next=7、e.next=null
當線程A的時間片耗盡后,CPU開始執行線程B,並在線程B中成功的完成了數據遷移
重點來了,根據Java內存模式可知,線程B執行完數據遷移后,此時主內存中newTable和table都是最新的,也就是說:7.next=3、3.next=null。
隨后線程A獲得CPU時間片繼續執行newTable[i] = e,將3放入新數組對應的位置,執行完此輪循環后線程A的情況如下:
接着繼續執行下一輪循環,此時e=7,從主內存中讀取e.next時發現主內存中7.next=3,於是乎next=3,並將7采用頭插法的方式放入新數組中,並繼續執行完此輪循環,結果如下:
執行下一次循環可以發現,next=e.next=null,所以此輪循環將會是最后一輪循環。接下來當執行完e.next=newTable[i]即3.next=7后,3和7之間就相互連接了,當執行完newTable[i]=e后,3被頭插法重新插入到鏈表中,執行結果如下圖所示:
上面說了此時e.next=null即next=null,當執行完e=null后,將不會進行下一輪循環。到此線程A、B的擴容操作完成,很明顯當線程A執行完后,HashMap中出現了環形結構,當在以后對該HashMap進行操作時會出現死循環。
並且從上圖可以發現,元素5在擴容期間被莫名的丟失了,這就發生了數據丟失的問題。
JDK1.8中的線程不安全
根據上面JDK1.7出現的問題,在JDK1.8中已經得到了很好的解決,如果你去閱讀1.8的源碼會發現找不到transfer函數,因為JDK1.8直接在resize函數中完成了數據遷移。另外說一句,JDK1.8在進行元素插入時使用的是尾插法。
為什么說JDK1.8會出現數據覆蓋的情況喃,我們來看一下下面這段JDK1.8中的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; if ((p = tab[i = (n - 1) & hash]) == null) // 如果沒有hash碰撞則直接插入元素 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) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } 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; }
其中第六行代碼是判斷是否出現hash碰撞,假設兩個線程A、B都在進行put操作,並且hash函數計算出的插入下標是相同的,當線程A執行完第六行代碼后由於時間片耗盡導致被掛起,而線程B得到時間片后在該下標處插入了元素,完成了正常的插入,然后線程A獲得時間片,由於之前已經進行了hash碰撞的判斷,所有此時不會再進行判斷,而是直接進行插入,這就導致了線程B插入的數據被線程A覆蓋了,從而線程不安全。
除此之前,還有就是代碼的第38行處有個++size,我們這樣想,還是線程A、B,這兩個線程同時進行put操作時,假設當前HashMap的zise大小為10,當線程A執行到第38行代碼時,從主內存中獲得size的值為10后准備進行+1操作,但是由於時間片耗盡只好讓出CPU,線程B快樂的拿到CPU還是從主內存中拿到size的值10進行+1操作,完成了put操作並將size=11寫回主內存,然后線程A再次拿到CPU並繼續執行(此時size的值仍為10),當執行完put操作后,還是將size=11寫回內存,此時,線程A、B都執行了一次put操作,但是size的值只增加了1,所有說還是由於數據覆蓋又導致了線程不安全。
結論
HashMap的線程不安全主要體現在下面兩個方面:
1.在JDK1.7中,當並發執行擴容操作時會造成環形鏈和數據丟失的情況。
2.在JDK1.8中,在並發執行put操作時會發生數據覆蓋的情況。