本文在此博客的內容上進行了部分修改,旨在加深筆者對HashMap的理解,暫不討論紅黑樹相關邏輯java
HashMap做爲常用到的類,大多時候都是隻知道大概原理,好比底層是由數組+鏈表+紅黑樹
實現,使用HashMap存儲自定義類時須要重寫其hashCode和equals方法等等……但對其具體如何實現卻知之甚少,本文將做爲相似筆記的形式記錄筆者的源碼閱讀方式。(在JDK 1.7及其以前由數組加鏈表組成,正常狀況想咱們談論的均爲JDK 1.8及其以後的HashMap。須要注意的是HashMap非線程安全,在多線程下可能會引起多線程問題)node
HashMap 的構造方法很少,只有四個。HashMap 構造方法作的事情比較簡單,通常都是初始化一些重要變量,好比 loadFactor 和 threshold。而底層的數據結構則是延遲到插入鍵值對時再進行初始化。HashMap 相關構造方法以下:segmentfault
//構造方法1,也是使用最多的一種 public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } //構造方法2,能夠設置初始容量 public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } //構造方法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); } //構造方法4,經過一個已有的map映射到新的map public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }
第二個方法調用了第三個方法,通常來講前兩個方法用的是比較多的,至於負載因子(loadFactor)通常使用默認的0.75就行。構造方法主要是進行一些參數的設置,下面說說初始化的這些參數。數組
咱們在通常狀況下,都會使用無參構造方法建立 HashMap。但當咱們對時間和空間複雜度有要求的時候,使用默認值有時可能達不到咱們的要求,這個時候咱們就須要手動調參。在 HashMap 構造方法中,可供咱們調整的參數有兩個,一個是初始容量 initialCapacity,另外一個負載因子 loadFactor。經過這兩個設定這兩個參數,能夠進一步影響閾值大小。但初始閾值 threshold 僅由 initialCapacity 通過移位操做計算得出。他們的做用分別以下:安全
名稱 | 用途 |
---|---|
initialCapacity | HashMap 初始容量 |
loadFactor | 負載因子 |
threshold | 當前 HashMap 所能容納鍵值對數量的最大值,超過這個值,則需擴容 |
先介紹幾個常量值:數據結構
//The default initial capacity - MUST be a power of two. //默認容量爲16,容量必須爲2的冪次方:16,32,64…… static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 /*The maximum capacity, used if a higher value is implicitly specified by either of the constructors with arguments. MUST be a power of two <= 1<<30. */ //最大容量爲2的30次冪 static final int MAXIMUM_CAPACITY = 1 << 30; //The load factor used when none specified in constructor. //默認負載因子爲0.75 static final float DEFAULT_LOAD_FACTOR = 0.75f; //The next size value at which to resize (capacity * load factor). // 閾值並無初始值,由於閾值 = 容量 * 負載因子 int threshold; //樹化閾值,即桶元素達到8時而且桶數達到MIN_TREEIFY_CAPACITY(64)時開始樹化,把鏈表轉化爲紅黑樹 static final int TREEIFY_THRESHOLD = 8; //紅黑樹拆分閾值,即擴容後若是數的節點小於6,則把紅黑樹轉化爲鏈表 static final int UNTREEIFY_THRESHOLD = 6; /* 當哈希表中的容量達到這個值時,表中的桶才能進行樹形化,不然桶內元素太多時會優先擴容,而不是樹形化 這是由於當容量較小時桶內元素數更容易超過樹化閾值,此時應該優先選擇擴容而不是樹化,這樣能夠避免頻繁樹化。 */ static final int MIN_TREEIFY_CAPACITY = 64;
在上面的代碼中使用了位運算:<<
,位運算是直接操做二進制位,<<
表明向左移動,這裏只須要知道左移一位:a << 1
等價於a * 2
就好了(右移一位等價於除以2)。代碼中並無定義初始容量initialCapacity這個變量,由於initialCapacity只使用一次,而且能夠被threshold暫時代替(下面會有講到),所以並無必要浪費空間存儲該值。
默認狀況下HashMap容量爲16,負載因子0.75,這裏並無默認閾值,緣由是閾值可由容量乘上負載因子計算而來(註釋中有說明),即threshold = capacity * loadFactor。但當你仔細看構造方法3時,會發現閾值並非由上面公式計算而來,而是經過一個方法算出來的。咱們來看看初始化 threshold 的方法長什麼樣的的,源碼以下:多線程
/** * 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; }
上面的代碼不太好理解,使用了無符號右移>>>
和或|
運算。具體位運算如何使用請百度,本文再也不贅述。該段代碼的做用是找到大於或等於 cap 的最小2的冪。能夠經過如下運算看出端倪:app
int cap = (int) Math.pow(2, 29) + 1;//2的29次冪+1 System.out.println(Integer.toBinaryString(cap));//輸出cap的二進制數 int n = cap - 1; System.out.println(Integer.toBinaryString(n)); n |= n >>> 1; System.out.println(Integer.toBinaryString(n)); n |= n >>> 2; System.out.println(Integer.toBinaryString(n)); n |= n >>> 4; System.out.println(Integer.toBinaryString(n)); n |= n >>> 8; System.out.println(Integer.toBinaryString(n)); n |= n >>> 16; System.out.println(Integer.toBinaryString(n)); n = n + 1; System.out.println(Integer.toBinaryString(n));
輸出結果:框架
100000000000000000000000000001 100000000000000000000000000000 110000000000000000000000000000 111100000000000000000000000000 111111110000000000000000000000 111111111111111100000000000000 111111111111111111111111111111 1000000000000000000000000000000
這裏放一張圖解:
函數
說完了初始閾值的計算過程,再來講說負載因子(loadFactor)。對於 HashMap 來講,負載因子是一個很重要的參數,該參數反應了 HashMap 桶數組的使用狀況(假設鍵值對節點均勻分佈在桶數組中)。經過調節負載因子,可以使 HashMap 時間和空間複雜度上有不一樣的表現。當咱們調低負載因子時,HashMap 所能容納的鍵值對數量變少。擴容時,從新將鍵值對存儲新的桶數組裏,鍵的鍵之間產生的碰撞會降低,鏈表長度變短。此時,HashMap 的增刪改查等操做的效率將會變高,這裏是典型的拿空間換時間。相反,若是增長負載因子(負載因子能夠大於1),HashMap 所能容納的鍵值對數量變多,空間利用率高,但碰撞率也高。這意味着鏈表長度變長,效率也隨之下降,這種狀況是拿時間換空間。至於負載因子怎麼調節,這個看使用場景了。通常狀況下,咱們用默認值就能夠了。
首先要知道內部的Node這個類,鏈表、二叉樹通常都是使用相似這種的類,沒什麼好說的。
static class Node<K,V> implements Map.Entry<K,V> { final int hash;//節點的hash final K key;//節點的鍵 V value;//節點的值 Node<K,V> next;//節點的下一個節點 //構造方法 Node(int hash, K key, V value, Node<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } public final K getKey() { return key; } public final V getValue() { return value; } public final String toString() { return key + "=" + value; } //下面這些沒什麼好說的 public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } public final boolean equals(Object o) { if (o == this) return true; if (o instanceof Map.Entry) { Map.Entry<?,?> e = (Map.Entry<?,?>)o; if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue())) return true; } return false; } } //這個table就是底層的數組,每一個節點能夠能夠經過其成員變量next造成鏈表 transient Node<K,V>[] table;
查找操做就是利用HashMap的原理:
//get()方法,經過key獲取value public V get(Object key) { //聲明一個節點,調用內部方法獲取該key的節點,並返回其value Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; } //上面的方法並無調用key自己的hashCode()方法,而是使用了本身的靜態方法: static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);//int類型爲32位,無符號右移16位表明取到了高16位的值 } //這麼作的目的是使hashcode的高位也參與運算,增長隨機性 //主要步驟在這個方法裏 final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; // 1. 定位鍵值對所在桶的位置,即若是這個鍵在表裏,它會在哪一個桶 if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {//tab[(n - 1) & hash]這是個重點 //總會檢查桶的第一個節點 if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) return first;//表明第一個節點就是要找的 //到這裏說明不是第一個節點 if ((e = first.next) != null) {//第一個節點以後還有節點的話 //若是這個桶放的是紅黑樹了,調用紅黑樹的查找方法 if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); //到這裏說明不是紅黑樹,而是鏈表,直接遍歷 do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } //到這裏說明沒找到 return null; }
上面,定位桶所在的位置關鍵在這一步:
// index = (n - 1) & hash first = tab[(n - 1) & hash]
這裏,在n爲2的冪次方的時候,(n - 1) & hash
等價於hash % n
可是效率更高。舉個例子hash = 185,n = 16,計算過程以下:
正好能夠獲取到hash對n的模。這裏正好解釋了爲何容量必須是2的冪次方。網上好多人說容量不是2的冪次方以後,是由於(n - 1) & hash
這麼運算所得的值會衝突,而且會致使有些桶不能放元素了。但筆者不這麼認爲,由於假如容量不是2的冪次方以後,確定不能使用(n - 1) & hash
這種運算方式了,只能使用模運算%
,這樣就會致使效率低好多。因此空間必須是2的冪次方以後才能(n - 1) & hash
這麼運算,最終獲得效率上的提高。
可是上面的hash並不是key的hashCode方法,而是HashMap的靜態方法:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
這樣作有兩個好處,首先看一下上面求餘的計算圖,圖中的 hash 是由鍵的 hashCode 產生。計算餘數時,因爲 n 比較小,hash 只有低4位參與了計算,高位的計算能夠認爲是無效的。這樣致使了計算結果只與低位信息有關,高位數據沒發揮做用。爲了處理這個缺陷,咱們能夠上圖中的 hash 高4位數據與低4位數據進行異或運算,即 hash ^ (hash >>> 4)
。經過這種方式,讓高位數據與低位數據進行異或,以此加大低位信息的隨機性,變相的讓高位數據參與到計算中。此時的計算過程以下:
在 Java 中,hashCode 方法產生的 hash 是 int 類型,32 位寬。前16位爲高位,後16位爲低位,因此要右移16位。
上面所說的是從新計算 hash 的一個好處,除此以外,從新計算 hash 的另外一個好處是能夠增長 hash 的複雜度。當咱們覆寫 hashCode 方法時,可能會寫出分佈性不佳的 hashCode 方法,進而致使 hash 的衝突率比較高。經過移位和異或運算,可讓 hash 變得更復雜,進而影響 hash 的分佈性。這也就是爲何 HashMap 不直接使用鍵對象原始 hash 的緣由了。
和查找查找同樣,遍歷操做也是你們使用頻率比較高的一個操做。對於 遍歷 HashMap,咱們通常都會用下面兩種方式:
//加強for循環體遍歷keySet for(Object key : map.keySet()) { // do something } //遍歷entrySet for(HashMap.Entry entry : map.entrySet()) { // do something }
加強型for循環的底層原理就是迭代器,所以上面的代碼至關於:
Set keys = map.keySet(); Iterator ite = keys.iterator(); while (ite.hasNext()) { Object key = ite.next(); // do something }
你們在遍歷 HashMap 的過程當中會發現,屢次對 HashMap 進行遍歷時,遍歷結果順序都是一致的。但這個順序和插入的順序通常都是不一致的。爲何呢,這裏首先分析一下keySet的遍歷:
//keySet方法,返回的是一個內部KeySet類 public Set<K> keySet() { Set<K> ks = keySet; if (ks == null) { ks = new KeySet(); keySet = ks; } return ks; } //內部的KeySet類 final class KeySet extends AbstractSet<K> { //大小 public final int size() { return size; } //清空 public final void clear() { HashMap.this.clear(); } //注意這一步,返回的是下面的KeyIterator類的對象 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; } //多線程迭代 public final Spliterator<K> spliterator() { return new KeySpliterator<>(HashMap.this, 0, -1, 0, 0); } //集合能夠調用forEach方法,參數爲Consumer函數式接口 public final void forEach(Consumer<? super K> action) { Node<K,V>[] tab; if (action == null) throw new NullPointerException(); if (size > 0 && (tab = table) != null) { int mc = modCount; for (Node<K, V> e : tab) { for (; e != null; e = e.next) action.accept(e.key); } if (modCount != mc) throw new ConcurrentModificationException(); } } } //內部類KeyIterator,繼承抽象內部類HashIterator final class KeyIterator extends HashIterator implements Iterator<K> { public final K next() { return nextNode().key; } } //抽象內部類,下面有KeyIterator、ValueIterator、EntryIterator三個實現子類 abstract class HashIterator { //在當前已讀到的元素的下一個元素 Node<K,V> next; // next entry to return //當前已讀到的元素 Node<K,V> current; // current entry // 指望操做數,用於多線程狀況下,若是多個線程同時對 HashMap 進行讀寫,那麼這個指望操做數 expectedModCount 和 HashMap 的 modCount 就會不一致,這時候拋個異常出來,稱爲「快速失敗」 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) 這行代碼時,有可能這時候 modCount 仍是等於 expectedModCount,當過了這一行代碼,modCount 有可能不等於 expectedModCoun,因此對於這個時候會有一個時差,或許會讀到有問題的數據 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; } // 和外部 remove(Object key) 差很少,可是不會對 table 的元素進行重排,因此這個方法適合一邊迭代一邊刪除元素 public final void remove() { Node<K,V> p = current; if (p == null) throw new IllegalStateException(); //快速失敗 if (modCount != expectedModCount) throw new ConcurrentModificationException(); current = null; removeNode(p.hash, p.key, null, false, false); //操做完更新操做數 expectedModCount = modCount; } }
如上面的源碼,遍歷全部的鍵時,首先要獲取鍵集合KeySet對象,而後再經過 KeySet 的迭代器KeyIterator進行遍歷。KeyIterator 類繼承自HashIterator類,核心邏輯也封裝在 HashIterator 類中。HashIterator 的邏輯並不複雜,在初始化時,HashIterator 先從桶數組中找到包含鏈表節點引用的桶。而後對這個桶指向的鏈表進行遍歷。遍歷完成後,再繼續尋找下一個包含鏈表節點引用的桶,找到繼續遍歷。找不到,則結束遍歷。舉個例子,假設咱們遍歷下圖的結構:
HashIterator 在初始化時,會先遍歷桶數組,找到包含鏈表節點引用的桶,對應圖中就是3號桶。隨後由 nextNode 方法遍歷該桶所指向的鏈表。遍歷完3號桶後,nextNode 方法繼續尋找下一個不爲空的桶,對應圖中的7號桶。以後流程和上面相似,直至遍歷完最後一個桶。以上就是 HashIterator 的核心邏輯的流程,對應下圖:
經過這段代碼能夠驗證一下:
//建立一個HashMap並添加幾個Integer,注意Integer重寫了hashCode方法,Integer的HashCode等於其自己 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, ""); //建立一個list保存這些數以對比 List<Integer> nums = new ArrayList<>(); Collections.addAll(nums, 7, 11, 43, 59, 19, 3, 35); //建立另一個list保存這些數的hashcode對16取餘 List<Integer> numsHash = new ArrayList<>(); for (Integer n : nums) { numsHash.add(n.hashCode() % 16); } System.out.println("Key:"); System.out.println(nums); System.out.println("Key的哈希值對容量(16)取餘:"); System.out.println(numsHash); System.out.println("在Map中的遍歷結果:"); System.out.println(map.keySet());
輸出結果:
Key: [7, 11, 43, 59, 19, 3, 35] Key的哈希值對容量(16)取餘: [7, 11, 11, 11, 3, 3, 3] 在Map中的遍歷結果: [19, 3, 35, 7, 11, 43, 59]
徹底一致
插入的大體流程實際上是比較簡單的:首先確定是先定位要插入的鍵值對屬於哪一個桶,定位到桶後,再判斷桶是否爲空。若是爲空,則將鍵值對存入便可。若是不爲空,則需將鍵值對接在鏈表最後一個位置,或者更新鍵值對。
可是真正的插入流程很是複雜,由於摻雜了桶的擴容以及鏈表的樹化等等。下面上源碼:
//put方法,把新值放入並返回舊值 public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } //內部進行實現的方法,須要注意的是boolean onlyIfAbsent, boolean evict這兩個參數目前並無什麼用 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; //這裏纔是初始化桶,HashMap在建立時並無直接初始化,而是延遲到放入元素時才進行初始化 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;//這裏tab = resize()進行了擴容 //若是鍵所在的桶是空的就直接放入 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { //使用e保存鍵和插入的鍵相同的節點 Node<K,V> e; K k; //若是第一個節點的鍵和要放入的鍵相同,把e指向該節點 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; } //條件爲 true,表示當前鏈表包含要插入的鍵值對,終止遍歷 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e;//這一步是遍歷的關鍵,p指針後移 } } //判斷要插入的鍵值對是否存在 HashMap 中,非空表明存在 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; }
大體流程以下:
仍是比較容易理解的。
在 Java 中,數組的長度是固定的,這意味着數組只能存儲固定量的數據。但在開發的過程當中,不少時候咱們沒法知道該建多大的數組合適。建小了不夠用,建大了用不完,形成浪費。若是咱們能實現一種變長的數組,並按需分配空間就行了。好在,咱們不用本身實現變長數組,Java 集合框架已經實現了變長的數據結構。好比 ArrayList 和 HashMap。對於這類基於數組的變長數據結構,擴容是一個很是重要的操做。下面就來聊聊 HashMap 的擴容機制。
在詳細分析以前,先來講一下擴容相關的背景知識:
在 HashMap 中,桶數組的長度均是2的冪,閾值大小爲桶數組長度與負載因子的乘積。當 HashMap 中的鍵值對數量超過閾值時,進行擴容。
HashMap 的擴容機制與其餘變長集合的套路不太同樣,HashMap 按當前桶數組長度的2倍進行擴容,閾值也變爲原來的2倍(若是計算過程當中,閾值溢出歸零,則按閾值公式從新計算)。擴容以後,要從新計算鍵值對的位置,並把它們移動到合適的位置上去。以上就是 HashMap 的擴容大體過程,接下來咱們來看看具體的實現:
// 擴容方法 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 不爲空,代表已經初始化過了 if (oldCap > 0) { // 當 table 容量超過容量最大值,則再也不擴容 if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } // 按舊容量和閾值的2倍計算新容量和閾值的大小 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 /* * 初始化時,將 threshold 的值賦值給 newCap, * HashMap 使用 threshold 變量暫時保存 initialCapacity 參數的值 */ newCap = oldThr; else { // zero initial threshold signifies using defaults /* * 調用無參構造方法時,桶數組容量爲默認容量, * 閾值爲默認容量與默認負載因子乘積 */ newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } // newThr 爲 0 時,按閾值計算公式進行計算 if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; // 建立新的桶數組,桶數組的初始化也是在這裏完成的 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; }
上面的源碼總共作了3件事,分別是:
上面列的三點中,建立新的桶數組就一行代碼,不用說了。接下來,來講說第一點和第三點,先說說 newCap 和 newThr 計算過程。該計算過程對應 resize 源碼的第一和第二個條件分支,以下:
// 第一個條件分支 if ( oldCap > 0) { // 嵌套條件分支 if (oldCap >= MAXIMUM_CAPACITY) {...} else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) {...} } else if (oldThr > 0) {...} else {...} // 第二個條件分支 if (newThr == 0) {...}
經過這兩個條件分支對不一樣狀況進行判斷,進而算出不一樣的容量值和閾值。它們所覆蓋的狀況以下:
條件 | 覆蓋狀況 | 備註 |
---|---|---|
oldCap > 0 | 桶數組 table 已經被初始化 | |
oldThr > 0 | threshold > 0,且桶數組未被初始化 | 調用 HashMap(int) 和 HashMap(int, float) 構造方法時會產生這種狀況,此種狀況下 newCap = oldThr,newThr 在第二個條件分支中算出 |
oldCap == 0 && oldThr == 0 | 桶數組未被初始化,且 threshold 爲 0 | 調用 HashMap() 構造方法會產生這種狀況。 |
這裏把oldThr > 0狀況單獨拿出來講一下。在這種狀況下,會將 oldThr 賦值給 newCap,等價於newCap = threshold = tableSizeFor(initialCapacity)。咱們在初始化時傳入的 initialCapacity 參數通過 threshold 中轉最終賦值給了 newCap。這也就解答了前面提的一個疑問:initialCapacity 參數沒有被保存下來,那麼它怎麼參與桶數組的初始化過程的呢?
嵌套分支:
條件 | 覆蓋狀況 | 備註 |
---|---|---|
oldCap >= 230 | 桶數組容量大於或等於最大桶容量 230 | 這種狀況下再也不擴容 |
newCap < 2^30 && oldCap > 16 | 新桶數組容量小於最大值,且舊桶數組容量大於 16 | 該種狀況下新閾值 newThr = oldThr << 1,移位可能會致使溢出 |
這裏簡單說明一下移位致使的溢出狀況,當 loadFactor小數位爲 0,整數位可被2整除且大於等於8時,在某次計算中就可能會致使 newThr 溢出歸零。見下圖:
分支二:
條件 | 覆蓋狀況 | 備註 |
---|---|---|
newThr == 0 | 第一個條件分支未計算 newThr 或嵌套分支在計算過程當中致使 newThr 溢出歸零 |
說完 newCap 和 newThr 的計算過程,接下來再來分析一下鍵值對節點從新映射的過程。
在 JDK 1.8 中,從新映射節點須要考慮節點類型。對於樹形節點,需先拆分成黑樹再映射。對於鏈表類型節點,則需先對鏈表進行分組,而後再映射。須要的注意的是,分組後,組內節點相對位置保持不變。關於紅黑樹拆分的邏輯將會放在下一小節說明,先來看看鏈表是怎樣進行分組映射的。
咱們都知道往底層數據結構中插入節點時,通常都是先經過模運算計算桶位置,接着把節點放入桶中便可。事實上,咱們能夠把從新映射看作插入操做。在 JDK 1.7 中,也確實是這樣作的。但在 JDK 1.8 中,則對這個過程進行了必定的優化,邏輯上要稍微複雜一些。在詳細分析前,咱們先來回顧一下 hash 求餘的過程:
上圖中,桶數組大小 n = 16,hash1 與 hash2 不相等。但由於只有後4位參與求餘,因此結果相等。當桶數組擴容後,n 由16變成了32,對上面的 hash 值從新進行映射:
擴容後,參與模運算的位數由4位變爲了5位。因爲兩個 hash 第5位的值是不同,因此兩個 hash 算出的結果也不同。上面的計算過程並不難理解,繼續往下分析。
假設咱們上圖的桶數組進行擴容,擴容後容量 n = 16,從新映射過程以下:
依次遍歷鏈表,並計算節點 hash & oldCap 的值。以下圖所示
若是值爲0,將 loHead 和 loTail 指向這個節點。若是後面還有節點 hash & oldCap 爲0的話,則將節點鏈入 loHead 指向的鏈表中,並將 loTail 指向該節點。若是值爲非0的話,則讓 hiHead 和 hiTail 指向該節點。完成遍歷後,可能會獲得兩條鏈表,此時就完成了鏈表分組:
最後再將這兩條連接存放到相應的桶中,完成擴容。以下圖:
從上圖能夠發現,從新映射後,兩條鏈表中的節點順序並未發生變化,仍是保持了擴容前的順序。以上就是 JDK 1.8 中 HashMap 擴容的代碼講解。另外再補充一下,JDK 1.8 版本下 HashMap 擴容效率要高於以前版本。若是你們看過 JDK 1.7 的源碼會發現,JDK 1.7 爲了防止因 hash 碰撞引起的拒絕服務攻擊,在計算 hash 過程當中引入隨機種子。以加強 hash 的隨機性,使得鍵值對均勻分佈在桶數組中。在擴容過程當中,相關方法會根據容量判斷是否須要生成新的隨機種子,並從新計算全部節點的 hash。而在 JDK 1.8 中,則經過引入紅黑樹替代了該種方式。從而避免了屢次計算 hash 的操做,提升了擴容效率。
HashMap 的刪除操做並不複雜,僅需三個步驟便可完成。第一步是定位桶位置,第二步遍歷鏈表並找到鍵值相等的節點,第三步刪除節點。相關源碼以下:
//刪除方法 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; // 若是鍵的值與鏈表第一個節點相等,則將 node 指向該節點 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) node = p; else if ((e = p.next) != null) { // 若是是 TreeNode 類型,調用紅黑樹的查找邏輯定位待刪除節點 if (p instanceof TreeNode) node = ((TreeNode<K,V>)p).getTreeNode(hash, key); else { // 2. 遍歷鏈表,找到待刪除節點 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. 刪除節點,並修復鏈表或紅黑樹 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; }
上面的代碼並不複雜,不難理解,這裏就很少說了。
若是你們細心閱讀 HashMap 的源碼,會發現桶數組 table 被申明爲 transient。transient 表示易變的意思,在 Java 中,被該關鍵字修飾的變量不會被默認的序列化機制序列化。咱們再回到源碼中,考慮一個問題:桶數組 table 是 HashMap 底層重要的數據結構,不序列化的話,別人還怎麼還原呢?
這裏簡單說明一下吧,HashMap 並無使用默認的序列化機制,而是經過實現readObject/writeObject兩個方法自定義了序列化的內容。這樣作是有緣由的,試問一句,HashMap 中存儲的內容是什麼?不用說,你們也知道是鍵值對。因此只要咱們把鍵值對序列化了,咱們就能夠根據鍵值對數據重建 HashMap。有的朋友可能會想,序列化 table 不是能夠一步到位,後面直接還原不就好了嗎?這樣一想,倒也是合理。但序列化 talbe 存在着兩個問題:
以上兩個問題中,第一個問題比較好理解,第二個問題解釋一下。HashMap 的get/put/remove等方法第一步就是根據 hash 找到鍵所在的桶位置,但若是鍵沒有覆寫 hashCode 方法,計算 hash 時最終調用 Object 中的 hashCode 方法。但 Object 中的 hashCode 方法是 native 型的,不一樣的 JVM 下,可能會有不一樣的實現,產生的 hash 可能也是不同的。也就是說同一個鍵在不一樣平臺下可能會產生不一樣的 hash,此時再對在同一個 table 繼續操做,就會出現問題。
綜上所述,你們應該能明白 HashMap 不序列化 table 的緣由了
直接閱讀源碼仍是有些難度的,結合這篇博客勉強把源碼讀完了,感受源代碼寫的好厲害(* ̄3 ̄)╭