目錄html
春節拜年取消,在家花了好多天時間啃一啃HashMap的源碼,一樣是找了不少不少的資料,有JDK1.7的,也有JDK1.8的,固然本文基於JDK1.8。將所學到的東西進行整理,但願回過頭再看的時候,有更深入的看法。java
先來看看史詩級長屏之官方介紹
算法
實際上,在JDK1.8中,HashMap底層是依據數組+單鏈表+紅黑樹的結構存儲數據的。具體是怎麼樣的呢?
數組
HashMap實現了Map接口,維護的是一組組鍵值對,以便於咱們根據鍵就能馬上獲取其對應值。另外,HashMap用了特殊的手法,優化了它的性能,咱們本篇來具體學習並總結一下。數據結構
可是,哈希函數並非萬能的,兩個不一樣的元素徹底有可能算出相同的哈希值,這個時候就產生了哈希碰撞。app
但,又有一個問題,要是真的出現了極端的狀況:有大量的元素經過哈希函數求得的值彙集在同一個鏈表上,這時想要找到這個元素,須要花費大量的時間。JDK1.8中,運用了紅黑樹結構,鏈表中的節點數>TREEIFY_THRESHOLD時,鏈表結構將會轉化爲樹形結構,將查找元素的時間複雜度從O(n)降爲O(logn),大大提升了效率。函數
再看看HashMap中定義的一些常量:性能
//序列號 private static final long serialVersionUID = 362498820763181265L; //默認的初始容量爲16(必須爲2的冪) static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 //容許的最大容量2的30次冪 static final int MAXIMUM_CAPACITY = 1 << 30; //沒有指定負載因子時,默認爲0.75f static final float DEFAULT_LOAD_FACTOR = 0.75f; //鏈表轉化爲紅黑樹的閾值 static final int TREEIFY_THRESHOLD = 8; //紅黑樹退化爲鏈表的閾值 static final int UNTREEIFY_THRESHOLD = 6; //數組的容量大於64時,桶纔有可能轉化爲樹形結構 static final int MIN_TREEIFY_CAPACITY = 64;
還有一些成員變量:學習
//存儲的元素的數組,數組容量必定時2的冪次 transient Node<K,V>[] table; //存放具體元素的集 transient Set<Map.Entry<K,V>> entrySet; //存放元素的個數 transient int size; //每次更改結構的計數器 transient int modCount; //閾值,尚未分配數組時,閾值爲默認容量或指定容量,以後該值等於容量*負載因子 int threshold; //負載因子 final float loadFactor;
咱們根據源碼,來看看在JDK1.8中,這些究竟是如何實現的,以及爲何要這樣考慮。
仍是先看看其中三個構造器(暫時先忽略最後一個):優化
//無參構造器 public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } //指定容量的構造器 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); } //傳入映射集的構造器 public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }
這就是HashMap中提供的四個構造器,咱們從中能夠察覺出一些端倪。
tableSizeFor
對咱們傳入的初始容量進行計算,併爲閾值賦值。說到這,咱們來看看這個巧妙的tableSizeFor
,咱們經過註解能夠知道,這個方法返回的是大於等於傳入值的最小2的冪次方(傳入1時,爲1)。它究竟是怎麼實現的呢,咱們來看看具體的源碼:
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; }
說實話,我再看到這個方法具體實現以後,感嘆了一句,數學好牛!我經過代入具體數字,翻閱了許多關於這部分的文章與視頻,經過簡單的例子,來作一下總結。
咱們再傳入更大的數,爲了寫着方便,這裏就以8位爲例:
int n = cap -1
這一步實際上是爲了防止cap自己爲2的冪次的狀況,若是沒有這一步的話,在一頓操做以後,會出現翻倍的狀況。好比傳入爲8,算出來會是16,因此事先減去1,保證結果。n>=MAXIMUM_CAPACITY的狀況的斷定,排除了移位和或運算以後所有爲1的狀況。
講到這裏,我知道了爲何數組的容量老是2的冪次數了:是由於運算規定,可是這基本不算是緣由,選擇2的冪次方數必定有出於便利的方面的緣由,這部分咱們待會再說。
咱們在分析成員變量的時候說過,
threshold
是用來表示一個閾值,表示數組容量和負載因子的乘積。可是咱們發現,還沒分配數組的時候,實際上是咱們不小於指定容量的二次冪。
那麼,數組何時才進行初始化呢?腦瓜子轉一下,應該就知道,是往裏面存元素的時候。咱們來看一看HashMap裏面存儲元素的方法。
//聯繫指定的鍵Key和值Value,若是在這以前map包含相同的key,返回舊key對應的value public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
其中調用了hash方法,對傳入的鍵key進行哈希計算,具體計算細節以下:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
咱們着重瞭解一下,key不爲null的狀況下hash函數的實現,具體爲啥要這樣設計,咱們以後再總結:
有效地將高低位二進制特徵混合,防止由高位的細微區別產生的頻繁哈希碰撞,具體能夠看一下文末的參考連接。
下面是一個及其關鍵的方法putVal。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; //若是數組未初始化或者長度爲0,則調用resize()初始化數組 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //根據hash值計算數組中的桶位,若是爲null,則在該桶位上新建節點 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; //hash值相同,落入同一個桶中,且key相同 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 { //在節點後面插入新節點,桶中鏈表最多有8個節點,再加就變成了樹 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; } //判斷後面節點是否存在key相同的狀況 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; //e=p.next;p=e;這兩步完成遍歷 p = e; } } //若是存在相同key值相同,新值替換舊值 if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; //容量大於閾值,resize(); if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
在沒了解resize方法以前,咱們暫且將他定義成擴容和重哈希的重要方法,咱們先就putVal方法進行一些總結:
p = tab[i = (n - 1) & hash]
,n爲數組的長度,它是2的冪次方,咱們很容易可以明白,經過(n-1)&hash產生的索引值必然落在0~n-1的範圍內,至關於i=hash%n
,可是位運算的效率更高。這就是容量設置爲2的冪次方數的另外緣由。(k = p.key) == key || (key != null && key.equals(k)))
,這一步兩邊分別表示key是否爲null的狀況。TREEIFY_THRESHOLD
爲8,是鏈表結構轉換爲樹形結構的闕值,經過源碼咱們能夠知道,鏈表結構最多隻能存儲8個節點,若是要存第9個,就須要調用treeifyBin(tab, hash);
,轉換爲樹。++size > threshold)
,從這部分咱們能夠看出,除了初始化的時候是先resize再插入,其餘的時候都是先插入,再判斷是否須要擴容。那麼接下來,終於輪到resize方法了,咱們先看一下代碼的實現部分,哇這部分但是花了我好多的功夫,若是還有理解不正確的地方,還但願評論區批評指正:
final Node<K,V>[] resize() { //oldTab存儲的是擴容前的數組 Node<K,V>[] oldTab = table; //oldCap存儲的是擴容前的數組容量 int oldCap = (oldTab == null) ? 0 : oldTab.length; //oldThr存儲的是擴容前的閾值 int oldThr = threshold; //newCap新數組容量,newThr新數組閾值 int newCap, newThr = 0; if (oldCap > 0) { if (oldCap >= MAXIMUM_CAPACITY) { //若是老數組容量比數組最大容量還大,閾值變爲Integer的最大值,返回老數組 threshold = Integer.MAX_VALUE; return oldTab; } //新數組容量變爲老數組容量的兩倍 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) //新閾值變爲兩倍須要上面的條件都成立(一、擴容兩倍以後的數組容量小於最大容量二、老容量大於等於16) newThr = oldThr << 1; // double threshold } else if (oldThr > 0) // initial capacity was placed in threshold //使用帶有初始容量構造器,讓新容量變爲經過initial capacity求得的threshold newCap = oldThr; else { // zero initial threshold signifies using defaults //使用默認構造器,初始化容量爲16 newCap = DEFAULT_INITIAL_CAPACITY; //新容量變爲16,新閾值變爲0.75*16 = 12 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); } //將newThr賦值給threshold表示閾值 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) { //建立臨時節點存儲老數組oldTab上的元素 Node<K,V> e; //若是老數組上索引j的位置不爲null 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循環保證從到到尾遍歷鏈表 } while ((e = next) != null); //若是尾節點不爲空,就讓它的next指向空,鏈表完整 if (loTail != null) { loTail.next = null; //新數組的原索引位置指向鏈表頭節點 newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; //新數組的原索引加老數組容量的索引位置指向鏈表頭節點 newTab[j + oldCap] = hiHead; } } } } } return newTab; }
咱們先談一談數組的初始化部分:
initialCapacity
的時候,threshold一開始表示的是大於等於initialCapacity最小的2的冪次方數,直到第一次添加元素時進行擴容,數組容量爲threshold的值,而threshold此時爲指定負載因子與數組容量的乘積。咱們重點談一談數組的搬移的基礎部分:
newTab[e.hash & (newCap - 1)] = e;
。((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
。最難的是,發生哈希碰撞時,數組的搬移是如何實現的呢?咱們能夠發現,源碼中對e.hash & oldCap
的值是0仍是1進行了分類判斷,爲啥要這樣作呢?
咱們首先必須明確,一樣的哈希值,擴容先後的區別只是在於被截取的那一位,就拿26而言(0001 1010),以16爲容量時,它的有效索引位置爲1010,而以32爲容量時,它的有效索引則是11010,恰好差了10000,即oldCap,以下圖:
e.hash&oldCap
爲0,節點在新數組中的索引不變,newTab[j]。e.hash&oldCap
爲1,節點在新數組中的索引值 = 老數組容量+原索引值,newTab[j + oldCap]。瞭解完這個,咱們對其中哈希碰撞時節點搬移的代碼的分析開始!
關於其中針對e.hash & oldCap
不一樣而定義的一對做用相同的節點,咱們暫且將他們單獨拎出來,研究loHead和loTail,另一對其實同理便可。
//do……while循環 do{ next = e.next; }while((e = next)!=null);
loTail.next = null;
newTab[j] = loHead;
最後的最後,本文還有許多方面須要完善或者修改,以後會陸續將新體會上傳,還望評論區批評指正。
參考:
HashMap中的hash算法中的幾個疑問
HashMap中的hash函數
jdk1.8 HashMap工做原理和擴容機制(源碼解析)
Java 1.8中HashMap的resize()方法擴容部分的理解