【必備技能】HashMap在jdk1.7和1.8中的實現
Java集合類的原始碼是深入學習Java非常好的素材,原始碼裡很多優雅的寫法和思路,會讓人歎為觀止。HashMap的原始碼尤為經典,是非常值得去深入研究的,jdk1.8中HashMap發生了比較大的變化,這方面的東西也是各個公司高頻的考點。網上也有很多應對面試的標準答案,我之前也寫過類似的面試技巧( ofollow,noindex">面試必備:Hashtable、HashMap、ConcurrentHashMap的原理與區別 ),應付一般的面試應該是夠了,但個人覺得這還是遠遠不夠,畢竟我們不能只苟且於得到offer,更應去勇敢的追求詩和遠方(原始碼)。
jdk版本目前更新的相對頻繁,好多小夥伴說jdk1.7才剛真正弄明白,1.8就出現了,1.8還用都沒開始用,更高的jdk版本就又釋出了。很多小夥伴大聲疾呼:臣妾真的學不動啦!這也許就是技術的最大魅力吧,活到老學到老,沒有人能說精通所有技術。不管jdk版本如何更新,目前jdk1.7和1.8還是各個公司的主力版本。不管是否學得動,難道各位小夥伴忘記了 《倚天屠龍記》裡九陽真經裡的口訣:他強由他強,清風拂山崗;他橫由他橫,明月照大江。他自狠來他自惡,我自一口真氣足。(原諒我插入廣告緬懷金庸大師,年少時期讀的最多的書就是金庸大師的,遍佈俠骨柔情大義啊)。這裡的“真氣”就是先掌握好jdk1.7和1.8,其它學不動的版本以後再說。
一、初窺HashMap
HashMap是應用更廣泛的 雜湊表 實現,而且大部分情況下,都能在常數時間效能的情況下進行put和get操作。要掌握HashMap,主要從如下幾點來把握:
- jdk1.7中底層是由 陣列(也有叫做“位桶”的)+連結串列 實現;jdk1.8中底層是由 陣列+連結串列/紅黑樹 實現
- 可 以儲存null鍵和null值 ,執行緒 不安全
- 初始size為 16 ,擴容:newsize = oldsize*2,size一定為2的n次冪
- 擴容針對整個Map,每次擴容時,原來陣列中的元素依次重新計算存放位置,並重新插入
- 插入元素後才判斷該不該擴容,有可能無效擴容(插入後如果擴容,如果沒有再次插入,就會產生無效擴容)
- 當Map中元素總數超過Entry陣列的75%,觸發擴容操作,為了減少連結串列長度,元素分配更均勻
為什麼說HashMap是執行緒不安全的?在接近臨界點時,若此時兩個或者多個執行緒進行put操作,都會進行resize(擴容)和reHash(為key重新計算所在位置),而reHash在併發的情況下可能會形成連結串列環。
二、jdk1.7中HashMap的實現
HashMap底層維護的是陣列+連結串列,我們可以通過一小段原始碼來看看:
/** * The default initial capacity - MUST be a power of two. */ 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. */ static final int MAXIMUM_CAPACITY = 1 << 30; /** * The load factor used when none specified in constructor. */ static final float DEFAULT_LOAD_FACTOR = 0.75f; /** * An empty table instance to share when the table is not inflated. */ static final Entry<?,?>[] EMPTY_TABLE = {}; /** * The table, resized as necessary. Length MUST Always be a power of two. */ transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
通過以上程式碼可以看出初始容量(16)、負載因子以及對陣列的說明。 陣列中的每一個元素其實就是Entry<K,V>[] table,Map中的key和value就是以Entry的形式儲存的 。關於Entry<K,V>的具體定義參看如下原始碼:
static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; Entry<K,V> next; int hash; Entry(int h, K k, V v, Entry<K,V> n) { value = v; next = n; key = k; hash = h; } public final K getKey() { return key; } public final V getValue() { return value; } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } public final boolean equals(Object o) { if (!(o instanceof Map.Entry)) return false; Map.Entry e = (Map.Entry)o; Object k1 = getKey(); Object k2 = e.getKey(); if (k1 == k2 || (k1 != null && k1.equals(k2))) { Object v1 = getValue(); Object v2 = e.getValue(); if (v1 == v2 || (v1 != null && v1.equals(v2))) return true; } return false; } public final int hashCode() { return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue()); } public final String toString() { return getKey() + "=" + getValue(); } /** * This method is invoked whenever the value in an entry is * overwritten by an invocation of put(k,v) for a key k that's already * in the HashMap. */ void recordAccess(HashMap<K,V> m) { } /** * This method is invoked whenever the entry is * removed from the table. */ void recordRemoval(HashMap<K,V> m) { } }
當向 HashMap 中 put 一對鍵值時,它會根據 key的 hashCode 值計算出一個位置, 該位置就是此物件準備往陣列中存放的位置。 該計算過程參看如下程式碼:
transient int hashSeed = 0; final int hash(Object k) { int h = hashSeed; if (0 != h && k instanceof String) { return sun.misc.Hashing.stringHash32((String) k); } h ^= k.hashCode(); // This function ensures that hashCodes that differ only by // constant multiples at each bit position have a bounded // number of collisions (approximately 8 at default load factor). h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); } /** * Returns index for hash code h. */ static int indexFor(int h, int length) { // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2"; return h & (length-1); }
通過hash計算出來的值將會使用indexFor方法找到它應該所在的table下標。 當兩個key通過hashCode計算相同時,則發生了hash衝突(碰撞),HashMap解決hash衝突的方式是用 連結串列 。 當發生hash衝突時,則將存放在陣列中的Entry設定為新值的next(這裡要注意的是,比如A和B都hash後都對映到下標i中,之前已經有A了,當map.put(B)時,將B放到下標i中,A則為B的next,所以新值存放在陣列中,舊值在新值的連結串列上)。 即將新值作為此連結串列的頭節點,為什麼要這樣操作?據說後插入的Entry被查詢的可能性更大(因為get查詢的時候會遍歷整個連結串列),此處有待考究,如果有哪位大神知道,請留言告知。
如果該位置沒有物件存在,就將此物件直接放進陣列當中;如果該位置已經有物件存在了,則順著此存在的物件的鏈開始尋找(為了判斷是否是否值相同,map不允許<key,value>鍵值對重複), 如果此鏈上有物件的話,再去使用 equals方法進行比較,如果對此鏈上的每個物件的 equals 方法比較都為 false,則將該物件放到陣列當中,然後將陣列中該位置以前存在的那個物件連結到此物件的後面。
圖中,左邊部分即代表雜湊表,也稱為雜湊陣列(預設陣列大小是16,每對key-value鍵值對其實是存在map的內部類entry裡的),陣列的每個元素都是一個單鏈表的 頭節點 ,跟著的藍色連結串列是用來解決衝突的,如果不同的key對映到了陣列的同一位置處,就將其放入單鏈表中。
前面說過HashMap的key是允許為null的,當出現這種情況時,會放到table[0]中。
private V putForNullKey(V value) { for (Entry<K,V> e = table[0]; e != null; e = e.next) { if (e.key == null) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; addEntry(0, null, value, 0); return null; }
當size>=threshold( threshold等於“容量*負載因子”)時,會發生擴容。
void addEntry(int hash, K key, V value, int bucketIndex) { if ((size >= threshold) && (null != table[bucketIndex])) { resize(2 * table.length); hash = (null != key) ? hash(key) : 0; bucketIndex = indexFor(hash, table.length); } createEntry(hash, key, value, bucketIndex); }
jdk1.7中resize,只有當 size>=threshold並且 table中的那個槽中已經有Entry時,才會發生resize。即有可能雖然size>=threshold,但是必須等到每個槽都至少有一個Entry時,才會擴容,可以通過上面的程式碼看到每次resize都會擴大一倍容量(2 * table.length)。
三、jdk1.8中HashMap的實現
在jdk1.8中HashMap的內部結構可以看作是陣列(Node<K,V>[] table)和連結串列的複合結構,陣列被分為一個個桶(bucket),通過雜湊值決定了鍵值對在這個陣列中的定址(雜湊值相同的鍵值對,則以連結串列形式儲存。有一點需要注意, 如果連結串列大小超過閾值(TREEIFY_THRESHOLD,8),圖中的連結串列就會被改造為樹形(紅黑樹)結構 。
transient Node<K,V>[] table;
Entry的名字變成了Node,原因是和紅黑樹的實現TreeNode相關聯。
在分析jdk1.7中HashMap的hash衝突時,不知大家是否有個疑問就是萬一發生碰撞的節點非常多怎麼版?如果說成百上千個節點在hash時發生碰撞,儲存一個連結串列中,那麼如果要查詢其中一個節點,那就不可避免的花費O(N)的查詢時間,這將是多麼大的效能損失。這個問題終於在JDK1.8中得到了解決,在最壞的情況下, 連結串列查詢的時間複雜度為O(n),而紅黑樹一直是O(logn), 這樣會提高HashMap的效率。
jdk1.7中HashMap採用的是位桶+連結串列的方式,即我們常說的雜湊連結串列的方式,而jdk1.8中採用的是位桶+連結串列/紅黑樹的方式,也是非執行緒安全的。當某個位桶的連結串列的長度達到某個閥值的時候,這個連結串列就將轉換成紅黑樹。
jdk1.8中,當同一個hash值的節點數不小於8時,將不再以單鏈表的形式儲存了,會被調整成一顆紅黑樹(上圖中null節點沒畫)。這就是jdk1.7與jdk1.8中HashMap實現的最大區別。
通過分析put方法的原始碼,可以讓這種區別更直觀:
static final int TREEIFY_THRESHOLD = 8; 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; //如果當前map中無資料,執行resize方法。並且返回n if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //如果要插入的鍵值對要存放的這個位置剛好沒有元素,那麼把他封裝成Node物件,放在這個位置上即可 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); //否則的話,說明這上面有元素 else { Node<K,V> e; K k; //如果這個元素的key與要插入的一樣,那麼就替換一下。 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; //1.如果當前節點是TreeNode型別的資料,執行putTreeVal方法 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { //還是遍歷這條鏈子上的資料,跟jdk7沒什麼區別 for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); //2.完成了操作後多做了一件事情,判斷,並且可能執行treeifyBin方法 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) //true || -- e.value = value; //3. afterNodeAccess(e); return oldValue; } } ++modCount; //判斷閾值,決定是否擴容 if (++size > threshold) resize(); //4. afterNodeInsertion(evict); return null; }
以上程式碼中的特別之處如下:
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash);
treeifyBin()就是將連結串列轉換成紅黑樹。
putVal方法處理的邏輯比較多,包括初始化、擴容、樹化,近乎在這個方法中都能體現,針對原始碼簡單講解下幾個關鍵點:
- 如果Node<K,V>[] table是null,resize方法會負責初始化,即如下程式碼:
if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;
- resize方法兼顧兩個職責,建立初始儲存表格,或者在容量不滿足需求的時候,進行擴容(resize)。
- 在放置新的鍵值對的過程中,如果發生下面條件,就會發生擴容。
if (++size > threshold) resize();
- 具體鍵值對在雜湊表中的位置(陣列index)取決於下面的位運算:
i = (n - 1) & hash
仔細觀察雜湊值的源頭,會發現它並不是key本身的hashCode,而是來自於HashMap內部的另一個hash方法。為什麼這裡需要將高位資料移位到低位進行異或運算呢? 這是因為有些資料計算出的雜湊值差異主要在高位,而HashMap裡的雜湊定址是忽略容量以上的高位的,那麼這種處理就可以有效避免類似情況下的雜湊碰撞。
在jdk1.8中取消了indefFor()方法,直接用(tab.length-1)&hash,所以看到這個,代表的就是陣列的下角標。
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
為什麼HashMap為什麼要樹化?
之前在極客時間的專欄裡看到過一個解釋。 本質上這是個安全問題 。因為在元素放置過程中,如果一個物件雜湊衝突,都被放置到同一個桶裡,則會形成一個連結串列,我們知道連結串列查詢是線性的,會嚴重影響存取的效能。而在現實世界,構造雜湊衝突的資料並不是非常複雜的事情,惡意程式碼就可以利用這些資料大量與伺服器端互動,導致伺服器端CPU大量佔用,這就構成了雜湊碰撞拒絕服務攻擊,國內一線網際網路公司就發生過類似攻擊事件。
四、分析Hashtable、HashMap、TreeMap的區別
- HashMap是繼承自 AbstractMap 類,而HashTable是繼承自 Dictionary 類。不過它們都實現了同時實現了map、Cloneable(可複製)、Serializable(可序列化)這三個介面。儲存的內容是基於key-value的鍵值對對映,不能由重複的key,而且一個key只能對映一個value。
- Hashtable的key、value都不能為null;HashMap的key、value可以為null,不過只能有一個key為null,但可以有多個null的value;TreeMap鍵、值都不能為null。
- Hashtable、HashMap具有無序特性。 TreeMap是利用紅黑樹實現的 (樹中的每個節點的值都會大於或等於它的左子樹中的所有節點的值,並且小於或等於它的右子樹中的所有節點的值),實現了SortMap介面,能夠對儲存的記錄根據鍵進行排序。所以一般需求排序的情況下首選TreeMap, 預設按鍵的升序排序(深度優先搜尋) ,也可以自定義實現Comparator介面實現排序方式。
一般情況下我們選用HashMap,因為HashMap的鍵值對在取出時是隨機的,其依據鍵的hashCode和鍵的equals方法存取資料,具有很快的訪問速度,所以在Map中插入、刪除及索引元素時其是效率最高的實現。而TreeMap的鍵值對在取出時是排過序的,所以效率會低點。
TreeMap是基於紅黑樹的一種提供順序訪問的Map,與HashMap不同的是它的get、put、remove之類操作都是o(log(n))的時間複雜度,具體順序可以由指定的Comparator來決定,或者根據鍵的自然順序來判斷。
對HashMap做下總結:
HashMap基於雜湊散列表實現 ,可以實現對資料的讀寫。將鍵值對傳遞給put方法時,它呼叫鍵物件的hashCode()方法來計算hashCode,然後找到相應的bucket位置(即陣列)來儲存值物件。當獲取物件時,通過鍵物件的equals()方法找到正確的鍵值對,然後返回值物件。HashMap使用連結串列來解決hash衝突問題,當發生衝突了,物件將會儲存在連結串列的頭節點中。HashMap在每個連結串列節點中儲存鍵值對物件,當兩個不同的鍵物件的hashCode相同時,它們會儲存在同一個bucket位置的連結串列中,如果連結串列大小超過閾值(TREEIFY_THRESHOLD,8),連結串列就會被改造為樹形結構。