HashMap源碼探討(基於JDK1.8)

HashMap簡介

HashMap 是一個散列表,它存儲的內容是鍵值對(key-value)映射,該類繼承於AbstractMap,實現了Map、Cloneable、java.io.Serializable接口。
HashMap 的實現不是同步的,這意味着它不是線程安全的。它的key、value都可以爲null。此外,HashMap中的映射不是有序的。
 
HashMap有兩個參數影響其性能:“初始容量” 和 “加載因子”。容量是哈希表中桶的數量,初始容量DEFAULT_INITIAL_CAPACITY  只是哈希表在創建時的容量。加載因子是哈希表在其容量自動增加之前可以達到多滿的一種尺度。當哈希表中的條目數超出了加載因子與當前容量的乘積時,則要對該哈希表進行 rehash 操作(即重建內部數據結構),從而哈希表將具有大約兩倍的桶數。

通常,默認加載因子DEFAULT_LOAD_FACTOR 是 0.75, 這是在時間和空間成本上尋求一種折衷。加載因子過高雖然減少了空間開銷,但同時也增加了查詢成本(在大多數 HashMap 類的操作中,包括 get 和 put 操作,都反映了這一點)。在設置初始容量時應該考慮到映射中所需的條目數及其加載因子,以便最大限度地減少 rehash 操作次數(提升性能)。如果初始容量大於最大條目數除以加載因子,則不會發生 rehash 操作。


注意新版本HashMap的變化

學習HashMap前一定要知道JDK1.8中他的實現和之前的JDK版本有了較大變化,在1.6的版本中,只使用數組+鏈表來實現HashMap,我們可以把數組看成一排桶連接起來,所以hash到同一個桶中的key,他們對應的結點是通過鏈表連接起來的。
JDK1.8的版本中HashMap的底層實現是基於Node數組+鏈表+紅黑樹的,結點的key如果hash到同一個桶中,一開始先使用鏈表來連接,當一個桶中的結點超過閥值TREEIFY_THRESHOLD 時,該桶的結構會從鏈表轉換成紅黑樹。當然這其中還設計到另外一個重要的參數MIN_TREEIFY_CAPACITY (默認爲64),在加入結點初期時,同一個桶中的節點數可能會超過TREEIFY_THRESHOLD ,但是如果整個table的節點數不超過MIN_TREEIFY_CAPACITY ,該桶暫時不會向紅黑樹轉化,而是對整個HashMap調用resize方法,把HashMap擴容2倍,然後把原來的table的結點轉換到新的table上。


JDK1.6版本結構圖。




HashMap中重要的成員變量
  1. static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默認初始大小16
  2. static final float DEFAULT_LOAD_FACTOR = 0.75f; //默認負載因子0.75
  3. static final int TREEIFY_THRESHOLD = 8; //鏈表轉紅黑樹的閥值
  4. static final int UNTREEIFY_THRESHOLD = 6; //紅黑樹轉鏈表閥值
  5. static final int MIN_TREEIFY_CAPACITY = 64; //容量超過64時纔會用紅黑樹


HashMap的hash方法
  1. static final int hash(Object key) {
  2. int h;
  3. return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
  4. }
在這個方法中,可以看到如果key爲null,都是hash到0位置,否則進行(h = key.hashCode()) ^ (h >>> 16)運算獲取hashCode。所以HashMap是允許key爲null的情況的。


HashMap的putVal方法
  1. public V put(K key, V value) { //平常我們使用put方法來存放key和value
  2. return putVal(hash(key), key, value, false, true);
  3. }
  4. final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
  5. boolean evict) {
  6. Node<K,V>[] tab; Node<K,V> p; int n, i;
  7. if ((tab = table) == null || (n = tab.length) == 0)
  8. n = (tab = resize()).length; //如果table爲null或者table大小爲0,調用resize來初始化table
  9. if ((p = tab[i = (n - 1) & hash]) == null) //hash到的桶還沒有保存結點,直接保存到tab[i]中
  10. tab[i] = newNode(hash, key, value, null);
  11. else {
  12. Node<K,V> e; K k;
  13. if (p.hash == hash &&
  14. ((k = p.key) == key || (key != null && key.equals(k))))
  15. e = p;
  16. else if (p instanceof TreeNode) //如果桶中第一個結點就是紅黑樹結點,那該桶是紅黑樹結構,新加入的結點就加入紅黑樹中
  17. e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
  18. else {
  19. for (int binCount = 0; ; ++binCount) {
  20. if ((e = p.next) == null) { //鏈表末尾,所以p.next==null
  21. p.next = newNode(hash, key, value, null); //把新增的結點鏈接到鏈表末尾,連接到鏈表後,下一步就是是否轉紅黑樹判斷
  22. if (binCount >= TREEIFY_THRESHOLD - 1) // 如果同一個桶的節點數達到閥值,就可以把鏈表轉爲紅黑樹
  23. treeifyBin(tab, hash);
  24. break; //保存了結點就可以跳出for循環了
  25. }
  26. if (e.hash == hash &&
  27. ((k = e.key) == key || (key != null && key.equals(k))))
  28. break;
  29. p = e;
  30. }
  31. }
  32. if (e != null) { // 如果說key已經存在,則更新value值,返回舊的value
  33. V oldValue = e.value;
  34. if (!onlyIfAbsent || oldValue == null)
  35. e.value = value;
  36. afterNodeAccess(e);
  37. return oldValue;
  38. }
  39. }
  40. ++modCount; //修改次數+1
  41. if (++size > threshold) //判斷是否超過resize的閥值
  42. resize(); //超過閥值要resize
  43. afterNodeInsertion(evict);
  44. return null;
  45. }

putVal方法可以總結如下:
一,如果table爲null或者table大小爲0,調用resize來初始化table
二,hash到的桶還沒有保存結點,直接保存到tab[i]中
三,如果桶中已經有結點,細分以下步驟
①判斷該桶是否是紅黑樹結構,如果是則以紅黑樹的結點保存下來
②不是紅黑樹結構,先保存到鏈表中,然後檢查鏈表的長度是否達到閥值,達到閥值則調用treeifyBin轉化成紅黑樹,前面說過加入結點初期時,同一個桶中的節點數可能會超過TREEIFY_THRESHOLD ,但是如果整個table的節點數不超過MIN_TREEIFY_CAPACITY ,該桶暫時不會向紅黑樹轉化,而是對整個HashMap調用resize方法,把HashMap擴容2倍,然後把原來的table的結點轉換到新的table上。
四,如果說key已經存在,則更新value值,返回舊的value
五,判斷table的大小是否已經超過resize閥值,如果是要進行resize操作。關於resize方法後面會解析。


HashMap的get方法
  1. public V get(Object key) {
  2. Node<K,V> e;
  3. return (e = getNode(hash(key), key)) == null ? null : e.value;
  4. }

可以看到真正起作用的是getNode方法。

  1. final Node<K,V> getNode(int hash, Object key) {
  2. Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
  3. if ((tab = table) != null && (n = tab.length) > 0 &&
  4. (first = tab[(n - 1) & hash]) != null) {
  5. if (first.hash == hash && // always check first node
  6. ((k = first.key) == key || (key != null && key.equals(k))))
  7. return first;
  8. if ((e = first.next) != null) {
  9. if (first instanceof TreeNode) //如果桶是紅黑樹結構,則用紅黑樹的查找方式查找結點
  10. return ((TreeNode<K,V>)first).getTreeNode(hash, key);
  11. do { //用鏈表的方式查找結點
  12. if (e.hash == hash &&
  13. ((k = e.key) == key || (key != null && key.equals(k))))
  14. return e;
  15. } while ((e = e.next) != null);
  16. }
  17. }
  18. return null; //結果可能爲null
  19. }

getNode方法可以總結如下:
一,獲取hash值對應的桶,以及該桶的首節點first
二,如果first結點是紅黑樹結點,在紅黑樹中查找結點
三,如果first結點是鏈表結點,在鏈表中查找
四,返回結果值Node或null



HashMap的remove方法
  1. public V remove(Object key) {
  2. Node<K,V> e;
  3. return (e = removeNode(hash(key), key, null, false, true)) == null ?
  4. null : e.value;
  5. }

  1. final Node<K,V> removeNode(int hash, Object key, Object value,
  2. boolean matchValue, boolean movable) {
  3. Node<K,V>[] tab; Node<K,V> p; int n, index;
  4. if ((tab = table) != null && (n = tab.length) > 0 &&
  5. (p = tab[index = (n - 1) & hash]) != null) {
  6. Node<K,V> node = null, e; K k; V v;
  7. if (p.hash == hash &&
  8. ((k = p.key) == key || (key != null && key.equals(k))))
  9. node = p; //先找到key所對應的桶,如果key所對應的結點就在首節點,那麼把node指向首節點,即要刪除的結點
  10. else if ((e = p.next) != null) { //key對應的結點不在首節點,查找後面的結點
  11. if (p instanceof TreeNode) //如果首節點是紅黑樹結點,在紅黑樹中查找key對應的結點
  12. node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
  13. else {
  14. do { //在鏈表中查找key對應的結點
  15. if (e.hash == hash &&
  16. ((k = e.key) == key ||
  17. (key != null && key.equals(k)))) {
  18. node = e;
  19. break;
  20. }
  21. p = e;
  22. } while ((e = e.next) != null);
  23. }
  24. }
  25. if (node != null && (!matchValue || (v = node.value) == value ||
  26. (value != null && value.equals(v)))) {
  27. if (node instanceof TreeNode) //如果是樹節點,從紅黑樹中刪除結點
  28. ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
  29. else if (node == p) //鏈表的情況,修改鏈表
  30. tab[index] = node.next;
  31. else
  32. p.next = node.next;
  33. ++modCount;
  34. --size;
  35. afterNodeRemoval(node);
  36. return node;
  37. }
  38. }
  39. return null;
  40. }

removeNode方法可以總結如下:
一,先找到key所對應的桶,如果key所對應的結點就在首節點,那麼把node指向首節點,即要刪除的結點
二,如果key對應的結點不在桶的首節點,則該節點在鏈表或者紅黑樹中,如果桶的首節點是紅黑樹結點,在紅黑樹中查找key對應的結點,否則在鏈表中查找
三,去紅黑樹或者鏈表中刪除結點
四,返回節點Node


HashMap的containsValue方法
  1. public boolean containsValue(Object value) {
  2. Node<K,V>[] tab; V v;
  3. if ((tab = table) != null && size > 0) {
  4. for (int i = 0; i < tab.length; ++i) {
  5. for (Node<K,V> e = tab[i]; e != null; e = e.next) {
  6. if ((v = e.value) == value ||
  7. (value != null && value.equals(v)))
  8. return true;
  9. }
  10. }
  11. }
  12. return false;
  13. }

雖然該集合用了紅黑樹+鏈表,但是這個方法沒有使用到紅黑樹,就是用很簡單的for循環暴力搜索。


HashMap的resize方法
調用put方法時,如果發現目前的bucket佔用程度已經超過了loadFactor,就會發生resize。簡單的說就是把bucket擴充爲2倍,之後重新計算index,把節點再放到新的bucket中。
方法的註釋如下。
  1. /**
  2. * Initializes or doubles table size. If null, allocates in
  3. * accord with initial capacity target held in field threshold.
  4. * Otherwise, because we are using power-of-two expansion, the
  5. * elements from each bin must either stay at same index, or move
  6. * with a power of two offset in the new table.
  7. *
  8. * @return the table
  9. */
當超過限制的時候會resize,又因爲我們使用的是2次冪的擴展,所以,元素的位置要麼是在原位置,要麼是在原位置再移動2次冪的位置。


怎麼理解呢?例如我們從16擴展爲32時,具體的變化如下:



假設bucket大小n=2^k,元素在重新計算hash之後,因爲n變爲2倍,那麼新的位置就是(2^(k+1)-1)&hash。而2^(k+1)-1=2^k+2^k-1,相當於2^k-1的mask範圍在高位多1bit(紅色)(再次提醒,原來的長度n也是2的次冪),這1bit非1即0。如圖:



所以,我們在resize的時候,不需要重新定位,只需要看看原來的hash值新增的那個bit是1還是0就好了,是0的話位置沒變,是1的話位置變成“原位置+oldCap”。代碼比較長就不貼了,下面爲16擴充爲32的resize示意圖:



新增的1bit是0還是1可以認爲是隨機的,因此resize的過程均勻的把之前的衝突的節點分散到新的bucket中了。



HashMap線程不安全性
測試以下demo。(出自https://tech.meituan.com/java-hashmap.html)
  1. public class HashMapInfiniteLoop {
  2. private static HashMap<Integer,String> map = new HashMap<Integer,String>(2,0.75f);
  3. public static void main(String[] args) {
  4. map.put(5,"C");
  5. new Thread("Thread1") {
  6. public void run() {
  7. map.put(7, "B");
  8. System.out.println(map);
  9. };
  10. }.start();
  11. new Thread("Thread2") {
  12. public void run() {
  13. map.put(3,"A");
  14. System.out.println(map);
  15. };
  16. }.start();
  17. }
  18. }
在JDK1.6環境下有可能出現下面的結果。
第一次執行結果:
{5=C, 3=A, 7=B}
{5=C, 3=A, 7=B}

第二次執行結果:
{5=C, 7=B}
{5=C, 3=A, 7=B}

第三次執行結果:
{5=C, 3=A}
{5=C, 7=B, 3=A}


在JDK1.8環境下均有可能出現下面的結果。
第一次執行結果:
{5=C, 7=B}
{5=C, 7=B, 3=A}

第二次執行結果:
{5=C, 7=B}
{5=C, 7=B}

第三次執行結果:
{5=C, 7=B, 3=A}
{5=C, 7=B, 3=A}

但是上面的這些結果只是表明HashMap是線程不安全的。關於JDK1.6下HashMap多線程造成死循環的問題還要另外分析。



多線程環境下HashMap如何形成死循環?
多線程環境下,在JDK1.7下操作HashMap可能會引起死循環,而在JDK1.8中同樣的前提下不會引起死循環。原因是擴容轉移後前後鏈表順序不變,保持之前節點的引用關係。那麼爲什麼HashMap依然是線程不安全的,通過源碼看到put/get方法都沒有加同步鎖,多線程情況最容易出現的就是:無法保證上一秒put的值,下一秒get的時候還是原值,建議併發情況下使用ConcurrentHashMap。

關於引起死循環的問題,強烈建議閱讀此文https://juejin.im/post/5a255bbd6fb9a0450c493f4d#heading-2










發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章