HashMap源碼深度解析

前一篇博客Java中HashTable和HashMap已經基本介紹了HashMap的存儲結構。

jdk1.8之前是數組+鏈表的形式,後面會介紹jdk1.8對hashMap的改動:數組+鏈表+紅黑樹

我們先從HashMap結構源代碼:

transient Entry[] table; static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; Entry<K,V> next; final int hash; …… }

(註:在jdk1.8中使用的是Node<V,K>)

可以看出,Entry就是數組中的元素,每個 Map.Entry 其實就是一個key-value對,它持有一個指向下一個元素的引用,這就構成了鏈表。

HashMap的初始化:

HashMap有兩個參數影響其性能:初始容量載入因子。默認初始容量是16,載入因子是0.75。容量是哈希表中桶(Entry數組)的數量,初始容量只是哈希表在創建時的容量。載入因子是哈希表在其容量自動增加之前可以達到多滿的一種尺度。當哈希表中的條目數超出了載入因子與當前容量的乘積時,通過調用 rehash 方法將容量翻倍。

HashMap中定義的成員變數如下:

/** * The default initial capacity - MUST be a power of two. */ static final int DEFAULT_INITIAL_CAPACITY = 16;// 默認初始容量為16,必須為2的冪 /** * 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. */ static final int MAXIMUM_CAPACITY = 1 << 30;// 最大容量為2的30次方 /** * The load factor used when none specified in constructor. */ static final float DEFAULT_LOAD_FACTOR = 0.75f;// 默認載入因子0.75 /** * The table, resized as necessary. Length MUST Always be a power of two. */ transient Entry<K,V>[] table;// Entry數組,哈希表,長度必須為2的冪 /** * The number of key-value mappings contained in this map. */ transient int size;// 已存元素的個數 /** * The next size value at which to resize (capacity * load factor). * @serial */ int threshold;// 下次擴容的臨界值,size>=threshold就會擴容 /** * The load factor for the hash table. * * @serial */ final float loadFactor;// 載入因子

從源代碼的注釋中可以大概理解這些基本參數。

初始默認容量:DEFAULT_INITIAL_CAPACITY = 16;

最大容量:int MAXIMUM_CAPACITY = 1 << 30;// 最大容量為2的30次方

載入因子默認為0.75: DEFAULT_LOAD_FACTOR = 0.75f;// 默認載入因子0.75

為什麼使用transient關鍵字?

transient Entry<K,V>[] table;我們可以看到這裡使用了transient關鍵字,表示Entry不能被序列化。

transient是Java語言的關鍵字,用來表示一個域不是該對象串列化的一部分。

當一個對象被串列化的時候,transient型變數的值不包括在串列化的表示中,也就是說沒法持久化。

因為讀寫Map是根據Object.hashcode()來確定從table[i]讀/寫

而Object.hashcode()是native方法, 不同的JVM里可能是不一樣的

比如向HashMap存一個鍵值對entry, key為字元串"guowuxin", 在第一個java程序里, "guowuxin"的hashcode()為1, 存入table【1】

在另一個JVM程序里, "guowuxin" 的hashcode()有可能就是2, 存入table【2】

如果用默認的串列化(Entry[] table不用transient), 那麼這個HashMap從第一個java程序里通過串列化導入第二個JVM環境之後, 其內存分布是一樣的. 這就不對了.

HashMap現在的readObject和writeObject是把內容 輸出/輸入, 把HashMap重新生成出來.

所以HashMap自己實現了readObject和writeObject

另外因為 HashMap 中的存儲數據的數組數據成員中,數組還有很多的空間沒有被使用,沒有被使用到的空間被序列化沒有意義。所以需要手動使用 writeObject() 方法,只序列化實際存儲元素的數組。

構造函數

這裡注意一句話:MUST be a power of two.,翻譯過來就是大小必須是2的冥,至於是為什麼,下面將會解釋到。

HashMap常用的構造方法一般有四種:

我們來看一下public HashMap(int initialCapacity, float loadFactor)的源碼,其他的構造方法都是通過調用它來實現的。

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); // Find a power of 2 >= initialCapacity // 這裡需要注意一下 int capacity = 1; while (capacity < initialCapacity) capacity <<= 1; // 設置載入因子 this.loadFactor = loadFactor; // 設置下次擴容臨界值 threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1); // 初始化哈希表 table = new Entry[capacity]; useAltHashing = sun.misc.VM.isBooted() && (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD); init(); }

從源代碼中我們可以看到基本流程。

int capacity = 1;

while (capacity < initialCapacity)

capacity <<= 1;

這兩行代碼,保證了大小一定是2的冥,如果我們設置初始大小為5,那麼得到的Entry大小為8。

那麼這樣做的目的到底是為什麼?

put操作:

我們來看看下面介紹的put操作:

從上到下依次更近源碼

public V put(K key, V value) { // HashMap允許存放null鍵和null值。 // 當key為null時,調用putForNullKey方法,將value放置在數組第一個位置。 if (key == null) return putForNullKey(value); // 根據key的keyCode重新計算hash值。 int hash = hash(key.hashCode()); // 搜索指定hash值在對應table中的索引。 int i = indexFor(hash, table.length); // 如果 i 索引處的 Entry 不為 null,通過循環不斷遍歷 e 元素的下一個元素。 for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } // 如果i索引處的Entry為null,表明此處還沒有Entry。 modCount++; // 將key、value添加到i索引處。 addEntry(hash, key, value, i); return null; }

這裡留意一下modount++這句話。後面將會解釋這句話的用處

當我們往HashMap中put元素的時候,如果key為null的話,hash值為0,對象存儲在數組中索引為0的位置。即table[0]。

1 private V putForNullKey(V value) { 2 for (Entry<K,V> e = table[0]; e != null; e = e.next) { 3 if (e.key == null) { //如果有key為null的對象存在,則覆蓋掉 4 V oldValue = e.value; 5 e.value = value; 6 e.recordAccess(this); 7 return oldValue; 8 } 9 }10 modCount++;11 addEntry(0, null, value, 0); //如果鍵為null的話,則hash值為012 return null;13 }

然後根據key的hashCode重新計算hash值,根據hash值得到這個元素在數組中的位置(即下標)。這裡調用了indexFor(hash, table.length)函數

static int hash(int h) { h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); } static int indexFor(int h, int length) { return h & (length-1); }

這裡使用的是一個位運算。

這裡h是通過K的hashCode最終計算出來的哈希值,並不是hashCode本身,而是在hashCode之上又經過一層運算的hash值,length是目前容量。

這塊的處理很有玄機,與容量一定為2的冪環環相扣,當容量一定是2^n時,h & (length - 1) == h % length,它倆是等價不等效的,位運算效率非常高,實際開發中,很多的數值運算以及邏輯判斷都可以轉換成位運算,但是位運算通常是難以理解的,因為其本身就是給電腦運算的,運算的是二進位,而不是給人類運算的,人類運算的是十進位,這也是位運算在普遍的開發者中間不太流行的原因(門檻太高)。

這個等式實際上可以推理出來,2^n轉換成二進位就是1+n個0,減1之後就是0+n個1,如16 -> 10000,15 -> 01111,那根據&位運算的規則,都為1(真)時,才為1,那0≤運算後的結果≤15,假設h <= 15,那麼運算後的結果就是h本身,h >15,運算後的結果就是最後三位二進位做&運算後的值,最終,就是%運算後的餘數

下面來解釋一下為什麼必須是2的冥

假設數組長度分別為15和16,優化後的hash碼分別為8和9,那麼&運算後的結果如下:

h & (table.length-1) hash table.length-1

8 & (15-1): 0100 & 1110 = 0100

9 & (15-1): 0101 & 1110 = 0100

-----------------------------------------------------------------------------------------------------------------------

8 & (16-1): 0100 & 1111 = 0100

9 & (16-1): 0101 & 1111 = 0101

從上面的例子中可以看出:當它們和15-1(1110)「與」的時候,產生了相同的結果,也就是說它們會定位到數組中的同一個位置上去,這就產生了碰撞,8和9會被放到數組中的同一個位置上形成鏈表,那麼查詢的時候就需要遍歷這個鏈 表,得到8或者9,這樣就降低了查詢的效率。同時,我們也可以發現,當數組長度為15的時候,hash值會與15-1(1110)進行「與」,那麼 最後一位永遠是0,而0001,0011,0101,1001,1011,0111,1101這幾個位置永遠都不能存放元素了,空間浪費相當大,更糟的是這種情況中,數組可以使用的位置比數組長度小了很多,這意味著進一步增加了碰撞的幾率,減慢了查詢的效率!而當數組長度為16時,即為2的n次方時,2n-1得到的二進位數的每個位上的值都為1,這使得在低位上&時,得到的和原hash的低位相同,加之hash(int h)方法對key的hashCode的進一步優化,加入了高位計算,就使得只有相同的hash值的兩個值才會被放到數組中的同一個位置上形成鏈表。

接下來進行存儲

言歸正傳,根據hash值得到這個元素在數組中的位置(即下標),如果數組該位置上已經存放有其他元素了,那麼在這個位置上的元素將以鏈表的形式存放,新加入的放在鏈頭,最先加入的放在鏈尾。如果數組該位置上沒有元素,就直接將該元素放到此數組中的該位置上。

addEntry(hash, key, value, i)方法根據計算出的hash值,將key-value對放在數組table的i索引處。addEntry 是HashMap 提供的一個包訪問許可權的方法,代碼如下:

void addEntry(int hash, K key, V value, int bucketIndex) { // 獲取指定 bucketIndex 索引處的 Entry Entry<K,V> e = table[bucketIndex]; // 將新創建的 Entry 放入 bucketIndex 索引處,並讓新的 Entry 指向原來的 Entry table[bucketIndex] = new Entry<K,V>(hash, key, value, e); // 如果 Map 中的 key-value 對的數量超過了極限 if (size++ >= threshold) // 把 table 對象的長度擴充到原來的2倍。 resize(2 * table.length); }

當系統決定存儲HashMap中的key-value對時,完全沒有考慮Entry中的value,僅僅只是根據key來計算並決定每個Entry的存儲位置。我們完全可以把 Map 集合中的 value 當成 key 的附屬,當系統決定了 key 的存儲位置之後,value 隨之保存在那裡即可。

讀取操作:

public V get(Object key) { if (key == null) return getForNullKey(); int hash = hash(key.hashCode()); for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) return e.value; } return null; }

有了上面put的源碼跟進介紹,get操作的源碼理解起來就比較方便了。

從HashMap中get元素時,首先計算key的hashCode,找到數組中對應位置的某一元素,然後通過key的equals方法在對應位置的鏈表中找到需要的元素。

歸納起來簡單地說,HashMap 在底層將 key-value 當成一個整體進行處理,這個整體就是一個 Entry 對象。HashMap 底層採用一個 Entry[] 數組來保存所有的 key-value 對,當需要存儲一個 Entry 對象時,會根據hash演算法來決定其在數組中的存儲位置,在根據equals方法決定其在該數組位置上的鏈表中的存儲位置;當需要取出一個Entry時,也會根據hash演算法找到其在數組中的存儲位置,再根據equals方法從該位置上的鏈表中取出該Entry。

HashMap的resize(rehash):

當HashMap中的元素越來越多的時候,hash衝突的幾率也就越來越高,因為數組的長度是固定的。所以為了提高查詢的效率,就要對HashMap的數組進行擴容,數組擴容這個操作也會出現在ArrayList中,這是一個常用的操作,而在HashMap數組擴容之後,最消耗性能的點就出現了:原數組中的數據必須重新計算其在新數組中的位置,並放進去,這就是resize。

那麼HashMap什麼時候進行擴容呢?當HashMap中的元素個數超過數組大小*loadFactor時,就會進行數組擴容,loadFactor的默認值為0.75,這是一個折中的取值。也就是說,默認情況下,數組大小為16,那麼當HashMap中元素個數超過16*0.75=12的時候,就把數組的大小擴展為 2*16=32,即擴大一倍,然後重新計算每個元素在數組中的位置,而這是一個非常消耗性能的操作,所以如果我們已經預知HashMap中元素的個數,那麼預設元素的個數能夠有效的提高HashMap的性能。

為什麼載入因子是0.75

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

如果很多映射關係要存儲在 HashMap 實例中,則相對於按需執行自動的 rehash 操作以增大表的容量來說,使用足夠大的初始容量創建它將使得映射關係能更有效地存儲。 當HashMap存放的元素越來越多,到達臨界值(閥值)threshold時,就要對Entry數組擴容,這是Java集合類框架最大的魅力,HashMap在擴容時,新數組的容量將是原來的2倍,由於容量發生變化,原有的每個元素需要重新計算bucketIndex,再存放到新數組中去,也就是所謂的rehash。HashMap默認初始容量16,載入因子0.75,也就是說最多能放16*0.75=12個元素,當put第13個時,HashMap將發生rehash,rehash的一系列處理比較影響性能,所以當我們需要向HashMap存放較多元素時,最好指定合適的初始容量和載入因子,否則HashMap默認只能存12個元素,將會發生多次rehash操作。

Fail-Fast機制:

我們知道java.util.HashMap不是線程安全的,因此如果在使用迭代器的過程中有其他線程修改了map,那麼將拋出ConcurrentModificationException,這就是所謂fail-fast策略。

這一策略在源碼中的實現是通過modCount域,modCount顧名思義就是修改次數,對HashMap內容的修改都將增加這個值,那麼在迭代器初始化過程中會將這個值賦給迭代器的expectedModCount。

HashIterator() { expectedModCount = modCount; if (size > 0) { // advance to first entry Entry[] t = table; while (index < t.length && (next = t[index++]) == null) ; } }

在迭代過程中,判斷modCount跟expectedModCount是否相等,如果不相等就表示已經有其他線程修改了Map:

注意到modCount聲明為volatile,保證線程之間修改的可見性。

final Entry<K,V> nextEntry() { if (modCount != expectedModCount) throw new ConcurrentModificationException();

fail-fast機制,是一種錯誤檢測機制。它只能被用來檢測錯誤,因為JDK並不保證fail-fast機制一定會發生。若在多線程環境下使用fail-fast機制的集合,建議使用「java.util.concurrent包下的類」去取代「java.util包下的類」。

迭代器的快速失敗行為不能得到保證,一般來說,存在非同步的並發修改時,不可能作出任何堅決的保證。快速失敗迭代器盡最大努力拋出 ConcurrentModificationException。因此,編寫依賴於此異常的程序的做法是錯誤的,正確做法是:迭代器的快速失敗行為應該僅用於檢測程序錯誤。

推薦閱讀:

浙江大學-數據結構-選講Complete Binary Search Tree-7.3.2
Leetcodes Solution 38 Count and Say
碼圖並茂紅黑樹
浙江大學-數據結構-堆排序-9.3.2
浙江大學-數據結構-小白專場:最小路徑問題-7.1.3

TAG:哈希函數 | 數據結構 |