HashMap 源码详细分析(JDK1.8)
1. 概述
本篇文章我們來聊聊大家日常開發(fā)中常用的一個(gè)集合類 -?HashMap。HashMap 最早出現(xiàn)在 JDK 1.2中,底層基于散列算法實(shí)現(xiàn)。HashMap 允許 null 鍵和 null 值,在計(jì)算哈鍵的哈希值時(shí),null 鍵哈希值為 0。HashMap 并不保證鍵值對(duì)的順序,這意味著在進(jìn)行某些操作后,鍵值對(duì)的順序可能會(huì)發(fā)生變化。另外,需要注意的是,HashMap 是非線程安全類,在多線程環(huán)境下可能會(huì)存在問題。
在本篇文章中,我將會(huì)對(duì) HashMap 中常用方法、重要屬性及相關(guān)方法進(jìn)行分析。需要說明的是,HashMap 源碼中可分析的點(diǎn)很多,本文很難一一覆蓋,請(qǐng)見諒。
?2. 原理
上一節(jié)說到 HashMap 底層是基于散列算法實(shí)現(xiàn),散列算法分為散列再探測(cè)和拉鏈?zhǔn)健ashMap 則使用了拉鏈?zhǔn)降纳⒘兴惴?#xff0c;并在 JDK 1.8 中引入了紅黑樹優(yōu)化過長(zhǎng)的鏈表。數(shù)據(jù)結(jié)構(gòu)示意圖如下:
對(duì)于拉鏈?zhǔn)降纳⒘兴惴?#xff0c;其數(shù)據(jù)結(jié)構(gòu)是由數(shù)組和鏈表(或樹形結(jié)構(gòu))組成。在進(jìn)行增刪查等操作時(shí),首先要定位到元素的所在桶的位置,之后再?gòu)逆湵碇卸ㄎ辉撛?。比如我們要查詢上圖結(jié)構(gòu)中是否包含元素35,步驟如下:
上面就是 HashMap 底層數(shù)據(jù)結(jié)構(gòu)的原理,HashMap 基本操作就是對(duì)拉鏈?zhǔn)缴⒘兴惴ɑ静僮鞯囊粚影b。不同的地方在于 JDK 1.8 中引入了紅黑樹,底層數(shù)據(jù)結(jié)構(gòu)由數(shù)組+鏈表變?yōu)榱藬?shù)組+鏈表+紅黑樹,不過本質(zhì)并未變。好了,原理部分先講到這,接下來說說源碼實(shí)現(xiàn)。
?3. 源碼分析
本篇文章所分析的源碼版本為 JDK 1.8。與 JDK 1.7 相比,JDK 1.8 對(duì) HashMap 進(jìn)行了一些優(yōu)化。比如引入紅黑樹解決過長(zhǎng)鏈表效率低的問題。重寫 resize 方法,移除了 alternative hashing 相關(guān)方法,避免重新計(jì)算鍵的 hash 等。不過本篇文章并不打算對(duì)這些優(yōu)化進(jìn)行分析,本文僅會(huì)分析 HashMap 常用的方法及一些重要屬性和相關(guān)方法。如果大家對(duì)紅黑樹感興趣,可以閱讀我的另一篇文章 -?紅黑樹詳細(xì)分析。
?3.1 構(gòu)造方法
?3.1.1 構(gòu)造方法分析
HashMap 的構(gòu)造方法不多,只有四個(gè)。HashMap 構(gòu)造方法做的事情比較簡(jiǎn)單,一般都是初始化一些重要變量,比如 loadFactor 和 threshold。而底層的數(shù)據(jù)結(jié)構(gòu)則是延遲到插入鍵值對(duì)時(shí)再進(jìn)行初始化。HashMap 相關(guān)構(gòu)造方法如下:
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | /** 構(gòu)造方法 1 */ public HashMap() {this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted }/** 構(gòu)造方法 2 */ public HashMap(int initialCapacity) {this(initialCapacity, DEFAULT_LOAD_FACTOR); }/** 構(gòu)造方法 3 */ 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); }/** 構(gòu)造方法 4 */ public HashMap(Map<? extends K, ? extends V> m) {this.loadFactor = DEFAULT_LOAD_FACTOR;putMapEntries(m, false); } |
上面4個(gè)構(gòu)造方法中,大家平時(shí)用的最多的應(yīng)該是第一個(gè)了。第一個(gè)構(gòu)造方法很簡(jiǎn)單,僅將 loadFactor 變量設(shè)為默認(rèn)值。構(gòu)造方法2調(diào)用了構(gòu)造方法3,而構(gòu)造方法3仍然只是設(shè)置了一些變量。構(gòu)造方法4則是將另一個(gè) Map 中的映射拷貝一份到自己的存儲(chǔ)結(jié)構(gòu)中來,這個(gè)方法不是很常用。
上面就是對(duì)構(gòu)造方法簡(jiǎn)單的介紹,構(gòu)造方法本身并沒什么太多東西,所以就不說了。接下來說說構(gòu)造方法所初始化的幾個(gè)的變量。
?3.1.2 初始容量、負(fù)載因子、閾值
我們?cè)谝话闱闆r下,都會(huì)使用無參構(gòu)造方法創(chuàng)建 HashMap。但當(dāng)我們對(duì)時(shí)間和空間復(fù)雜度有要求的時(shí)候,使用默認(rèn)值有時(shí)可能達(dá)不到我們的要求,這個(gè)時(shí)候我們就需要手動(dòng)調(diào)參。在 HashMap 構(gòu)造方法中,可供我們調(diào)整的參數(shù)有兩個(gè),一個(gè)是初始容量 initialCapacity,另一個(gè)負(fù)載因子 loadFactor。通過這兩個(gè)設(shè)定這兩個(gè)參數(shù),可以進(jìn)一步影響閾值大小。但初始閾值 threshold 僅由 initialCapacity 經(jīng)過移位操作計(jì)算得出。他們的作用分別如下:
| initialCapacity | HashMap 初始容量 |
| loadFactor | 負(fù)載因子 |
| threshold | 當(dāng)前 HashMap 所能容納鍵值對(duì)數(shù)量的最大值,超過這個(gè)值,則需擴(kuò)容 |
相關(guān)代碼如下:
| 1 2 3 4 5 6 7 8 9 10 | /** The default initial capacity - MUST be a power of two. */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;/** The load factor used when none specified in constructor. */ static final float DEFAULT_LOAD_FACTOR = 0.75f;final float loadFactor;/** The next size value at which to resize (capacity * load factor). */ int threshold; |
如果大家去看源碼,會(huì)發(fā)現(xiàn) HashMap 中沒有定義 initialCapacity 這個(gè)變量。這個(gè)也并不難理解,從參數(shù)名上可看出,這個(gè)變量表示一個(gè)初始容量,只是構(gòu)造方法中用一次,沒必要定義一個(gè)變量保存。但如果大家仔細(xì)看上面 HashMap 的構(gòu)造方法,會(huì)發(fā)現(xiàn)存儲(chǔ)鍵值對(duì)的數(shù)據(jù)結(jié)構(gòu)并不是在構(gòu)造方法里初始化的。這就有個(gè)疑問了,既然叫初始容量,但最終并沒有用與初始化數(shù)據(jù)結(jié)構(gòu),那傳這個(gè)參數(shù)還有什么用呢?這個(gè)問題我先不解釋,給大家留個(gè)懸念,后面會(huì)說明。
默認(rèn)情況下,HashMap 初始容量是16,負(fù)載因子為 0.75。這里并沒有默認(rèn)閾值,原因是閾值可由容量乘上負(fù)載因子計(jì)算而來(注釋中有說明),即threshold = capacity * loadFactor。但當(dāng)你仔細(xì)看構(gòu)造方法3時(shí),會(huì)發(fā)現(xiàn)閾值并不是由上面公式計(jì)算而來,而是通過一個(gè)方法算出來的。這是不是可以說明 threshold 變量的注釋有誤呢?還是僅這里進(jìn)行了特殊處理,其他地方遵循計(jì)算公式呢?關(guān)于這個(gè)疑問,這里也先不說明,后面在分析擴(kuò)容方法時(shí),再來解釋這個(gè)問題。接下來,我們來看看初始化 threshold 的方法長(zhǎng)什么樣的的,源碼如下:
| 1 2 3 4 5 6 7 8 9 10 11 12 | /*** 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; } |
上面的代碼長(zhǎng)的有點(diǎn)不太好看,反正我第一次看的時(shí)候不明白它想干啥。不過后來在紙上畫畫,知道了它的用途??偨Y(jié)起來就一句話:找到大于或等于 cap 的最小2的冪。至于為啥要這樣,后面再解釋。我們先來看看 tableSizeFor 方法的圖解:
上面是 tableSizeFor 方法的計(jì)算過程圖,這里cap = 536,870,913 = 2<sup>29</sup> + 1,多次計(jì)算后,算出n + 1 = 1,073,741,824 = 2<sup>30</sup>。通過圖解應(yīng)該可以比較容易理解這個(gè)方法的用途,這里就不多說了。
說完了初始閾值的計(jì)算過程,再來說說負(fù)載因子(loadFactor)。對(duì)于 HashMap 來說,負(fù)載因子是一個(gè)很重要的參數(shù),該參數(shù)反應(yīng)了 HashMap 桶數(shù)組的使用情況(假設(shè)鍵值對(duì)節(jié)點(diǎn)均勻分布在桶數(shù)組中)。通過調(diào)節(jié)負(fù)載因子,可使 HashMap 時(shí)間和空間復(fù)雜度上有不同的表現(xiàn)。當(dāng)我們調(diào)低負(fù)載因子時(shí),HashMap 所能容納的鍵值對(duì)數(shù)量變少。擴(kuò)容時(shí),重新將鍵值對(duì)存儲(chǔ)新的桶數(shù)組里,鍵的鍵之間產(chǎn)生的碰撞會(huì)下降,鏈表長(zhǎng)度變短。此時(shí),HashMap 的增刪改查等操作的效率將會(huì)變高,這里是典型的拿空間換時(shí)間。相反,如果增加負(fù)載因子(負(fù)載因子可以大于1),HashMap 所能容納的鍵值對(duì)數(shù)量變多,空間利用率高,但碰撞率也高。這意味著鏈表長(zhǎng)度變長(zhǎng),效率也隨之降低,這種情況是拿時(shí)間換空間。至于負(fù)載因子怎么調(diào)節(jié),這個(gè)看使用場(chǎng)景了。一般情況下,我們用默認(rèn)值就可以了。
?3.2 查找
HashMap 的查找操作比較簡(jiǎn)單,查找步驟與原理篇介紹一致,即先定位鍵值對(duì)所在的桶的位置,然后再對(duì)鏈表或紅黑樹進(jìn)行查找。通過這兩步即可完成查找,該操作相關(guān)代碼如下:
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | public V get(Object key) {Node<K,V> e;return (e = getNode(hash(key), key)) == null ? null : e.value; }final Node<K,V> getNode(int hash, Object key) {Node<K,V>[] tab; Node<K,V> first, e; int n; K k;// 1. 定位鍵值對(duì)所在桶的位置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) {// 2. 如果 first 是 TreeNode 類型,則調(diào)用黑紅樹查找方法if (first instanceof TreeNode)return ((TreeNode<K,V>)first).getTreeNode(hash, key);// 2. 對(duì)鏈表進(jìn)行查找do {if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))return e;} while ((e = e.next) != null);}}return null; } |
查找的核心邏輯是封裝在 getNode 方法中的,getNode 方法源碼我已經(jīng)寫了一些注釋,應(yīng)該不難看懂。我們先來看看查找過程的第一步 - 確定桶位置,其實(shí)現(xiàn)代碼如下:
| 1 2 | // index = (n - 1) & hash first = tab[(n - 1) & hash] |
這里通過(n - 1)& hash即可算出桶的在桶數(shù)組中的位置,可能有的朋友不太明白這里為什么這么做,這里簡(jiǎn)單解釋一下。HashMap 中桶數(shù)組的大小 length 總是2的冪,此時(shí),(n - 1) & hash?等價(jià)于對(duì) length 取余。但取余的計(jì)算效率沒有位運(yùn)算高,所以(n - 1) & hash也是一個(gè)小的優(yōu)化。舉個(gè)例子說明一下吧,假設(shè) hash = 185,n = 16。計(jì)算過程示意圖如下:
上面的計(jì)算并不復(fù)雜,這里就不多說了。
在上面源碼中,除了查找相關(guān)邏輯,還有一個(gè)計(jì)算 hash 的方法。這個(gè)方法源碼如下:
| 1 2 3 4 5 6 7 | /*** 計(jì)算鍵的 hash 值*/ static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } |
看這個(gè)方法的邏輯好像是通過位運(yùn)算重新計(jì)算 hash,那么這里為什么要這樣做呢?為什么不直接用鍵的 hashCode 方法產(chǎn)生的 hash 呢?大家先可以思考一下,我把答案寫在下面。
這樣做有兩個(gè)好處,我來簡(jiǎn)單解釋一下。我們?cè)倏匆幌律厦媲笥嗟挠?jì)算圖,圖中的 hash 是由鍵的 hashCode 產(chǎn)生。計(jì)算余數(shù)時(shí),由于 n 比較小,hash 只有低4位參與了計(jì)算,高位的計(jì)算可以認(rèn)為是無效的。這樣導(dǎo)致了計(jì)算結(jié)果只與低位信息有關(guān),高位數(shù)據(jù)沒發(fā)揮作用。為了處理這個(gè)缺陷,我們可以上圖中的 hash 高4位數(shù)據(jù)與低4位數(shù)據(jù)進(jìn)行異或運(yùn)算,即?hash ^ (hash >>> 4)。通過這種方式,讓高位數(shù)據(jù)與低位數(shù)據(jù)進(jìn)行異或,以此加大低位信息的隨機(jī)性,變相的讓高位數(shù)據(jù)參與到計(jì)算中。此時(shí)的計(jì)算過程如下:
在 Java 中,hashCode 方法產(chǎn)生的 hash 是 int 類型,32 位寬。前16位為高位,后16位為低位,所以要右移16位。
上面所說的是重新計(jì)算 hash 的一個(gè)好處,除此之外,重新計(jì)算 hash 的另一個(gè)好處是可以增加 hash 的復(fù)雜度。當(dāng)我們覆寫 hashCode 方法時(shí),可能會(huì)寫出分布性不佳的 hashCode 方法,進(jìn)而導(dǎo)致 hash 的沖突率比較高。通過移位和異或運(yùn)算,可以讓 hash 變得更復(fù)雜,進(jìn)而影響 hash 的分布性。這也就是為什么 HashMap 不直接使用鍵對(duì)象原始 hash 的原因了。
?3.3 遍歷
和查找查找一樣,遍歷操作也是大家使用頻率比較高的一個(gè)操作。對(duì)于 遍歷 HashMap,我們一般都會(huì)用下面的方式:
| 1 2 3 | for(Object key : map.keySet()) {// do something } |
或
| 1 2 3 | for(HashMap.Entry entry : map.entrySet()) {// do something } |
從上面代碼片段中可以看出,大家一般都是對(duì) HashMap 的 key 集合或 Entry 集合進(jìn)行遍歷。上面代碼片段中用 foreach 遍歷 keySet 方法產(chǎn)生的集合,在編譯時(shí)會(huì)轉(zhuǎn)換成用迭代器遍歷,等價(jià)于:
| 1 2 3 4 5 6 | Set keys = map.keySet(); Iterator ite = keys.iterator(); while (ite.hasNext()) {Object key = ite.next();// do something } |
大家在遍歷 HashMap 的過程中會(huì)發(fā)現(xiàn),多次對(duì) HashMap 進(jìn)行遍歷時(shí),遍歷結(jié)果順序都是一致的。但這個(gè)順序和插入的順序一般都是不一致的。產(chǎn)生上述行為的原因是怎樣的呢?大家想一下原因。我先把遍歷相關(guān)的代碼貼出來,如下:
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 | public Set<K> keySet() {Set<K> ks = keySet;if (ks == null) {ks = new KeySet();keySet = ks;}return ks; }/*** 鍵集合*/ final class KeySet extends AbstractSet<K> {public final int size() { return size; }public final void clear() { HashMap.this.clear(); }public final Iterator<K> iterator() { return new KeyIterator(); }public final boolean contains(Object o) { return containsKey(o); }public final boolean remove(Object key) {return removeNode(hash(key), key, null, false, true) != null;}// 省略部分代碼 }/*** 鍵迭代器*/ final class KeyIterator extends HashIterator implements Iterator<K> {public final K next() { return nextNode().key; } }abstract class HashIterator {Node<K,V> next; // next entry to returnNode<K,V> current; // current entryint expectedModCount; // for fast-failint index; // current slotHashIterator() {expectedModCount = modCount;Node<K,V>[] t = table;current = next = null;index = 0;if (t != null && size > 0) { // advance to first entry // 尋找第一個(gè)包含鏈表節(jié)點(diǎn)引用的桶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) {// 尋找下一個(gè)包含鏈表節(jié)點(diǎn)引用的桶do {} while (index < t.length && (next = t[index++]) == null);}return e;}//省略部分代碼 } |
如上面的源碼,遍歷所有的鍵時(shí),首先要獲取鍵集合KeySet對(duì)象,然后再通過 KeySet 的迭代器KeyIterator進(jìn)行遍歷。KeyIterator 類繼承自HashIterator類,核心邏輯也封裝在 HashIterator 類中。HashIterator 的邏輯并不復(fù)雜,在初始化時(shí),HashIterator 先從桶數(shù)組中找到包含鏈表節(jié)點(diǎn)引用的桶。然后對(duì)這個(gè)桶指向的鏈表進(jìn)行遍歷。遍歷完成后,再繼續(xù)尋找下一個(gè)包含鏈表節(jié)點(diǎn)引用的桶,找到繼續(xù)遍歷。找不到,則結(jié)束遍歷。舉個(gè)例子,假設(shè)我們遍歷下圖的結(jié)構(gòu):
HashIterator 在初始化時(shí),會(huì)先遍歷桶數(shù)組,找到包含鏈表節(jié)點(diǎn)引用的桶,對(duì)應(yīng)圖中就是3號(hào)桶。隨后由 nextNode 方法遍歷該桶所指向的鏈表。遍歷完3號(hào)桶后,nextNode 方法繼續(xù)尋找下一個(gè)不為空的桶,對(duì)應(yīng)圖中的7號(hào)桶。之后流程和上面類似,直至遍歷完最后一個(gè)桶。以上就是 HashIterator 的核心邏輯的流程,對(duì)應(yīng)下圖:
遍歷上圖的最終結(jié)果是?19 -> 3 -> 35 -> 7 -> 11 -> 43 -> 59,為了驗(yàn)證正確性,簡(jiǎn)單寫點(diǎn)測(cè)試代碼跑一下看看。測(cè)試代碼如下:
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | /*** 應(yīng)在 JDK 1.8 下測(cè)試,其他環(huán)境下不保證結(jié)果和上面一致*/ public class HashMapTest {@Testpublic void testTraversal() {HashMap<Integer, String> map = new HashMap(16);map.put(7, "");map.put(11, "");map.put(43, "");map.put(59, "");map.put(19, "");map.put(3, "");map.put(35, "");System.out.println("遍歷結(jié)果:");for (Integer key : map.keySet()) {System.out.print(key + " -> ");}} } |
遍歷結(jié)果如下:
在本小節(jié)的最后,拋兩個(gè)問題給大家。在 JDK 1.8 版本中,為了避免過長(zhǎng)的鏈表對(duì) HashMap 性能的影響,特地引入了紅黑樹優(yōu)化性能。但在上面的源碼中并沒有發(fā)現(xiàn)紅黑樹遍歷的相關(guān)邏輯,這是為什么呢?對(duì)于被轉(zhuǎn)換成紅黑樹的鏈表該如何遍歷呢?大家可以先想想,然后可以去源碼或本文后續(xù)章節(jié)中找答案。
?3.4 插入
?3.4.1 插入邏輯分析
通過前兩節(jié)的分析,大家對(duì) HashMap 低層的數(shù)據(jù)結(jié)構(gòu)應(yīng)該了然于心了。即使我不說,大家也應(yīng)該能知道 HashMap 的插入流程是什么樣的了。首先肯定是先定位要插入的鍵值對(duì)屬于哪個(gè)桶,定位到桶后,再判斷桶是否為空。如果為空,則將鍵值對(duì)存入即可。如果不為空,則需將鍵值對(duì)接在鏈表最后一個(gè)位置,或者更新鍵值對(duì)。這就是 HashMap 的插入流程,是不是覺得很簡(jiǎn)單。當(dāng)然,大家先別高興。這只是一個(gè)簡(jiǎn)化版的插入流程,真正的插入流程要復(fù)雜不少。首先 HashMap 是變長(zhǎng)集合,所以需要考慮擴(kuò)容的問題。其次,在 JDK 1.8 中,HashMap 引入了紅黑樹優(yōu)化過長(zhǎng)鏈表,這里還要考慮多長(zhǎng)的鏈表需要進(jìn)行優(yōu)化,優(yōu)化過程又是怎樣的問題。引入這里兩個(gè)問題后,大家會(huì)發(fā)現(xiàn)原本簡(jiǎn)單的操作,現(xiàn)在略顯復(fù)雜了。在本節(jié)中,我將先分析插入操作的源碼,擴(kuò)容、樹化(鏈表轉(zhuǎn)為紅黑樹,下同)以及其他和樹結(jié)構(gòu)相關(guān)的操作,隨后將在獨(dú)立的兩小結(jié)中進(jìn)行分析。接下來,先來看一下插入操作的源碼:
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 | 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;// 初始化桶數(shù)組 table,table 被延遲到插入新數(shù)據(jù)時(shí)再進(jìn)行初始化if ((tab = table) == null || (n = tab.length) == 0)n = (tab = resize()).length;// 如果桶中不包含鍵值對(duì)節(jié)點(diǎn)引用,則將新鍵值對(duì)節(jié)點(diǎn)的引用存入桶中即可if ((p = tab[i = (n - 1) & hash]) == null)tab[i] = newNode(hash, key, value, null);else {Node<K,V> e; K k;// 如果鍵的值以及節(jié)點(diǎn) hash 等于鏈表中的第一個(gè)鍵值對(duì)節(jié)點(diǎn)時(shí),則將 e 指向該鍵值對(duì)if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))e = p;// 如果桶中的引用類型為 TreeNode,則調(diào)用紅黑樹的插入方法else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);else {// 對(duì)鏈表進(jìn)行遍歷,并統(tǒng)計(jì)鏈表長(zhǎng)度for (int binCount = 0; ; ++binCount) {// 鏈表中不包含要插入的鍵值對(duì)節(jié)點(diǎn)時(shí),則將該節(jié)點(diǎn)接在鏈表的最后if ((e = p.next) == null) {p.next = newNode(hash, key, value, null);// 如果鏈表長(zhǎng)度大于或等于樹化閾值,則進(jìn)行樹化操作if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1sttreeifyBin(tab, hash);break;}// 條件為 true,表示當(dāng)前鏈表包含要插入的鍵值對(duì),終止遍歷if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))break;p = e;}}// 判斷要插入的鍵值對(duì)是否存在 HashMap 中if (e != null) { // existing mapping for keyV oldValue = e.value;// onlyIfAbsent 表示是否僅在 oldValue 為 null 的情況下更新鍵值對(duì)的值if (!onlyIfAbsent || oldValue == null)e.value = value;afterNodeAccess(e);return oldValue;}}++modCount;// 鍵值對(duì)數(shù)量超過閾值時(shí),則進(jìn)行擴(kuò)容if (++size > threshold)resize();afterNodeInsertion(evict);return null; } |
插入操作的入口方法是?put(K,V),但核心邏輯在V putVal(int, K, V, boolean, boolean)?方法中。putVal 方法主要做了這么幾件事情:
以上就是 HashMap 插入的邏輯,并不是很復(fù)雜,這里就不多說了。接下來來分析一下擴(kuò)容機(jī)制。
?3.4.2 擴(kuò)容機(jī)制
在 Java 中,數(shù)組的長(zhǎng)度是固定的,這意味著數(shù)組只能存儲(chǔ)固定量的數(shù)據(jù)。但在開發(fā)的過程中,很多時(shí)候我們無法知道該建多大的數(shù)組合適。建小了不夠用,建大了用不完,造成浪費(fèi)。如果我們能實(shí)現(xiàn)一種變長(zhǎng)的數(shù)組,并按需分配空間就好了。好在,我們不用自己實(shí)現(xiàn)變長(zhǎng)數(shù)組,Java 集合框架已經(jīng)實(shí)現(xiàn)了變長(zhǎng)的數(shù)據(jù)結(jié)構(gòu)。比如 ArrayList 和 HashMap。對(duì)于這類基于數(shù)組的變長(zhǎng)數(shù)據(jù)結(jié)構(gòu),擴(kuò)容是一個(gè)非常重要的操作。下面就來聊聊 HashMap 的擴(kuò)容機(jī)制。
在詳細(xì)分析之前,先來說一下擴(kuò)容相關(guān)的背景知識(shí):
在 HashMap 中,桶數(shù)組的長(zhǎng)度均是2的冪,閾值大小為桶數(shù)組長(zhǎng)度與負(fù)載因子的乘積。當(dāng) HashMap 中的鍵值對(duì)數(shù)量超過閾值時(shí),進(jìn)行擴(kuò)容。
HashMap 的擴(kuò)容機(jī)制與其他變長(zhǎng)集合的套路不太一樣,HashMap 按當(dāng)前桶數(shù)組長(zhǎng)度的2倍進(jìn)行擴(kuò)容,閾值也變?yōu)樵瓉淼?倍(如果計(jì)算過程中,閾值溢出歸零,則按閾值公式重新計(jì)算)。擴(kuò)容之后,要重新計(jì)算鍵值對(duì)的位置,并把它們移動(dòng)到合適的位置上去。以上就是 HashMap 的擴(kuò)容大致過程,接下來我們來看看具體的實(shí)現(xiàn):
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 | final Node<K,V>[] resize() {Node<K,V>[] oldTab = table;int oldCap = (oldTab == null) ? 0 : oldTab.length;int oldThr = threshold;int newCap, newThr = 0;// 如果 table 不為空,表明已經(jīng)初始化過了if (oldCap > 0) {// 當(dāng) table 容量超過容量最大值,則不再擴(kuò)容if (oldCap >= MAXIMUM_CAPACITY) {threshold = Integer.MAX_VALUE;return oldTab;} // 按舊容量和閾值的2倍計(jì)算新容量和閾值的大小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/** 初始化時(shí),將 threshold 的值賦值給 newCap,* HashMap 使用 threshold 變量暫時(shí)保存 initialCapacity 參數(shù)的值*/ newCap = oldThr;else { // zero initial threshold signifies using defaults/** 調(diào)用無參構(gòu)造方法時(shí),桶數(shù)組容量為默認(rèn)容量,* 閾值為默認(rèn)容量與默認(rèn)負(fù)載因子乘積*/newCap = DEFAULT_INITIAL_CAPACITY;newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);}// newThr 為 0 時(shí),按閾值計(jì)算公式進(jìn)行計(jì)算if (newThr == 0) {float ft = (float)newCap * loadFactor;newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?(int)ft : Integer.MAX_VALUE);}threshold = newThr;// 創(chuàng)建新的桶數(shù)組,桶數(shù)組的初始化也是在這里完成的Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];table = newTab;if (oldTab != null) {// 如果舊的桶數(shù)組不為空,則遍歷桶數(shù)組,并將鍵值對(duì)映射到新的桶數(shù)組中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)// 重新映射時(shí),需要對(duì)紅黑樹進(jìn)行拆分((TreeNode<K,V>)e).split(this, newTab, j, oldCap);else { // preserve orderNode<K,V> loHead = null, loTail = null;Node<K,V> hiHead = null, hiTail = null;Node<K,V> next;// 遍歷鏈表,并將鏈表節(jié)點(diǎn)按原順序進(jìn)行分組do {next = e.next;if ((e.hash & oldCap) == 0) {if (loTail == null)loHead = e;elseloTail.next = e;loTail = e;}else {if (hiTail == null)hiHead = e;elsehiTail.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; } |
上面的源碼有點(diǎn)長(zhǎng),希望大家耐心看懂它的邏輯。上面的源碼總共做了3件事,分別是:
上面列的三點(diǎn)中,創(chuàng)建新的桶數(shù)組就一行代碼,不用說了。接下來,來說說第一點(diǎn)和第三點(diǎn),先說說 newCap 和 newThr 計(jì)算過程。該計(jì)算過程對(duì)應(yīng) resize 源碼的第一和第二個(gè)條件分支,如下:
| 1 2 3 4 5 6 7 8 9 10 11 12 | // 第一個(gè)條件分支 if ( oldCap > 0) {// 嵌套條件分支if (oldCap >= MAXIMUM_CAPACITY) {...}else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&oldCap >= DEFAULT_INITIAL_CAPACITY) {...} } else if (oldThr > 0) {...} else {...}// 第二個(gè)條件分支 if (newThr == 0) {...} |
通過這兩個(gè)條件分支對(duì)不同情況進(jìn)行判斷,進(jìn)而算出不同的容量值和閾值。它們所覆蓋的情況如下:
分支一:
| oldCap > 0 | 桶數(shù)組 table 已經(jīng)被初始化 | ? |
| oldThr > 0 | threshold > 0,且桶數(shù)組未被初始化 | 調(diào)用?HashMap(int) 和 HashMap(int, float) 構(gòu)造方法時(shí)會(huì)產(chǎn)生這種情況,此種情況下 newCap = oldThr,newThr 在第二個(gè)條件分支中算出 |
| oldCap == 0 &&?oldThr == 0 | 桶數(shù)組未被初始化,且?threshold 為 0 | 調(diào)用?HashMap() 構(gòu)造方法會(huì)產(chǎn)生這種情況。 |
這里把oldThr > 0情況單獨(dú)拿出來說一下。在這種情況下,會(huì)將 oldThr 賦值給 newCap,等價(jià)于newCap = threshold = tableSizeFor(initialCapacity)。我們?cè)诔跏蓟瘯r(shí)傳入的 initialCapacity 參數(shù)經(jīng)過 threshold 中轉(zhuǎn)最終賦值給了 newCap。這也就解答了前面提的一個(gè)疑問:initialCapacity 參數(shù)沒有被保存下來,那么它怎么參與桶數(shù)組的初始化過程的呢?
嵌套分支:
| oldCap >= 230 | 桶數(shù)組容量大于或等于最大桶容量 230 | 這種情況下不再擴(kuò)容 |
| newCap < 230?&& oldCap > 16 | 新桶數(shù)組容量小于最大值,且舊桶數(shù)組容量大于 16 | 該種情況下新閾值 newThr = oldThr << 1,移位可能會(huì)導(dǎo)致溢出 |
這里簡(jiǎn)單說明一下移位導(dǎo)致的溢出情況,當(dāng) loadFactor小數(shù)位為 0,整數(shù)位可被2整除且大于等于8時(shí),在某次計(jì)算中就可能會(huì)導(dǎo)致 newThr 溢出歸零。見下圖:
分支二:
| newThr == 0 | 第一個(gè)條件分支未計(jì)算 newThr 或嵌套分支在計(jì)算過程中導(dǎo)致 newThr 溢出歸零 | ? |
說完 newCap 和 newThr 的計(jì)算過程,接下來再來分析一下鍵值對(duì)節(jié)點(diǎn)重新映射的過程。
在 JDK 1.8 中,重新映射節(jié)點(diǎn)需要考慮節(jié)點(diǎn)類型。對(duì)于樹形節(jié)點(diǎn),需先拆分紅黑樹再映射。對(duì)于鏈表類型節(jié)點(diǎn),則需先對(duì)鏈表進(jìn)行分組,然后再映射。需要的注意的是,分組后,組內(nèi)節(jié)點(diǎn)相對(duì)位置保持不變。關(guān)于紅黑樹拆分的邏輯將會(huì)放在下一小節(jié)說明,先來看看鏈表是怎樣進(jìn)行分組映射的。
我們都知道往底層數(shù)據(jù)結(jié)構(gòu)中插入節(jié)點(diǎn)時(shí),一般都是先通過模運(yùn)算計(jì)算桶位置,接著把節(jié)點(diǎn)放入桶中即可。事實(shí)上,我們可以把重新映射看做插入操作。在 JDK 1.7 中,也確實(shí)是這樣做的。但在 JDK 1.8 中,則對(duì)這個(gè)過程進(jìn)行了一定的優(yōu)化,邏輯上要稍微復(fù)雜一些。在詳細(xì)分析前,我們先來回顧一下 hash 求余的過程:
上圖中,桶數(shù)組大小 n = 16,hash1 與 hash2 不相等。但因?yàn)橹挥泻?位參與求余,所以結(jié)果相等。當(dāng)桶數(shù)組擴(kuò)容后,n 由16變成了32,對(duì)上面的 hash 值重新進(jìn)行映射:
擴(kuò)容后,參與模運(yùn)算的位數(shù)由4位變?yōu)榱?位。由于兩個(gè) hash 第5位的值是不一樣,所以兩個(gè) hash 算出的結(jié)果也不一樣。上面的計(jì)算過程并不難理解,繼續(xù)往下分析。
假設(shè)我們上圖的桶數(shù)組進(jìn)行擴(kuò)容,擴(kuò)容后容量 n = 16,重新映射過程如下:
依次遍歷鏈表,并計(jì)算節(jié)點(diǎn)?hash & oldCap?的值。如下圖所示
如果值為0,將 loHead 和 loTail 指向這個(gè)節(jié)點(diǎn)。如果后面還有節(jié)點(diǎn) hash & oldCap 為0的話,則將節(jié)點(diǎn)鏈入 loHead 指向的鏈表中,并將 loTail 指向該節(jié)點(diǎn)。如果值為非0的話,則讓 hiHead 和 hiTail 指向該節(jié)點(diǎn)。完成遍歷后,可能會(huì)得到兩條鏈表,此時(shí)就完成了鏈表分組:
最后再將這兩條鏈接存放到相應(yīng)的桶中,完成擴(kuò)容。如下圖:
從上圖可以發(fā)現(xiàn),重新映射后,兩條鏈表中的節(jié)點(diǎn)順序并未發(fā)生變化,還是保持了擴(kuò)容前的順序。以上就是 JDK 1.8 中 HashMap 擴(kuò)容的代碼講解。另外再補(bǔ)充一下,JDK 1.8 版本下 HashMap 擴(kuò)容效率要高于之前版本。如果大家看過 JDK 1.7 的源碼會(huì)發(fā)現(xiàn),JDK 1.7 為了防止因 hash 碰撞引發(fā)的拒絕服務(wù)攻擊,在計(jì)算 hash 過程中引入隨機(jī)種子。以增強(qiáng) hash 的隨機(jī)性,使得鍵值對(duì)均勻分布在桶數(shù)組中。在擴(kuò)容過程中,相關(guān)方法會(huì)根據(jù)容量判斷是否需要生成新的隨機(jī)種子,并重新計(jì)算所有節(jié)點(diǎn)的 hash。而在 JDK 1.8 中,則通過引入紅黑樹替代了該種方式。從而避免了多次計(jì)算 hash 的操作,提高了擴(kuò)容效率。
本小節(jié)的內(nèi)容講就先講到這,接下來,來講講鏈表與紅黑樹相互轉(zhuǎn)換的過程。
?3.4.3 鏈表樹化、紅黑樹鏈化與拆分
JDK 1.8 對(duì) HashMap 實(shí)現(xiàn)進(jìn)行了改進(jìn)。最大的改進(jìn)莫過于在引入了紅黑樹處理頻繁的碰撞,代碼復(fù)雜度也隨之上升。比如,以前只需實(shí)現(xiàn)一套針對(duì)鏈表操作的方法即可。而引入紅黑樹后,需要另外實(shí)現(xiàn)紅黑樹相關(guān)的操作。紅黑樹是一種自平衡的二叉查找樹,本身就比較復(fù)雜。本篇文章中并不打算對(duì)紅黑樹展開介紹,本文僅會(huì)介紹鏈表樹化需要注意的地方。至于紅黑樹詳細(xì)的介紹,如果大家有興趣,可以參考我的另一篇文章 -?紅黑樹詳細(xì)分析。
在展開說明之前,先把樹化的相關(guān)代碼貼出來,如下:
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 | static final int TREEIFY_THRESHOLD = 8;/*** 當(dāng)桶數(shù)組容量小于該值時(shí),優(yōu)先進(jìn)行擴(kuò)容,而不是樹化*/ static final int MIN_TREEIFY_CAPACITY = 64;static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {TreeNode<K,V> parent; // red-black tree linksTreeNode<K,V> left;TreeNode<K,V> right;TreeNode<K,V> prev; // needed to unlink next upon deletionboolean red;TreeNode(int hash, K key, V val, Node<K,V> next) {super(hash, key, val, next);} }/*** 將普通節(jié)點(diǎn)鏈表轉(zhuǎn)換成樹形節(jié)點(diǎn)鏈表*/ final void treeifyBin(Node<K,V>[] tab, int hash) {int n, index; Node<K,V> e;// 桶數(shù)組容量小于 MIN_TREEIFY_CAPACITY,優(yōu)先進(jìn)行擴(kuò)容而不是樹化if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)resize();else if ((e = tab[index = (n - 1) & hash]) != null) {// hd 為頭節(jié)點(diǎn)(head),tl 為尾節(jié)點(diǎn)(tail)TreeNode<K,V> hd = null, tl = null;do {// 將普通節(jié)點(diǎn)替換成樹形節(jié)點(diǎn)TreeNode<K,V> p = replacementTreeNode(e, null);if (tl == null)hd = p;else {p.prev = tl;tl.next = p;}tl = p;} while ((e = e.next) != null); // 將普通鏈表轉(zhuǎn)成由樹形節(jié)點(diǎn)鏈表if ((tab[index] = hd) != null)// 將樹形鏈表轉(zhuǎn)換成紅黑樹hd.treeify(tab);} }TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {return new TreeNode<>(p.hash, p.key, p.value, next); } |
在擴(kuò)容過程中,樹化要滿足兩個(gè)條件:
第一個(gè)條件比較好理解,這里就不說了。這里來說說加入第二個(gè)條件的原因,個(gè)人覺得原因如下:
當(dāng)桶數(shù)組容量比較小時(shí),鍵值對(duì)節(jié)點(diǎn) hash 的碰撞率可能會(huì)比較高,進(jìn)而導(dǎo)致鏈表長(zhǎng)度較長(zhǎng)。這個(gè)時(shí)候應(yīng)該優(yōu)先擴(kuò)容,而不是立馬樹化。畢竟高碰撞率是因?yàn)橥皵?shù)組容量較小引起的,這個(gè)是主因。容量小時(shí),優(yōu)先擴(kuò)容可以避免一些列的不必要的樹化過程。同時(shí),桶容量較小時(shí),擴(kuò)容會(huì)比較頻繁,擴(kuò)容時(shí)需要拆分紅黑樹并重新映射。所以在桶容量比較小的情況下,將長(zhǎng)鏈表轉(zhuǎn)成紅黑樹是一件吃力不討好的事。
回到上面的源碼中,我們繼續(xù)看一下 treeifyBin 方法。該方法主要的作用是將普通鏈表轉(zhuǎn)成為由 TreeNode 型節(jié)點(diǎn)組成的鏈表,并在最后調(diào)用 treeify 是將該鏈表轉(zhuǎn)為紅黑樹。TreeNode 繼承自 Node 類,所以 TreeNode 仍然包含 next 引用,原鏈表的節(jié)點(diǎn)順序最終通過 next 引用被保存下來。我們假設(shè)樹化前,鏈表結(jié)構(gòu)如下:
HashMap 在設(shè)計(jì)之初,并沒有考慮到以后會(huì)引入紅黑樹進(jìn)行優(yōu)化。所以并沒有像 TreeMap 那樣,要求鍵類實(shí)現(xiàn) comparable 接口或提供相應(yīng)的比較器。但由于樹化過程需要比較兩個(gè)鍵對(duì)象的大小,在鍵類沒有實(shí)現(xiàn) comparable 接口的情況下,怎么比較鍵與鍵之間的大小了就成了一個(gè)棘手的問題。為了解決這個(gè)問題,HashMap 是做了三步處理,確保可以比較出兩個(gè)鍵的大小,如下:
tie break 是網(wǎng)球術(shù)語,可以理解為加時(shí)賽的意思,起這個(gè)名字還是挺有意思的。
通過上面三次比較,最終就可以比較出孰大孰小。比較出大小后就可以構(gòu)造紅黑樹了,最終構(gòu)造出的紅黑樹如下:
橙色的箭頭表示 TreeNode 的 next 引用。由于空間有限,prev 引用未畫出。可以看出,鏈表轉(zhuǎn)成紅黑樹后,原鏈表的順序仍然會(huì)被引用仍被保留了(紅黑樹的根節(jié)點(diǎn)會(huì)被移動(dòng)到鏈表的第一位),我們?nèi)匀豢梢园幢闅v鏈表的方式去遍歷上面的紅黑樹。這樣的結(jié)構(gòu)為后面紅黑樹的切分以及紅黑樹轉(zhuǎn)成鏈表做好了鋪墊,我們繼續(xù)往下分析。
?紅黑樹拆分
擴(kuò)容后,普通節(jié)點(diǎn)需要重新映射,紅黑樹節(jié)點(diǎn)也不例外。按照一般的思路,我們可以先把紅黑樹轉(zhuǎn)成鏈表,之后再重新映射鏈表即可。這種處理方式是大家比較容易想到的,但這樣做會(huì)損失一定的效率。不同于上面的處理方式,HashMap 實(shí)現(xiàn)的思路則是上好佳(上好佳請(qǐng)把廣告費(fèi)打給我)。如上節(jié)所說,在將普通鏈表轉(zhuǎn)成紅黑樹時(shí),HashMap 通過兩個(gè)額外的引用 next 和 prev 保留了原鏈表的節(jié)點(diǎn)順序。這樣再對(duì)紅黑樹進(jìn)行重新映射時(shí),完全可以按照映射鏈表的方式進(jìn)行。這樣就避免了將紅黑樹轉(zhuǎn)成鏈表后再進(jìn)行映射,無形中提高了效率。
以上就是紅黑樹拆分的邏輯,下面看一下具體實(shí)現(xiàn)吧:
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 | // 紅黑樹轉(zhuǎn)鏈表閾值 static final int UNTREEIFY_THRESHOLD = 6;final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {TreeNode<K,V> b = this;// Relink into lo and hi lists, preserving orderTreeNode<K,V> loHead = null, loTail = null;TreeNode<K,V> hiHead = null, hiTail = null;int lc = 0, hc = 0;/* * 紅黑樹節(jié)點(diǎn)仍然保留了 next 引用,故仍可以按鏈表方式遍歷紅黑樹。* 下面的循環(huán)是對(duì)紅黑樹節(jié)點(diǎn)進(jìn)行分組,與上面類似*/for (TreeNode<K,V> e = b, next; e != null; e = next) {next = (TreeNode<K,V>)e.next;e.next = null;if ((e.hash & bit) == 0) {if ((e.prev = loTail) == null)loHead = e;elseloTail.next = e;loTail = e;++lc;}else {if ((e.prev = hiTail) == null)hiHead = e;elsehiTail.next = e;hiTail = e;++hc;}}if (loHead != null) {// 如果 loHead 不為空,且鏈表長(zhǎng)度小于等于 6,則將紅黑樹轉(zhuǎn)成鏈表if (lc <= UNTREEIFY_THRESHOLD)tab[index] = loHead.untreeify(map);else {tab[index] = loHead;/* * hiHead == null 時(shí),表明擴(kuò)容后,* 所有節(jié)點(diǎn)仍在原位置,樹結(jié)構(gòu)不變,無需重新樹化*/if (hiHead != null) loHead.treeify(tab);}}// 與上面類似if (hiHead != null) {if (hc <= UNTREEIFY_THRESHOLD)tab[index + bit] = hiHead.untreeify(map);else {tab[index + bit] = hiHead;if (loHead != null)hiHead.treeify(tab);}} } |
從源碼上可以看得出,重新映射紅黑樹的邏輯和重新映射鏈表的邏輯基本一致。不同的地方在于,重新映射后,會(huì)將紅黑樹拆分成兩條由 TreeNode 組成的鏈表。如果鏈表長(zhǎng)度小于 UNTREEIFY_THRESHOLD,則將鏈表轉(zhuǎn)換成普通鏈表。否則根據(jù)條件重新將 TreeNode 鏈表樹化。舉個(gè)例子說明一下,假設(shè)擴(kuò)容后,重新映射上圖的紅黑樹,映射結(jié)果如下:
?紅黑樹鏈化
前面說過,紅黑樹中仍然保留了原鏈表節(jié)點(diǎn)順序。有了這個(gè)前提,再將紅黑樹轉(zhuǎn)成鏈表就簡(jiǎn)單多了,僅需將 TreeNode 鏈表轉(zhuǎn)成 Node 類型的鏈表即可。相關(guān)代碼如下:
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | final Node<K,V> untreeify(HashMap<K,V> map) {Node<K,V> hd = null, tl = null;// 遍歷 TreeNode 鏈表,并用 Node 替換for (Node<K,V> q = this; q != null; q = q.next) {// 替換節(jié)點(diǎn)類型Node<K,V> p = map.replacementNode(q, null);if (tl == null)hd = p;elsetl.next = p;tl = p;}return hd; }Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next) {return new Node<>(p.hash, p.key, p.value, next); } |
上面的代碼并不復(fù)雜,不難理解,這里就不多說了。到此擴(kuò)容相關(guān)內(nèi)容就說完了,不知道大家理解沒。
?3.5 刪除
如果大家堅(jiān)持看完了前面的內(nèi)容,到本節(jié)就可以輕松一下。當(dāng)然,前提是不去看紅黑樹的刪除操作。不過紅黑樹并非本文講解重點(diǎn),本節(jié)中也不會(huì)介紹紅黑樹相關(guān)內(nèi)容,所以大家不用擔(dān)心。
HashMap 的刪除操作并不復(fù)雜,僅需三個(gè)步驟即可完成。第一步是定位桶位置,第二步遍歷鏈表并找到鍵值相等的節(jié)點(diǎn),第三步刪除節(jié)點(diǎn)。相關(guān)源碼如下:
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | public V remove(Object key) {Node<K,V> e;return (e = removeNode(hash(key), key, null, false, true)) == null ?null : e.value; }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;if ((tab = table) != null && (n = tab.length) > 0 &&// 1. 定位桶位置(p = tab[index = (n - 1) & hash]) != null) {Node<K,V> node = null, e; K k; V v;// 如果鍵的值與鏈表第一個(gè)節(jié)點(diǎn)相等,則將 node 指向該節(jié)點(diǎn)if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))node = p;else if ((e = p.next) != null) { // 如果是 TreeNode 類型,調(diào)用紅黑樹的查找邏輯定位待刪除節(jié)點(diǎn)if (p instanceof TreeNode)node = ((TreeNode<K,V>)p).getTreeNode(hash, key);else {// 2. 遍歷鏈表,找到待刪除節(jié)點(diǎn)do {if (e.hash == hash &&((k = e.key) == key ||(key != null && key.equals(k)))) {node = e;break;}p = e;} while ((e = e.next) != null);}}// 3. 刪除節(jié)點(diǎn),并修復(fù)鏈表或紅黑樹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;elsep.next = node.next;++modCount;--size;afterNodeRemoval(node);return node;}}return null; } |
刪除操作本身并不復(fù)雜,有了前面的基礎(chǔ),理解起來也就不難了,這里就不多說了。
?3.6 其他細(xì)節(jié)
前面的內(nèi)容分析了 HashMap 的常用操作及相關(guān)的源碼,本節(jié)內(nèi)容再補(bǔ)充一點(diǎn)其他方面的東西。
?被 transient 所修飾 table 變量
如果大家細(xì)心閱讀 HashMap 的源碼,會(huì)發(fā)現(xiàn)桶數(shù)組 table 被申明為 transient。transient 表示易變的意思,在 Java 中,被該關(guān)鍵字修飾的變量不會(huì)被默認(rèn)的序列化機(jī)制序列化。我們?cè)倩氐皆创a中,考慮一個(gè)問題:桶數(shù)組 table 是 HashMap 底層重要的數(shù)據(jù)結(jié)構(gòu),不序列化的話,別人還怎么還原呢?
這里簡(jiǎn)單說明一下吧,HashMap 并沒有使用默認(rèn)的序列化機(jī)制,而是通過實(shí)現(xiàn)readObject/writeObject兩個(gè)方法自定義了序列化的內(nèi)容。這樣做是有原因的,試問一句,HashMap 中存儲(chǔ)的內(nèi)容是什么?不用說,大家也知道是鍵值對(duì)。所以只要我們把鍵值對(duì)序列化了,我們就可以根據(jù)鍵值對(duì)數(shù)據(jù)重建 HashMap。有的朋友可能會(huì)想,序列化 table 不是可以一步到位,后面直接還原不就行了嗎?這樣一想,倒也是合理。但序列化 talbe 存在著兩個(gè)問題:
以上兩個(gè)問題中,第一個(gè)問題比較好理解,第二個(gè)問題解釋一下。HashMap 的get/put/remove等方法第一步就是根據(jù) hash 找到鍵所在的桶位置,但如果鍵沒有覆寫 hashCode 方法,計(jì)算 hash 時(shí)最終調(diào)用 Object 中的 hashCode 方法。但 Object 中的 hashCode 方法是 native 型的,不同的 JVM 下,可能會(huì)有不同的實(shí)現(xiàn),產(chǎn)生的 hash 可能也是不一樣的。也就是說同一個(gè)鍵在不同平臺(tái)下可能會(huì)產(chǎn)生不同的 hash,此時(shí)再對(duì)在同一個(gè) table 繼續(xù)操作,就會(huì)出現(xiàn)問題。
綜上所述,大家應(yīng)該能明白 HashMap 不序列化 table 的原因了。
?3.7 總結(jié)
本章對(duì) HashMap 常見操作相關(guān)代碼進(jìn)行了詳細(xì)分析,并在最后補(bǔ)充了一些其他細(xì)節(jié)。在本章中,插入操作一節(jié)的內(nèi)容說的最多,主要是因?yàn)椴迦氩僮魃婕暗狞c(diǎn)特別多,一環(huán)扣一環(huán)。包含但不限于“table 初始化、擴(kuò)容、樹化”等,總體來說,插入操作分析起來難度還是很大的。好在,最后分析完了。
本章篇幅雖比較大,但仍未把 HashMap 所有的點(diǎn)都分析到。比如,紅黑樹的增刪查等操作。當(dāng)然,我個(gè)人看來,以上的分析已經(jīng)夠了。畢竟大家是類庫的使用者而不是設(shè)計(jì)者,沒必要去弄懂每個(gè)細(xì)節(jié)。所以如果某些細(xì)節(jié)實(shí)在看不懂的話就跳過吧,對(duì)我們開發(fā)來說,知道 HashMap 大致原理即可。
好了,本章到此結(jié)束。
?4、寫在最后
寫到這里終于可以松一口氣了,這篇文章前前后后花了我一周多的時(shí)間。在我寫這篇文章之前,對(duì) HashMap 認(rèn)識(shí)僅限于原理層面,并未深入了解。一開始,我覺得關(guān)于 HashMap 沒什么好寫的,畢竟大家對(duì) HashMap 多少都有一定的了解。但等我深入閱讀 HashMap 源碼后,發(fā)現(xiàn)之前的認(rèn)知是錯(cuò)的。不是沒什么可寫的,而是可寫的點(diǎn)太多了,不知道怎么寫了。JDK 1.8 版本的 HashMap 實(shí)現(xiàn)上比之前版本要復(fù)雜的多,想弄懂眾多的細(xì)節(jié)難度還是不小的。僅自己弄懂還不夠,還要寫出來,難度就更大了,本篇文章基本上是在邊讀源碼邊寫的狀態(tài)下完成的。由于時(shí)間和能力有限,加之文章篇幅比較大,很難保證不出錯(cuò)分析過程及配圖不出錯(cuò)。如果有錯(cuò)誤,希望大家指出來,我會(huì)及時(shí)修改,這里先謝謝大家。
好了,本文就到這里了,謝謝大家的閱讀!
?參考
- JDK 源碼中 HashMap 的 hash 方法原理是什么?- 知乎
- Java 8系列之重新認(rèn)識(shí)HashMap - 美團(tuán)技術(shù)博客
- python內(nèi)置的hash函數(shù)對(duì)于字符串來說,每次得到的值不一樣?- 知乎
- Java中HashMap關(guān)鍵字transient的疑惑 - segmentFault)
- 本文鏈接:?https://www.tianxiaobo.com/2018/01/18/HashMap-源碼詳細(xì)分析-JDK1-8/
from:http://www.tianxiaobo.com/2018/01/18/HashMap-%E6%BA%90%E7%A0%81%E8%AF%A6%E7%BB%86%E5%88%86%E6%9E%90-JDK1-8/?
總結(jié)
以上是生活随笔為你收集整理的HashMap 源码详细分析(JDK1.8)的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 红黑树详细分析
- 下一篇: String hashCode 方法为什