HashMap原理與理解
本文以 Java 1.8 為基礎進行展開。
一、HashMap的基本結構
HashMap是基於雜湊表的Map介面的非同步實現。此實現提供所有可選的對映操作,並允許使用null值和null鍵。此類不保證對映的順序,特別是它不保證該順序恆久不變。
在java程式語言中,最基本的結構就是兩種,一個是陣列,另外一個是模擬指標(引用),所有的資料結構都可以用這兩個基本結構來構造,比如String、ArrayList等,HashMap也不例外。HashMap實際上是一個“連結串列雜湊”的資料結構,即陣列、連結串列、紅黑樹的結合體(Java 1.8引入了紅黑樹結構)。
陣列的特點:查詢效率高,插入,刪除效率低。
連結串列的特點:查詢效率低,插入刪除效率高。
在HashMap底層使用陣列加(連結串列或紅黑樹)的結構完美的解決了陣列和連結串列的問題,使得查詢和插入,刪除的效率都很高。
其結構模型可參考下圖(借用baidu的兩張圖):
二、HashMap的基本屬性、方法
1.HashMap的成員變數
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //預設的初始容量為16 static final int MAXIMUM_CAPACITY = 1 << 30; //最大的容量為 2 ^ 30 static finalView Codefloat DEFAULT_LOAD_FACTOR = 0.75f; //預設的載入因子為 0.75f static final int TREEIFY_THRESHOLD = 8; //連結串列長度大於等於8時轉化為紅黑樹 static final int UNTREEIFY_THRESHOLD = 6; //紅黑樹長度小於等於6時轉化為連結串列 static final int MIN_TREEIFY_CAPACITY = 64; //連結串列轉為紅黑樹時要求的table capacity最小容量 transient Node<K,V>[] table; //Node型別的陣列(實現了Entry介面),HashMap的基本組成單元,用來儲存key-value對映transient Set<Map.Entry<K,V>> entrySet; // transient int size; //HashMap包含key-value對映的數量 final float loadFactor; //載入因子 int threshold; //HashMap的擴容臨界點(容量和載入因子的乘積) transient int modCount; // 每次擴容和更改map結構的計數器
計算陣列index的時候,為什麼要用位運算&呢?
主要是效率問題,位運算(&)效率要比取模運算(%)高很多,主要原因是位運算直接對記憶體資料進行操作,不需要轉成十進位制,因此處理速度非常快。在jdk1.8之前的index計算就是用的取模運算%。
MAXIMUM_CAPACITY為什麼設定成 1 << 30
i. HashMap在確定陣列下標Index的時候,採用的是( length-1) & hash的方式。只有當length為2的指數冪,2的n次-1的二進位制表示剛好全為1,這樣&運算確定的index才能分佈均勻,不然如果有一位是0,那
麼與運算結果對應的這一位也永遠是0,那對應的陣列index處就為空,index分佈不均勻了。所以HashMap規定了其容量必須是2的n次方,這樣才能較均勻的分佈元素,hash%length = hash&(length-1)才能成
立。
ii. 另外,HashMap內部由Node[](Entry[])陣列構成,Java的陣列下標是由Integer表示的。所以對於HashMap來說其最大的容量應該是不超過Integer最大值的一個2的指數冪,而最接近Integer最大值的2的
指數冪就是 1 << 30。此時,HashMap也就無法再繼續擴容。
DEFAULT_LOAD_FACTOR = 0.75f載入因子
loadFactor載入因子,是用來衡量 HashMap 滿的程度,表示HashMap的疏密程度,影響hash操作到同一個陣列位置的概率。計算HashMap的實時載入因子的方法為:size/capacity,而不是佔用桶的數量去除以capacity。capacity 是桶的數量,也就是 table 的長度length。當Size>=threshold的時候,那麼就要考慮對陣列的resize(擴容)。當連結串列的值超過8則會轉紅黑樹(jdk 1.8新增)
loadFactor太大導致查詢元素效率低,太小導致陣列的利用率低,存放的資料會很分散。loadFactor的預設值為0.75f是官方給出的一個比較好的臨界值。
當HashMap裡面容納的元素已經達到HashMap陣列長度的75%時,表示HashMap太擠了,需要擴容,而擴容這個過程涉及到 rehash、複製資料等操作,非常消耗效能。所以開發中儘量減少擴容的次數,可
以通過建立HashMap集合物件時指定初始容量來儘量避免。
同時在HashMap的構造器中可以指定loadFactor:
public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); }View Code
TREEIFY_THRESHOLD = 8; UNTREEIFY_THRESHOLD = 6;
為什麼Map桶中節點個數大於等於8轉為紅黑樹?桶中連結串列元素個數小於等於6時,樹結構還原成連結串列?
解釋1.參考自:https://www.cnblogs.com/xc-chejj/p/10825676.html
因為紅黑樹的平均查詢長度是log(n),長度為8的時候,平均查詢長度為3,如果繼續使用連結串列,平均查詢長度為8/2=4,這才有轉換為樹的必
要。連結串列長度如果是小於等於6,6/2=3,雖然速度也很快的,但是轉化為樹結構和生成樹的時間並不會太短。
還有選擇6和8,中間有個差值7可以有效防止連結串列和樹頻繁轉換。假設一下,如果設計成連結串列個數超過8則連結串列轉換成樹結構,連結串列個數小於8則樹結構轉換成連結串列,如果一個HashMap不停的插入、刪除元
素,連結串列個數在8左右徘徊,就會頻繁的發生樹轉連結串列、連結串列轉樹,效率會很低。
解釋2. 參考自:https://www.cnblogs.com/coding-996/p/12468618.html
根據官方原始碼的註釋可以看到:
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: * * 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 millionView Code
大概意思就是說,在理想情況下,使用隨機雜湊碼,節點出現在hash桶中的頻率遵循泊松分佈,同時給出了桶中元素個數和概率的對照表。從上面的表中可以看到當桶中元素到達8個的時候,概率已經變得非
常小,也就是說用0.75作為載入因子,每個碰撞位置的連結串列長度超過8個的概率小於一千萬分之一。即載入因子為0.75,同一個桶中出現8個元素然後轉化為紅黑樹的概率小於1000萬分之一。
MIN_TREEIFY_CAPACITY = 64
當HashMap桶的容量超過這個值時才能進行樹形化 ,否則會先選擇擴容,而不是樹形化。為了避免進行擴容、樹形化選擇的衝突,這個值不能小於 4 * TREEIFY_THRESHOLD。
Node<K,V>[] table
在jdk1.7中,Entry陣列是HashMap的基本組成單元:
View Codejdk1.8中,HashMap基本單元由Node陣列組成,Node陣列實現了Entry介面:
View Code當然,還有jdk1.8 引入的紅TreeNode靜態內部類:
View CodeSet<Map.Entry<K,V>> entrySet
HashMap中所有key-value對映的集合,儲存了所有的key-value鍵值對。可通過entrySet() 方法獲取:
public Set<Map.Entry<K,V>> entrySet() { Set<Map.Entry<K,V>> es; return (es = entrySet) == null ? (entrySet = new EntrySet()) : es; }View Code
粗略一看,發現HashMap中並沒有主動地去維護entrySet,比如put的時候去存值或者呼叫entrySet()去維護值,那entryset的值從哪而來呢?具體在後面的entrySet()方法中說明。
2.HashMap的常用方法
(1)HashMap的主要構造器
i. HashMap():構建一個初始容量為 16,負載因子為 0.75 的 HashMap
View Code
ii. HashMap(int initialCapacity):設定了初始容量initialCapacity,但方法內部會基於initialCapacity重新計算,得到一個不小於initialCapacity的最小的2的指數冪,並將其作為threshold(具體計算邏輯見下面的tableSizeFor() 方法和 put() 方法),同時設定負載因子為 0.75
public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); } 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; }View Code
iii. HashMap(int initialCapacity, float loadFactor):以指定初始容量、指定的負載因子建立一個 HashMap。同樣,方法內部也基於initialCapacity重新計算了threshold
原始碼見ii 中所述方法。
(2)tableSizeFor() 方法
原始碼見上述ii 中程式碼。該方法對給定初始容量initialCapacity進行初始化,將其修改為不小於initialCapacity的、最小的2的n次冪,以此來保證HashMap的容量只會是2的冪。也就是說如果傳入的引數為x,那麼呼叫該方法應該返回一個大於等於x的最小的2的冪。
// 10000000 00000000 00000000 00000000 n |= n >>> 1; // 11000000 00000000 00000000 00000000 n |= n >>> 2; // 11110000 00000000 00000000 00000000 n |= n >>> 4; // 11111111 00000000 00000000 00000000 n |= n >>> 8; // 11111111 11111111 00000000 00000000 n |= n >>> 16; // 11111111 11111111 11111111 11111111View Code
其實,我們只需要關心從左邊數第一個不為零的位,其他的位是1是0都不重要。n |= n >>> 1之後可以確保,從左邊數第一個不為零的位開始前兩位都是1,n |= n >>> 2之後可以確保前四位都是1。以此類推,最後得到的就是從第一個不為零的位開始全為1的數,再將這個數+1就可以得到大於等於cap這個變數的最小的2的冪了。
移位5次就停止是因為HashMap最大容量是1<<30 (2的30次方),是一個int型資料。在java中int型是4個位元組也就是32位,除去符號位就是31位,進行5次移位之後已經足以保證31位全為1了。
參考自:https://blog.csdn.net/qq_41046325/article/details/88626353
值得注意的是,上面的原始碼中,是將tableForSize的值賦值給了threshold, 那為何說是我們初始化容量(capacity)的大小為該值呢?可以下面的put()方法。
(3)put()方法
先解釋一下上面剛剛提出的問題。在第一次向map新增資料時,呼叫:
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; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //注意這一行程式碼 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { ... } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }View Code
注意上面,putVal會判斷table是否為null
if ((tab = table) == null || (n = tab.length) == 0)
如果為null,則呼叫resize方法:
n = (tab = resize()).length;
而resize()方法(原始碼見下文)實際上就是將之前設定的threshold作為了初始化的容量大小。
參考自:https://www.cnblogs.com/shuhe-nd/p/12011269.html
為了邏輯清晰、簡潔易懂,在此參考另一篇部落格中的一張圖,put() 方法的邏輯可以用下圖來表示:
上圖參考自:https://www.cnblogs.com/one-apple-pie/p/10473682.html
(4)resize()
方法
當hashmap中的元素越來越多的時候,碰撞的機率也就越來越高(因為陣列的長度是固定的),所以為了提高查詢的效率,就要對hashmap的陣列進行擴容,陣列擴容這個操作也會出現在ArrayList中,所以這是一個通用的操作,很多人對它的效能表示過懷疑,不過想想我們的“均攤”原理,就釋然了,而在hashmap陣列擴容之後,最消耗效能的點就出現了:原陣列中的資料必須重新計算其在新陣列中的位置,並放進去,這就是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; if (oldCap > 0) { if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; //注意這一行程式碼 else { // zero initial threshold signifies using defaults newCap = DEFAULT_INITIAL_CAPACITY; 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<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; 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; }View Code
resize的大致邏輯:
i. 校驗和擴容:校驗capacity 和threshold,重新計算這兩個引數,並新建一個Entry空陣列,長度是原來的兩倍。
ii. reHash :遍歷原來的Entry陣列,把所有的資料重新Hash到新的陣列。
那麼hashmap什麼時候進行擴容呢?當hashmap中的元素個數超過陣列大小*loadFactor時,就會進行陣列擴容,loadFactor的預設值為0.75,也就是說,預設情況下,陣列大小為16,那麼當hashmap中元素個數超過16*0.75=12的時候,就把陣列的大小擴充套件為2*16=32,即擴大一倍,然後重新計算每個元素在陣列中的位置,而這是一個非常消耗效能的操作,所以如果我們已經預知hashmap中元素的個數,那麼預設元素的個數能夠有效的提高hashmap的效能。比如說,我們有1000個元素new HashMap(1000), 但是理論上來講new HashMap(1024)更合適,不過即使是1000,hashmap也自動會將其設定為1024。 但是new HashMap(1024)還不是更合適的,因為0.75*1000 < 1000, 也就是說為了讓0.75 * size > 1000, 我們必須這樣new HashMap(2048)才最合適,既考慮了&的問題,也避免了resize的問題。
上面參考自:https://www.cnblogs.com/yuanblog/p/4441017.html
為什麼要重新Hash呢,直接複製過去不是更快捷方便嗎?
-
因為擴容以後的陣列長度變了,
index = HashCode(key)&(length - 1)
,擴容後的length和之前不一樣了,變為原來的2倍,重新Hash算出來的index值肯定也不一樣,而且重新計算後,會使元素更加均勻的分佈在HashMap表中,如果直接複製的話,那麼資料肯定都堆在一起了,擴容的意義就削弱了。
(5)entrySet()方法
i. 先看一段常用的遍歷程式碼:
View Code上面的遍歷方式可以遍歷HashMap中所有的key-value鍵值對,為什麼entrySet()會返回值呢?繼續看原始碼:
View Code View Codefinal class EntryIterator extends HashIterator implements Iterator<Map.Entry<K,V>> { public final Map.Entry<K,V> next() { return nextNode(); } }View Code
abstract class HashIterator { Node<K,V> next; // next entry to return Node<K,V> current; // current entry int expectedModCount; // for fast-fail int index; // current slot HashIterator() { expectedModCount = modCount; Node<K,V>[] t = table; current = next = null; index = 0; if (t != null && size > 0) { // advance to first entry do {} while (index < t.length && (next = t[index++]) == null); } } public final boolean hasNext() { return next != null; } final Node<K,V> nextNode() { Node<K,V>[] t; Node<K,V> e = next; if (modCount != expectedModCount) throw new ConcurrentModificationException(); if (e == null) throw new NoSuchElementException(); if ((next = (current = e).next) == null && (t = table) != null) { do {} while (index < t.length && (next = t[index++]) == null); } return e; } public final void remove() { Node<K,V> p = current; if (p == null) throw new IllegalStateException(); if (modCount != expectedModCount) throw new ConcurrentModificationException(); current = null; K key = p.key; removeNode(hash(key), key, null, false, false); expectedModCount = modCount; } }View Code
可以看到,上面entrySet() 方法中在entrySet為空時,會初始化一個EntrySet類。而該類中重寫了一個方法iterator(),並且在方法內部呼叫了EntryIterator 的構造方法。EntryIterator 類又繼承了HashIterator類,所以會預設呼叫該類的構造方法。在HashIterator 的構造方法中,會初始化一個next變數,構造初期會從0開始找有值(不為空)的索引位置,找到後將這個Node賦值給next;然後要遍歷的時候呼叫了EntryIterator的 next() 方法,即呼叫了HashIterator的nextNode() 方法,這個方法會一直遍歷找到下一個有值(不為空)的索引位置。如此反覆。
所以,HashMap在put 的時候維護了Node<K,V>[] table,然後在entrySet() 的時候會遍歷這個table,從而獲得所有的key-value鍵值對。
參考自:https://www.cnblogs.com/javammc/p/7631597.html
另外,關於entrySet 值初始化的問題,可能跟預設呼叫toString()方法有關,可以參考下面的討論:
https://www.cnblogs.com/allmignt/p/12353732.html,https://segmentfault.com/q/1010000012304833
ii. 遍歷方法對比
(i) 通過HashMap.entrySet()獲得鍵值對的Set集合,如上面 i 中所述方式。
(ii)通過HashMap.keySet()獲得鍵的Set集合(iterator或foreach)
Map<String, String> map = new HashMap<String, String>(); map.put("1", "11"); map.put("2", "22"); map.put("3", "33"); // 鍵和值 String key = null; String value = null; // 獲取鍵集合的迭代器 Iterator it = map.keySet().iterator(); while (it.hasNext()) { key = (String) it.next(); value = (String) map.get(key); System.out.println("key:" + key + "---" + "value:" + value); }View Code
(iii)通過HashMap.values()得到“值”的集合
Map<String, String> map = new HashMap<String, String>(); map.put("1", "11"); map.put("2", "22"); map.put("3", "33"); // 值 String value = null; // 獲取值集合的迭代器 Iterator it = map.values().iterator(); while (it.hasNext()) { value = (String) it.next(); System.out.println("value:" + value); }View Code
上述(i)與(ii)的不同之處在於獲取到相應集合之後,在遍歷的時候時間複雜度不同:(i)在遍歷時通過iterator獲取下一個鍵值對,時間複雜度為O(1),而(ii)呼叫get()方法則又會進行一次遍歷。因此方式(i)的效能要更優於方式(ii),尤其是在map容量比較大的時候。
(iiii) Lambda表示式
(iiiii) stream API
連結的文中對HashMap的遍歷方式列舉比較全面(包括Lambda表示式、stream API),可以進行學習參考:https://blog.csdn.net/sufu1065/article/details/105852634?utm_medium=distribute.pc_relevant_t0.none-task-blog-BlogCommendFromMachineLearnPai2-1.channel_param&depth_1-utm_source=distribute.pc_relevant_t0.none-task-blog-BlogCommendFromMachineLearnPai2-1.channel_param。
三、執行緒安全
1. 連結串列頭插法、尾插法
舉個例子,現在採用多執行緒往一個容量大小為2的HashMap中put 值:A = 1 ,B = 2,C = 3 ,負載因子是0.75。
正常單執行緒的情況下,在put第二個值的時候就會進行resize。對於多執行緒而言,假如各自都安全地完成了資料的插入(但還沒有觸發擴容),此時A->B->C,狀態如下所示:
現在進行擴容。如果使用單鏈表的頭插法,同一index位置上的新元素總會被放到連結串列的頭部
,假如某一執行緒這時剛好把 B 重新hash到了A原來的位置,如圖:
由於是多執行緒同時操作,當所有執行緒都執行完畢以後,就可能會出現這樣的情況:
此時居然出現了環狀的連結串列結構,如果這個時候去取值,就會出錯——InfiniteLoop(死迴圈)。
Java7在多執行緒操作HashMap時,採用了頭插法,在轉移過程中修改了原連結串列中節點的引用關係(互相顛倒),很有可能引起死迴圈;Java8採用尾插法,就不會引起死迴圈,原因是擴容前後(如果)仍然位於同一連結串列上的元素,他們的相對引用順序不會顛倒。
總之,Java7如果多個執行緒同時觸發擴容,在移動節點時可能會導致一個連結串列中的2個節點相互引用,從而生成環連結串列。
四、與Jdk1.7對比
1.HashMap基本構成
參考上面的:Node<K,V>[] table部分
2. 資料插入連結串列時,JDK8以前是頭插法,JDK8是尾插法
參考三、執行緒安全
3. JDK8引入了紅黑樹(相對平衡的二叉搜尋樹),提高了查詢效率。
紅黑樹可以參考:https://www.cnblogs.com/LiaHon/p/11203229.html
五、小結:
1. 擴容是一個特別耗效能的操作,所以在使用HashMap的時候,估算map的大小,初始化的時候給一個大致的數值,避免map進行頻繁的擴容。
2. 負載因子是可以修改的,也可以大於1,但是建議不要輕易修改,除非情況非常特殊。
3. HashMap是執行緒不安全的,不要在併發的環境中同時操作HashMap,建議使用ConcurrentHashMap。