一、HashMap集合簡介
- HashMap基於哈希表的Map接口實現,是以key-value存鍺形式存在,即主要用來存放鍵值對。HashMap的實現不是同步的,這意味着它不是線程安全的。它的key、value都可以為null,此外,HashMap中的映射不是有序的。
- jdk1.8之前HashMap由數組+鏈表組成的,數組是HashMap的主體,鏈表則是主要為了解決哈希沖突(兩個對象調用的hashCode方法計算的哈希值一致導致計算的教組索引值相同)而存在的(“拉鏈法”解決沖突)。jdk1.8以后在解決哈希沖突時有了較大的變化,當鏈表長度大於閾值(或者紅黑樹的邊界值,默認為8)並且當前數組的長度大於64時,此時此索引位置上的所有數據改為使用紅黑樹存儲。
- 補充:將鏈表轉換成紅黑樹前會判斷,即便閾值大於8,但是數組長度小於64,此時並不會將鏈表變為紅黑樹,而是選擇逬行數組擴容。
- 這樣做的目的是因為數組比較小,盡量避開紅黑樹結構,這種情況下變為紅黑樹結構,反而會降低效率,因為紅黑樹需要逬行左旋,右旋,變色這些操作來保持平衡。同時數組長度小於64時,搜索時間相對要快些。所以結上所述為了提高性能和減少搜索時間,底層閾值大於8並且數組長度大於64時,鏈表才轉換為紅黑樹,具體可以參考 treeifyBin() 方法。
- 當然雖然增了紅黑樹作為底層數據結構,結構變得復雜了,但是閾值大於8並且數組長度大於64時,鏈表轉換為紅黑樹時,效率也變的更高效。
小結:
特點:
1.存儲無序的
2.鍵和值位置都可以是null,但是鍵位置只能是一個null
3.鍵位置是唯一的,底層的數據結構控制的
4.jdk1.8前數據結構是鏈表+數組,jdk1.8之后是鏈表+數組+紅黑樹
5.閾值(邊界值)>8並且數組長度大於64,才將鏈表轉換為紅黑樹,變為紅黑樹的目的是為了高效的查詢。
二、HashMap集合底層的數據結構
2.1 數據結構概念
數據結構是計算機存儲、組織數據的方式。數據結構是指互相之間存在一種或多種特定關系的數據元素的集合。通常情況下,精心選擇的數據結構可以帶來更高效的運行或者存儲效率。數據結構往往同高效的檢索算法和索引技術有關。
2.2 HashMap底層的數據結構存儲數據的過程
存儲過程如下所示:
HashMap<String, Integer> map = new HashMap<>(); map.put("柳岩", 18); map.put("楊冪", 28); map.put("劉德華", 40); map.put("柳岩", 20);
輸出結果:
{楊冪=28, 柳岩=20, 劉德華=40}
如圖所示:
1.HashMap<String, Integer> map = new HashMap<>();
當創建HashMap集合對象的時候,在jdk8前,構造方法中創建一個長度是16的 Entry[] table 用來存儲鍵值對數據的。在jdk8以后不是在HashMap的構造方法底層創建數組了,是在第一次調用put方法時創建的數組 Node[] table 用來存儲鍵值對數據。
2.假設向哈希表中存箱<柳岩,18>數據,根據柳岩調用String類中重寫之后的 hashCode() 方法計算出值,然后結合數組長度采用某種算法(無符號右移等等)計算出向Node數組中存儲數據的空間的索引值。如果計算出的索引空間沒有數據,則直接將<柳岩,18>存儲到數組中。(舉例:計算出的索引是3)
3.向哈希表中存儲數據<劉德華,40>,假設算出的 hashCode() 方法結合數祖長度計算出的索引值也是3,那么此時數組空間不是null,此時底層會比較柳岩和劉德華的hash值是否一致,如果不一致,則在空間上划出一個結點來存儲鍵值對數據對<劉德華,40>,這種方式稱為拉鏈法。
4.假設向哈希表中存儲數據<柳岩,20>,那么首先根據柳岩調用 hashCode() 方法結合數組長度計算出索引肯定3,此時比較后存儲的數據柳岩和已經存在的數據的hash值是否相等,如果hash值相等,此時發生哈希碰撞。那么底層會調用柳岩所屬類 String 中的 equals() 方法比較兩個內容是否相等:
相等:將后添加的數據的value覆蓋之前的value。
不相等:繼續向下和其他的數據的key進行比較,如果都不相等,則划出一個結點存儲數據,如果結點長度即鏈表長度大於閾值8並且數組長度大於64則將鏈表變為紅黑樹。
5.在不斷的添加數據的過程中,會涉及到擴容問題,當超出閾值(且要存放的位置非空)時,擴容。默認的擴容方式:擴容為原來容量的2倍,並將原有的數據復制到新的數組中來。
6.綜上描述,當位於一個表中的元素較多,即hash值相等但是內容不相等的元素較多時,通過key值依次查找的效率較低。而jdk1.8中,哈希表存儲采用數組+鏈表+紅黑樹實現,當鏈表長度(閾值)超過8且當前數組的長度大於64時,將鏈表轉換為紅黑樹,這樣大大減少了查找時間。jdk8在哈希表中引入紅黑樹的原因只是為了查找效率更高。
簡單的來說,哈希表是由數組+鏈表+紅黑樹(JDK1.8增加了紅黑樹部分)實現的。如下圖所示:
7.傳統hashMap的缺點,1.8為什么引入紅黑樹?這樣結構的話不是更麻煩了嗎,為何閾值大於8換成紅黑樹?
jdk1.8以前HashMap的實現是數組+鏈表,即使哈希函數取得再好,也很難達到元素百分百均勻分布。當HashMap中有大量的元素都存放到同一個桶中時,這個桶下有一條長長的鏈表,這個時候HashMap就相當於一個單鏈表,假如單鏈表有n個元素,遍歷的時間復雜度就是O(n),完全失去了它的優勢。
針對這種情況,jdk1.8中引入了紅黑樹(查找時間復雜度為O(logn))來優化這個問題。當鏈表長度很小的時候,即使遍歷,速度也非常快,但是當鏈表長度不斷變長,肯定會對查詢性能有一定的影響,所以才需要轉成樹。
總結:
說明:
- size表示HashMap中鍵值對的實時數量,注意這個不等於數組的長度。
- threshold(臨界值)= capacity(容量)* loadFactor(負載因子)。這個值是當前已占用數組長度的最大值。size超過這個值就重新resize(擴容),擴容后的HashMap容量是之前容量的兩倍。
2.3 面試題
- HashMap中hash函數是怎么實現的?還有哪些hash函數的實現方式?
答:對於key的hashCode做hash操作,無符號右移16位然后做異或運算。還有平方取中法,偽隨機數法和取余數法。這三種效率都比較低。而無符號右移16位異或運算效率是最高的。
- 當兩個對象的hashcode相等時會怎么樣?
答:會產生哈希碰撞。若key值內容相同則替換舊的value,不然連接到鏈表后面,鏈表長度超過閾值8就轉換為紅黑樹存儲。
- 何時發生哈希碰撞和什么是哈希碰撞,如何解決哈希碰撞?
答:只要兩個元素的key計算的哈希碼值相同就會發生哈希碰撞。jdk8之前使用鏈表解決哈希碰撞。jdk8之后使用鏈表+紅黑樹解決哈希碰撞。(通過equals)
- 如果兩個鍵的hashcode相同,如何存儲鍵值對?
答:通過equals比較內容是否相同。相同:則新的value覆蓋之前的value。不相同:則將新的鍵值對添加到哈希表中。
三、HashMap繼承關系
HashMap繼承關系如下圖所示:
說明:
- Cloneable空接口,表示可以克隆。創建並返回HashMap對象的一個副本。
- Serializable序列化接口。屬於標記性接口。HashMap對象可以被序列化和反序列化。
- AbstractMap父類提供了 Map實現接口。以最大限度地減少實現此接口所需的工作。
補充:
- 通過上述繼承關系我們發現一個很奇怪的現象,就是HashMap已經繼承了AbstractMap而AbstractMap類實現了Map接口,那為什么HashMap還要在實現Map接口呢?同樣在ArrayList中LinkedLis沖都是這種結構。
- 據java集合框架的創始人 Josh Bloch 描述,這樣的寫法是一個失誤。在java集合框架中,類似這樣的寫法很多,最幵始寫java集合框架的時候,他認為這樣寫,在某些地方可能是有價值的,直到他意識到錯了。顯然的,jdk的維護者,后來不認為這個小小的失誤值得去修改,所以就這樣存在下來了。
四、HashMap集合類的成員
4.1 成員變量
-
序列化版本號
private static final long serialVersionUID = 362498820763181265L;
2.集合的初始化容量(必須是2的n次冪)
// 默認的初始容量是16 1 << 4 相當於 1*2的4次方 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
問題:為什么必須是2的n次冪?如果輸入值不是2的冪比如10會怎么樣?
HashMap構造方法還可以指定集合的初始化容量大小:
HashMap(int initialCapacity) // 構造一個帶指定初始容量和默認負載因子(0.75)的空HashMap。
- 當向HashMap中添加一個元素的時候,需要根據key的hash值,去確定其在數組中的具體位置。HashMap為了存取高效,減少碰撞,就是要盡量把數據分配均勻,每個鏈表長度大致相同,這個實現就在把數據存到哪個鏈表中的算法。
- 這個算法實際就是取模,hash%length,計算機中直接求余效率不如位移運算。所以源碼中做了優化,使用 hash&(length-1),而實際上 hash%length 等於 hash&(length-1) 的前提是length是2的n次冪。
例如長度為8的時候,3 & (8 - 1) = 3,2 & (8-1) = 2,不同位置上,不碰撞。
/** * Returns a power of two size for the given target capacity. */ 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; }
說明:
- 當在實例化HashMap實例時,如果給定了initialCapacity,由於HashMap的capacity必須都是2的冪,因此這個方法用於找到大於等於initialCapacity的最小的2的冪。
分析:
int n = cap - 1;
防止cap已經是2的冪。如果cap已經是2的冪,又沒有這個減1操作,則執行完后面的幾條無符號操作之后,返回的capacity將是這個cap的2倍。
如果n這時為0了(經過了cap - 1后),則經過后面的幾次無符號右移依然是0,最后返回的capacity是1(最后有個n + 1的操作)。
注意:容量最大也就是32bit的正數,因此最后 n |= n >>> 16; 最多也就32個1(但是這已經是負數了,在執行tableSizeFor之前,對initialCapacity做了判斷,如果大於MAXIMUM_CAPACITY(2 ^ 30),則取MAXIMUM_CAPACITY。如果等於MAXIMUM_CAPACITY,會執行位移操作。所以這里面的位移操作之后,最大30個1,不會大於等於MAXIMUM_CAPACITY。30個1,加1后得2 ^ 30)。
完整例子:
注意:得到的這個capacity卻被賦值給了threshold
1. this.threshold = tableSizeFor(initialCapacity);
2. 默認的負載因子,默認值是0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
3. 集合最大容量
static final int MAXIMUM_CAPACITY = 1 << 30; // 2的30次冪
4. 當鏈表的值超過8則會轉為紅黑樹(jdk1.8新增)
// 當桶(bucket)上的結點數大於這個值時會轉為紅黑樹 static final int TREEIFY_THRESHOLD = 8;
問題:為什么Map桶中結點個數超過8才轉為紅黑樹?
- 8這個閾值定義在HashMap中,針對這個成員變量,在源碼的注釋中只說明了8是bin(bin就是bucket桶)從鏈表轉成樹的閾值,但是並沒有說明為什么是8;
在HashMap中有一段注釋說明;
Because TreeNodes are about twice the size of regular nodes, we use them only when bins contain enough nodes to warrant use (see TREEIFY_THRESHOLD). And when they become too small (due to removal or resizing) they are converted back to plain bins. In usages with well-distributed user hashCodes, tree bins are rarely used. Ideally, under random hashCodes, the frequency of nodes in bins follows a Poisson distribution (http://en.wikipedia.org/wiki/Poisson_distribution) with a parameter of about 0.5 on average for the default resizing threshold of 0.75, although with a large variance because of resizing granularity. Ignoring variance, the expected occurrences of list size k are (exp(-0.5) * pow(0.5, k) / factorial(k)). The first values are: 翻譯:因為樹結點的大小大約是普通結點的兩倍,所以我們只在箱子包含足夠的結點時才使用樹結點(參見 TREEIFY_THRESHOLD)。當它們變得太小(由於刪除或調整大小)時,就會被轉換回普通的捅。在使用分布良好的用戶 hashCode 時,很少使用樹箱。理想情況下,在隨機哈希碼下,箱子中結點的頻率服從泊松分布 (http://en.wikipedia.org/wiki/Poisson_distribution) ,默認調整閾值為0.75,平均參數約為0.5,盡管由 於調整粒度的差異很大。忽略方差,列表大小k的預朗出現次數是(exp(-0.5) * pow(0.5, k) / factorial(k))。 第一個值是: 0: 0.60653066 1: 0.30326533 2: 0.07581633 3: 0.01263606 4: 0.00157952 5: 0.00015795 6: 0.00001316 7: 0.00000094 8: 0.00000006 more: less than 1 in ten million
- TreeNodes占用空間是普通Nodes的兩倍,所以只有當bin包含足夠多的結點時才會轉成TreeNodes,而是否足夠多就是由TREEIFY_THRESH〇LD的值決定的。當bin中結點數變少時,又會轉成普通的bin。並且我們查看源碼的時候發現,鏈表長度達到8就轉成紅黑樹,當長度降到6就轉成普通bin。
- 這樣就解釋了為什么不是一開始就將其轉換為TreeNodes,而是需要一定結點數才轉為TreeNodes,說白了就是權衡空間和時間。
- 這段內容還說到:當hashCode離散性很好的時候,樹型bin用到的概率非常小,因為數據均勻分布在每個bin中,幾乎不會有bin中鏈表長度會達到閾值。但是在隨機hashCode下,離散性可能會變差,然而jdk又不能阻止用戶實現這種不好的hash算法,因此就可能導致不均勻的數據分布。不理想情況下隨機hashCode算法下所有bin中結點的分布頻率會遵循泊松分布,我們可以看到,一個bin中鏈表長度達到8個元素的槪率為0.00000006,幾乎是不可能事件。所以,之所以選擇8,不是隨便決定的,而是裉據概率統計決定的。甶此可見,發展將近30年的java每一項改動和優化都是非常嚴謹和科學的。
- 也就是說:選擇8因為符合泊松分布,超過8的時候,概率已經非常小了,所以我們選擇8這個數宇。
補充:
- Poisson分布(泊松分布),是一種統計與概率學里常見到的離散[概率分布]。泊松分布的概率函數為:
- 泊松分布的參數A是單位時間(或單位面積)內隨機事件的平均發生次數。泊松分布適合於描述單位時間內隨機事件發生的次數。
- 紅黑樹的平均查找長度是log(n),如果長度為8,平均查找長度為log(8) = 3,鏈表的平均查找長度為n/2,當長度為8時,平均查找長虔為8/2 = 4,這才有轉換成樹的必要;鏈表長度如果是小於等於6, 6/2 = 3,而log(6) = 2.6,雖然速度也很快的,但是轉化為樹結構和生成樹的時間並不會太短。
5.當鏈表的值小於6則會從紅黑樹轉回鏈表
// 當捅(bucket)上的結點數小於這個值,樹轉為鏈表 static final int UNTREEIFY_THRESHOLD = 6;
6. MIN_TREEIFY_CAPACITY
當Map里面的數量超過這個值時,表中的桶才能進行樹形化,否則桶內元素太多時會擴容,而不是樹形化為了避免進行擴容、樹形化選擇的沖突,這個值不能小於4*TREEIFY_THRESHOLD(8)
// 捅中結構轉化為紅黑樹對應的數組長度最小的值 static final int MIN_TREEIFY_CAPACITY = 64;
7.table用來初始化(必須是二的n次冪)(重點)
// 存儲元素的數組 transient Node<K,V>[] table;
table在jdk1.8中我們了解到HashMap是由數組加鏈表加紅黑樹來組成的結構其中table就是HashMap中的數組,jdk8之前數組類型是Entry<K,V>類型。從jdk1.8之后是Node<K,V>類型。只是換了個名字,都實現了一樣的接口:Map.Entry<K,V>。負責存儲鍵值對數據的。
8.用來存放緩存
// 存放具體元素的集合 transient Set<Map.Entry<K,V>> entrySet;
9.HashMap中存放元素的個數(重點)
// 存放元素的個數,注意這個不等於數組的長度 transient int size;
size為HashMap中K-V的實時數量,不是數組table的長度。
10.用來記錄HashMap的修改次數
// 每次擴容和更改map結構的計數器 transient int modCount;
11.用來調整大小下一個容量的值計算方式為(容量*負載因子)
// 臨界值 當實際大小(容量*負載因子)超過臨界值時,會進行擴容 int threshold;
12.哈希表的負載因子(重點)
// 負載因子 final float loadFactor;
說明:
- loadFactor 負載因子,是用來衡量HashMap滿的程度,表示HashMap的疏密程度,影響hash操作到同一個數組位置的概率,計算HashMap的實時負載因子的方法為:size/capacity,而不是占用桶的數量去除以capacity。capacity 是桶的數量,也就是 table 的長度 length。
- loadFactor太大導致查找元素效率低,太小導致數組的利用率低,存放的數據會很分散。loadFactor的默認值為0.75f是官方給出的一個比較好的臨界值。
- 當HashMap里面容納的元素已經達到HashMap數組長度的75%時,表示HashMap太擠了,需要擴容,而擴容這個過程涉及到 rehash、復制數據等操作,非常消耗性能。所以開發中盡量減少擴容的次數,可以通過創建HashMap集合對象時指定初始容量來盡量避免。
同時在HashMap的構造器中可以定制loadFactor。
// 構造方法,構造一個帶指定初始容量和負載因子的空HashMap HashMap(int initialCapacity, float loadFactor);
-
為什么負載因子設置為0.75,初始化臨界值是12?
loadFactor越趨近於1,那么 數組中存放的數據(entry)也就越多,也就越密,也就是會讓鏈表的長度增加,loadFactor越小,也就是趨近於0,數組中存放的數據(entry)也就越少,也就越稀疏。
如果希望鏈表盡可能少些,要提前擴容。有的數組空間有可能一直沒有存儲數據,負載因子盡可能小一些。
舉例:
例如:負載因子是0.4。 那么16*0.4--->6 如果數組中滿6個空間就擴容會造成數組利用率太低了。
負載因子是0.9。 那么16*0.9---->14 那么這樣就會導致鏈表有點多了。導致查找元素效率低。
所以既兼顧數組利用率又考慮鏈表不要太多,經過大量測試0.75是最佳方案。
- threshold計算公式:capacity(數組長度默認16) * loadFactor(負載因子默認0.75)。這個值是當前已占用數組長度的最大值。當Size >= threshold的時候,那么就要考慮對數組的resize(擴容),也就是說,這個的意思就是 衡量數組是否需要擴增的一個標准。 擴容后的 HashMap 容量是之前容量的兩倍。
4.2 構造方法
HashMap 中重要的構造方法,它們分別如下:
-
構造一個空的HashMap,默認初始容量(16)和默認負載因子(0.75)
public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // 將默認的負載因子0.75賦值給loadFactor,並沒有創建數組 }
2.構造一個具有指定的初始容量和默認負載因子(0.75)HashMap
// 指定“容量大小”的構造函數 public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); }
3.構造一個具有指定的初始容量和負載因子的 HashMap
/* 指定“容量大小”和“負載因子”的構造函數 initialCapacity:指定的容量 loadFactor:指定的負載因子 */ public HashMap(int initialCapacity, float loadFactor) { // 判斷初始化容量initialCapacity是否小於0 if (initialCapacity < 0) // 如果小於0,則拋出非法的參數異常IllegalArgumentException throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); // 判斷初始化容量initialCapacity是否大於集合的最大容量MAXIMUM_CAPACITY if (initialCapacity > MAXIMUM_CAPACITY) // 如果超過MAXIMUM_CAPACITY,會將MAXIMUM_CAPACITY賦值給initialCapacity initialCapacity = MAXIMUM_CAPACITY; // 判斷負載因子loadFactor是否小於等於0或者是否是一個非數值 if (loadFactor <= 0 || Float.isNaN(loadFactor)) // 如果滿足上述其中之一,則拋出非法的參數異常IllegalArgumentException throw new IllegalArgumentException("Illegal load factor: " + loadFactor); // 將指定的負載因子賦值給HashMap成員變量的負載因子loadFactor this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); } // 最后調用了tableSizeFor,來看一下方法實現: /* 返回比指定初始化容量大的最小的2的n次冪 */ 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; }
說明:
對於 javathis.threshold = tableSizeFor(initialCapacity); 疑問解答:
tableSizeFor(initialCapacity)判斷指定的初始化容量是否是2的n次冪,如果不是那么會變為比指定初始化容量大的最小的2的n次冪。 但是注意,在tableSizeFor方法體內部將計算后的數據返回給調用這里了,並且直接賦值給threshold邊界值了。有些人會覺得這里是一個bug,應該這樣書寫: this.threshold = tableSizeFor(initialCapacity) * this.loadFactor; 這樣才符合threshold的意思(當HashMap的size到達threshold這個閾值時會擴容)。 但是請注意,在jdk8以后的構造方法中,並沒有對table這個成員變量進行初始化,table的初始化被推遲到了put方法中,在put方法中會對threshold重新計算。
4.包含另一個“Map”的構造函數
// 構造一個映射關系與指定 Map 相同的新 HashMap。 public HashMap(Map<? extends K, ? extends V> m) { // 負載因子loadFactor變為默認的負載因子0.75 this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }
最后調用了 putMapEntries(),來看一下方法實現:
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) { //獲取參數集合的長度 int s = m.size(); if (s > 0) { //判斷參數集合的長度是否大於0,說明大於0 if (table == null) { // 判斷table是否已經初始化 // 未初始化,s為m的實際元素個數 float ft = ((float)s / loadFactor) + 1.0F; int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY); // 計算得到的t大於閾值,則初始化閾值 if (t > threshold) threshold = tableSizeFor(t); } // 已初始化,並且m元素個數大於閾值,進行擴容處理 else if (s > threshold) resize(); // 將m中的所有元素添加至HashMap中 for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) { K key = e.getKey(); V value = e.getValue(); putVal(hash(key), key, value, false, evict); } } }
注意:
float ft = ((float)s / loadFactor) + 1.0F; 這一行代碼中為什么要加1.0F ?
s/loadFactor的結果是小數,加1.0F與(int)ft相當於是對小數做一個向上取整以盡可能的保證更大容量,更大的容量能夠減少resize的調用次數。所以 + 1.0F是為了獲取更大的容量。
例如:原來集合的元素個數是6個,那么6/0.75是8,是2的n次冪,那么新的數組大小就是8了。然后原來數組的數據就會存儲到長度是8的新的數組中了,這樣會導致在存儲元素的時候,容量不夠,還得繼續擴容,那么性能降低了,而如果+1呢,數組長度直接變為16了,這樣可以減少數組的擴容。
4.3成員方法
4.3.1 增加方法
put方法是比較復雜的,實現步驟大致如下:
- 先通過hash值計算出key映射到哪個桶;
- 如果桶上沒有碰撞沖突,則直接插入;
- 如果出現碰撞沖突了,則需要處理沖突:
- a 如果該桶使用紅黑樹處理沖突,則調用紅黑樹的方法插入數據;
- b 否則采用傳統的鏈式方法插入。如果鏈的長度達到臨界值,則把鏈轉變為紅黑樹;
- 如果桶中存在重復的鍵,則為該鍵替換新值value;
- 如果size大於閾值threshold,則進行擴容;
具體的方法如下
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
說明:
- HashMap只提供了put用於添加元素,putVal方法只是給put方法調用的一個方法,並沒有提供給用戶使用。 所以我們重點看putVal方法。
- 我們可以看到在putVal()方法中key在這里執行了一下hash()方法,來看一下Hash方法是如何實現的。
static final int hash(Object key) { int h; /* 1)如果key等於null:返回的是0. 2)如果key不等於null:首先計算出key的hashCode賦值給h,然后與h無符號右移16位后的二進制進行按位異或得到最后的hash值 */ return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
從上面可以得知HashMap是支持Key為空的,而HashTable是直接用Key來獲取HashCode所以key為空會拋異常。
解讀上述hash方法:
我們先研究下key的哈希值是如何計算出來的。key的哈希值是通過上述方法計算出來的。
這個哈希方法首先計算出key的hashCode賦值給h,然后與h無符號右移16位后的二進制進行按位異或得到最后的 hash值。計算過程如下所示:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
在putVal函數中使用到了上述hash函數計算的哈希值:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { ... if ((p = tab[i = (n - 1) & hash]) == null) // 這里的n表示數組長度16 ... }
計算過程如下所示:
說明:
- key.hashCode();返回散列值也就是hashcode,假設隨便生成的一個值。
- n表示數組初始化的長度是16。
- &(按位與運算):運算規則:相同的二進制數位上,都是1的時候,結果為1,否則為零。
- ^(按位異或運算):運算規則:相同的二進制數位上,數字相同,結果為0,不同為1。
簡單來說就是:
高16bit不變,低16bit和高16bit做了一個異或(得到的hashCode轉化為32位二進制,前16位和后16位低16bit和高16bit做了一個異或)
問題:為什么要這樣操作呢?
如果當n即數組長度很小,假設是16的話,那么n - 1即為1111 ,這樣的值和hashCode直接做按位與操作,實際上只使用了哈希值的后4位。如果當哈希值的高位變化很大,低位變化很小,這樣就很容易造成哈希沖突了,所以這里把高低位都利用起來,從而解決了這個問題。
現在看putVal()方法,看看它到底做了什么。
主要參數:
- hash key的hash值
- key 原始Key
- value 要存放的值
- onlyIfAbsent 如果true代表不更改現有的值
- evict 如果為false表示table為創建狀態
putVal()方法源代碼如下所示:
public V put(K key, V value) { 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; /* 1)transient Node<K,V>[] table; 表示存儲Map集合中元素的數組。 2)(tab = table) == null 表示將空的table賦值給tab,然后判斷tab是否等於null,第一次肯定是null。 3)(n = tab.length) == 0 表示將數組的長度0賦值給n,然后判斷n是否等於0,n等於0,由於if判斷使用雙或,滿足一個即可,則執行代碼 n = (tab = resize()).length; 進行數組初始化,並將初始化好的數組長度賦值給n。 4)執行完n = (tab = resize()).length,數組tab每個空間都是null。 */ if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; /* 1)i = (n - 1) & hash 表示計算數組的索引賦值給i,即確定元素存放在哪個桶中。 2)p = tab[i = (n - 1) & hash]表示獲取計算出的位置的數據賦值給結點p。 3) (p = tab[i = (n - 1) & hash]) == null 判斷結點位置是否等於null,如果為null,則執行代碼:tab[i] = newNode(hash, key, value, null);根據鍵值對創建新的結點放入該位置的桶中。 小結:如果當前桶沒有哈希碰撞沖突,則直接把鍵值對插入空間位置。 */ if ((p = tab[i = (n - 1) & hash]) == null) // 創建一個新的結點存入到桶中 tab[i] = newNode(hash, key, value, null); else { // 執行else說明tab[i]不等於null,表示這個位置已經有值了 Node<K,V> e; K k; /* 比較桶中第一個元素(數組中的結點)的hash值和key是否相等 1)p.hash == hash :p.hash表示原來存在數據的hash值 hash表示后添加數據的hash值 比較兩個hash值是否相等。 說明:p表示tab[i],即 newNode(hash, key, value, null)方法返回的Node對象。 Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) { return new Node<>(hash, key, value, next); } 而在Node類中具有成員變量hash用來記錄着之前數據的hash值的。 2)(k = p.key) == key :p.key獲取原來數據的key賦值給k key 表示后添加數據的key比較兩個key的地址值是否相等。 3)key != null && key.equals(k):能夠執行到這里說明兩個key的地址值不相等,那么先判斷后添加的key是否等於null,如果不等於null再調用equals方法判斷兩個key的內容是否相等。 */ if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) /* 說明:兩個元素哈希值相等,並且key的值也相等,將舊的元素整體對象賦值給e,用e來記錄 */ e = p; // hash值不相等或者key不相等;判斷p是否為紅黑樹結點 else if (p instanceof TreeNode) // 放入樹中 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); // 說明是鏈表結點 else { /* 1)如果是鏈表的話需要遍歷到最后結點然后插入 2)采用循環遍歷的方式,判斷鏈表中是否有重復的key */ for (int binCount = 0; ; ++binCount) { /* 1)e = p.next 獲取p的下一個元素賦值給e。 2)(e = p.next) == null 判斷p.next是否等於null,等於null,說明p沒有下一個元素,那么此時到達了鏈表的尾部,還沒有找到重復的key,則說明HashMap沒有包含該鍵,將該鍵值對插入鏈表中。 */ if ((e = p.next) == null) { /* 1)創建一個新的結點插入到尾部 p.next = newNode(hash, key, value, null); Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) { return new Node<>(hash, key, value, next); } 注意第四個參數next是null,因為當前元素插入到鏈表末尾了,那么下一個結點肯定是null。 2)這種添加方式也滿足鏈表數據結構的特點,每次向后添加新的元素。 */ p.next = newNode(hash, key, value, null); /* 1)結點添加完成之后判斷此時結點個數是否大於TREEIFY_THRESHOLD臨界值8,如果大於則將鏈表轉換為紅黑樹。 2)int binCount = 0 :表示for循環的初始化值。從0開始計數。記錄着遍歷結點的個數。值是0表示第一個結點,1表示第二個結點。。。。7表示第八個結點,加上數組中的的一個元素,元素個數是9。 TREEIFY_THRESHOLD - 1 --》8 - 1 ---》7 如果binCount的值是7(加上數組中的的一個元素,元素個數是9) TREEIFY_THRESHOLD - 1也是7,此時轉換紅黑樹。 */ if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st // 轉換為紅黑樹 treeifyBin(tab, hash); // 跳出循環 break; } /* 執行到這里說明e = p.next 不是null,不是最后一個元素。繼續判斷鏈表中結點的key值與插入的元素的key值是否相等。 */ if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) // 相等,跳出循環 /* 要添加的元素和鏈表中的存在的元素的key相等了,則跳出for循環。不用再繼續比較了 直接執行下面的if語句去替換去 if (e != null) */ break; /* 說明新添加的元素和當前結點不相等,繼續查找下一個結點。 用於遍歷桶中的鏈表,與前面的e = p.next組合,可以遍歷鏈表 */ p = e; } } /* 表示在桶中找到key值、hash值與插入元素相等的結點 也就是說通過上面的操作找到了重復的鍵,所以這里就是把該鍵的值變為新的值,並返回舊值 這里完成了put方法的修改功能 */ if (e != null) { // 記錄e的value V oldValue = e.value; // onlyIfAbsent為false或者舊值為null if (!onlyIfAbsent || oldValue == null) // 用新值替換舊值 // e.value 表示舊值 value表示新值 e.value = value; // 訪問后回調 afterNodeAccess(e); // 返回舊值 return oldValue; } } // 修改記錄次數 ++modCount; // 判斷實際大小是否大於threshold閾值,如果超過則擴容 if (++size > threshold) resize(); // 插入后回調 afterNodeInsertion(evict); return null; }
4.3.2 將鏈表轉換為紅黑樹的treeifyBin方法
結點添加完成之后判斷此時結點個數是否大於TREEIFY_THRESHOLD臨界值8,如果大於則將鏈表轉換為紅黑樹,轉換紅黑樹的方法 treeifyBin,整體代碼如下:
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st //轉換為紅黑樹 tab表示數組名 hash表示哈希值 treeifyBin(tab, hash);
treeifyBin方法如下所示:
/* 替換指定哈希表的索引處桶中的所有鏈接結點,除非表太小,否則將修改大小。 Node<K,V>[] tab = tab 數組名 int hash = hash表示哈希值 */ final void treeifyBin(Node<K,V>[] tab, int hash) { int n, index; Node<K,V> e; /* 如果當前數組為空或者數組的長度小於進行樹形化的閾值(MIN_TREEIFY_CAPACITY = 64),就去擴容。而不是將結點變為紅黑樹。 目的:如果數組很小,那么轉換紅黑樹,然后遍歷效率要低一些。這時進行擴容,那么重新計算哈希值,鏈表長度有可能就變短了,數據會放到數組中,這樣相對來說效率高一些。 */ if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) //擴容方法 resize(); else if ((e = tab[index = (n - 1) & hash]) != null) { /* 1)執行到這里說明哈希表中的數組長度大於閾值64,開始進行樹形化 2)e = tab[index = (n - 1) & hash]表示將數組中的元素取出賦值給e,e是哈希表中指定位置桶里的鏈表結點,從第一個開始 */ // hd:紅黑樹的頭結點 tl:紅黑樹的尾結點 TreeNode<K,V> hd = null, tl = null; do { // 新創建一個樹的結點,內容和當前鏈表結點e一致 TreeNode<K,V> p = replacementTreeNode(e, null); if (tl == null) hd = p; // 將新創鍵的p結點賦值給紅黑樹的頭結點 else { p.prev = tl; // 將上一個結點p賦值給現在的p的前一個結點 tl.next = p; // 將現在結點p作為樹的尾結點的下一個結點 } tl = p; /* e = e.next 將當前結點的下一個結點賦值給e,如果下一個結點不等於null 則回到上面繼續取出鏈表中結點轉換為紅黑樹 */ } while ((e = e.next) != null); /* 讓桶中的第一個元素即數組中的元素指向新建的紅黑樹的結點,以后這個桶里的元素就是紅黑樹 而不是鏈表數據結構了 */ if ((tab[index] = hd) != null) hd.treeify(tab); } }
小結:上述操作一共做了如下幾件事:
- 根據哈希表中元素個數確定是擴容還是樹形化。
- 如果是樹形化遍歷桶中的元素,創建相同個數的樹形結點,復制內容,建立起聯系。
- 然后讓桶中的第一個元素指向新創建的樹根結點,替換桶的鏈表內容為樹形化內容。
4.3.3 擴容方法 resize()
擴容機制
1.什么時候才需要擴容
- 當HashMap中的元素個數超過數組大小(數組長度)*loadFactor(負載因子)時,就會進行數組擴容,loadFactor的默認值(DEFAULT_LOAD_FACTOR)是0.75,這是一個折中的取值。也就是說,默認情況下,數組大小為16,那么當HashMap中的元素個數超過16×0.75=12(這個值就是閾值或者邊界值threshold值)的時候,就把數組的大小擴展為2×16=32,即擴大一倍,然后重新計算每個元素在數組中的位置,而這是一個非常耗性能的操作,所以如果我們已經預知HashMap中元素的個數,那么預知元素的個數能夠有效的提高HashMap的性能。
補充:
- 當HashMap中的其中一個鏈表的對象個數如果達到了8個,此時如果數組長度沒有達到64,那么HashMap會先擴容解決,如果已經達到了64,那么這個鏈表會變成紅黑樹,結點類型由Node變成TreeNode類型。當然,如果映射關系被移除后,下次執行resize方法時判斷樹的結點個數低於6,也會再把樹轉換為鏈表。
2.HashMap的擴容是什么
- 進行擴容,會伴隨着一次重新hash分配,並且會遍歷hash表中所有的元素,是非常耗時的。在編寫程序中,要盡量避免resize。
HashMap在進行擴容時,使用的rehash方式非常巧妙,因為每次擴容都是翻倍,與原來計算的 (n-1)&hash的結果相比,只是多了一個bit位,所以結點要么就在原來的位置,要么就被分配到"原位置+舊容量"這個位置。
例如我們從16擴展為32時,具體的變化如下所示:
因此元素在重新計算hash之后,因為n變為2倍,那么n-1的標記范圍在高位多1bit(紅色),因此新的index就會發生這樣的變化
說明:
5是假設計算出來的原來的索引。這樣就驗證了上述所描述的:擴容之后所以結點要么就在原來的位置,要么就被分配到"原位置+舊容量"這個位置。
因此,我們在擴充HashMap的時候,不需要重新計算hash,只需要看看原來的hash值新增的那個bit是1還是0就可以了,是0的話索引沒變,是1的話索引變成“原索引+oldCap(原位置+舊容量)”。可以看看下圖為16擴充為32的resize示意圖:
正是因為這樣巧妙的rehash方式,既省去了重新計算hash值的時間,而且同時,由於新增的1bit是0還是1可以認為是隨機的,在resize的過程中保證了rehash之后每個桶上的結點數一定小於等於原來桶上的結點數,保證了rehash之后不會出現更嚴重的hash沖突,均勻的把之前的沖突的結點分散到新的桶中了。
源碼resize方法的解讀
下面是代碼的具體實現:
final Node<K,V>[] resize() { // 得到當前數組 Node<K,V>[] oldTab = table; // 如果當前數組等於null長度返回0,否則返回當前數組的長度 int oldCap = (oldTab == null) ? 0 : oldTab.length; //當前閥值點 默認是12(16*0.75) int oldThr = threshold; int newCap, newThr = 0; // 如果老的數組長度大於0 // 開始計算擴容后的大小 if (oldCap > 0) { // 超過最大值就不再擴充了,就只好隨你碰撞去吧 if (oldCap >= MAXIMUM_CAPACITY) { // 修改閾值為int的最大值 threshold = Integer.MAX_VALUE; return oldTab; } /* 沒超過最大值,就擴充為原來的2倍 1) (newCap = oldCap << 1) < MAXIMUM_CAPACITY 擴大到2倍之后容量要小於最大容量 2)oldCap >= DEFAULT_INITIAL_CAPACITY 原數組長度大於等於數組初始化長度16 */ else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) // 閾值擴大一倍 newThr = oldThr << 1; // double threshold } // 老閾值點大於0 直接賦值 else if (oldThr > 0) // 老閾值賦值給新的數組長度 newCap = oldThr; else { // 直接使用默認值 newCap = DEFAULT_INITIAL_CAPACITY;//16 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } // 計算新的resize最大上限 if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } // 新的閥值 默認原來是12 乘以2之后變為24 threshold = newThr; // 創建新的哈希表 @SuppressWarnings({"rawtypes","unchecked"}) //newCap是新的數組長度--》32 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; // 判斷舊數組是否等於空 if (oldTab != null) { // 把每個bucket都移動到新的buckets中 // 遍歷舊的哈希表的每個桶,重新計算桶里元素的新位置 for (int j = 0; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null) { // 原來的數據賦值為null 便於GC回收 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 { // 采用鏈表處理沖突 Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; // 通過上述講解的原理來計算結點的新位置 do { // 原索引 next = e.next; // 這里來判斷如果等於true e這個結點在resize之后不需要移動位置 if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } // 原索引+oldCap else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); // 原索引放到bucket里 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } // 原索引+oldCap放到bucket里 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
4.3.4 刪除方法remove()
刪除的話就是首先先找到元素的位置,如果是鏈表就遍歷鏈表找到元素之后刪除。如果是用紅黑樹就遍歷樹然后找到之后做刪除,樹小於6的時候要轉鏈表。
刪除remove()方法:
// remove方法的具體實現在removeNode方法中,所以我們重點看下removeNode方法 public V remove(Object key) { Node<K,V> e; return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value; }
removeNode()方法:
final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) { Node<K,V>[] tab; Node<K,V> p; int n, index; // 根據hash找到位置 // 如果當前key映射到的桶不為空 if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) { Node<K,V> node = null, e; K k; V v; // 如果桶上的結點就是要找的key,則將node指向該結點 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) node = p; else if ((e = p.next) != null) { // 說明結點存在下一個結點 if (p instanceof TreeNode) // 說明是以紅黑樹來處理的沖突,則獲取紅黑樹要刪除的結點 node = ((TreeNode<K,V>)p).getTreeNode(hash, key); else { // 判斷是否以鏈表方式處理hash沖突,是的話則通過遍歷鏈表來尋找要刪除的結點 do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { node = e; break; } p = e; } while ((e = e.next) != null); } } // 比較找到的key的value和要刪除的是否匹配 if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) { // 通過調用紅黑樹的方法來刪除結點 if (node instanceof TreeNode) ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable); else if (node == p) // 鏈表刪除 tab[index] = node.next; else p.next = node.next; // 記錄修改次數 ++modCount; // 變動的數量 --size; afterNodeRemoval(node); return node; } } return null; }
4.3.5 查找元素方法get()
查找方法,通過元素的Key找到Value。
代碼如下:
public V get(Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; }
get方法主要調用的是getNode方法,代碼如下:
final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; // 如果哈希表不為空並且key對應的桶上不為空 if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { /* 判斷數組元素是否相等 根據索引的位置檢查第一個元素 注意:總是檢查第一個元素 */ if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) return first; // 如果不是第一個元素,判斷是否有后續結點 if ((e = first.next) != null) { // 判斷是否是紅黑樹,是的話調用紅黑樹中的getTreeNode方法獲取結點 if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); do { // 不是紅黑樹的話,那就是鏈表結構了,通過循環的方法判斷鏈表中是否存在該key if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; }
小結:
- get方法實現的步驟:
a. 通過hash值獲取該key映射到的桶
b. 桶上的key就是要查找的key,則直接找到並返回
c. 桶上的key不是要找的key,則查看后續的結點:
如果后續結點是紅黑樹結點,通過調用紅黑樹的方法根據key獲取value
如果后續結點是鏈表結點,則通過循環遍歷鏈表根據key獲取value
2 . 上述紅黑樹結點調用的是getTreeNode方法通過樹形結點的find方法進行查找:
final TreeNode<K,V> getTreeNode(int h, Object k) { return ((parent != null) ? root() : this).find(h, k, null); } final TreeNode<K,V> find(int h, Object k, Class<?> kc) { TreeNode<K,V> p = this; do { int ph, dir; K pk; TreeNode<K,V> pl = p.left, pr = p.right, q; if ((ph = p.hash) > h) p = pl; else if (ph < h) p = pr; else if ((pk = p.key) == k || (k != null && k.equals(pk))) return p; // 找到之后直接返回 else if (pl == null) p = pr; else if (pr == null) p = pl; else if ((kc != null || (kc = comparableClassFor(k)) != null) && (dir = compareComparables(kc, k, pk)) != 0) p = (dir < 0) ? pl : pr; // 遞歸查找 else if ((q = pr.find(h, k, kc)) != null) return q; else p = pl; } while (p != null); return null; }
3. 查找紅黑樹,由於之前添加時已經保證這個樹是有序的了,因此查找時基本就是折半查找,效率更高。
4. 這里和插入時一樣,如果對比結點的哈希值和要查找的哈希值相等,就會判斷key是否相等,相等就直接返回。不相等就從子樹中遞歸查找。
4.1 若為樹,則在樹中通過key.equals(k)查找,O(logn)
4.2 若為鏈表,則在鏈表中通過key.equals(k)查找,O(n)。
4.3.6 遍歷HashMap集合幾種方式
1.分別遍歷Key和Values
for (String key : map.keySet()) { System.out.println(key); } for (Object vlaue : map.values() { System.out.println(value); }
2.使用Iterator迭代器迭代
Iterator<Map.Entry<String, Object>> iterator = map.entrySet().iterator(); while (iterator.hasNext()) { Map.Entry<String, Object> mapEntry = iterator.next(); System.out.println(mapEntry.getKey() + "---" + mapEntry.getValue()); }
3.通過get方式(不建議使用)
Set<String> keySet = map.keySet(); for (String str : keySet) { System.out.println(str + "---" + map.get(str)); }
說明:根據阿里開發手冊,不建議使用這種方式,因為迭代兩次。keySet獲取Iterator一次,還有通過get又迭代一次,降低性能。
4.jdk8以后使用Map接口中的默認方法
default void forEach(BiConsumer<? super K,? super V> action) // BiConsumer接口中的方法: void accept(T t, U u) 對給定的參數執行此操作。 參數 t - 第一個輸入參數 u - 第二個輸入參數
遍歷代碼:
HashMap<String,String> map = new HashMap(); map.put("001", "zhangsan"); map.put("002", "lisi"); map.forEach((key, value) -> { System.out.println(key + "---" + value); });
五、如何設計多個非重復的鍵值對要存儲HashMap的初始化?
5.1 HashMap的初始化問題描述
- 如果我們確切的知道我們有多少鍵值對需要存儲,那么我們在初始化HashMap的時候就應該指定它的容量,以防止HashMap自動擴容,影響使用效率。
- 默認情況下HashMap的容量是16,但是,如果用戶通過構造函數指定了一個數字作為容量,那么Hash會選擇大於該數字的第一個2的冪作為容量(3->4、7->8、9->16)。這點我們在上述已經進行過講解。
《阿里巴巴Java開發手冊》中建議我們設置HashMap的初始化容量。
那么,為什么要這么建議?你有想過沒有。
- 當然,以上建議也是有理論支撐的。我們上面介紹過,HashMap的擴容機制,就是當達到擴容條件時會進行擴容。HashMap的擴容條件就是當HashMap中的元素個數(size)超過臨界值(threshold)時就會自動擴容。在HashMap中,threshold = loadFactor * capacity。
- 所以,如果我們沒有設置初始容量大小,隨着元素的不斷增加,HashMap會有可能發生多次擴容,而HashMap中的擴容機制決定了每次擴容都需要重建hash表,是非常影響性能的。
- 但是設置初始化容量,設置的數值不同也會影響性能,那么當我們已知HashMap中即將存放的KV個數的時候,容量設置成多少為好呢?
5.2 HashMap中容量的初始化
- 當我們使用HashMap(int initialCapacity)來初始化容量的時候,jdk會默認幫我們計算一個相對合理的值當做初始容量。那么,是不是我們只需要把已知的HashMap中即將存放的元素個數直接傳給initialCapacity就可以了呢?
- 也就是說,如果我們設置的默認值是7,經過Jdk處理之后,會被設置成8,但是,這個HashMap在元素個數達到 8*0.75 = 6的時候就會進行一次擴容,這明顯是我們不希望見到的。我們應該盡量減少擴容。原因也已經分析過。
- 如果我們通過initialCapacity/ 0.75F + 1.0F計算,7/0.75 + 1 = 10 ,10經過Jdk處理之后,會被設置成16,這就大大的減少了擴容的幾率。
- 當HashMap內部維護的哈希表的容量達到75%時(默認情況下),會觸發rehash,而rehash的過程是比較耗費時間的。所以初始化容量要設置成initialCapacity/0.75 + 1的話,可以有效的減少沖突也可以減小誤差。
- 所以,我可以認為,當我們明確知道HashMap中元素的個數的時候,把默認容量設置成initialCapacity/ 0.75F + 1.0F是一個在性能上相對好的選擇,但是,同時也會犧牲些內存。
- 我們想要在代碼中創建一個HashMap的時候,如果我們已知這個Map中即將存放的元素個數,給HashMap設置初始容量可以在一定程度上提升效率。
- 但是,JDK並不會直接拿用戶傳進來的數字當做默認容量,而是會進行一番運算,最終得到一個2的冪。原因也已經分析過。
- 但是,為了最大程度的避免擴容帶來的性能消耗,我們建議可以把默認容量的數字設置成initialCapacity/ 0.75F + 1.0F。
本文參考來源於:掘金