1. 程式人生 > >HashMap在jdk1.7和1.8中的實現

HashMap在jdk1.7和1.8中的實現

Java集合類的原始碼是深入學習Java非常好的素材,原始碼裡很多優雅的寫法和思路,會讓人歎為觀止。HashMap的原始碼尤為經典,是非常值得去深入研究的,jdk1.8中HashMap發生了比較大的變化,這方面的東西也是各個公司高頻的考點。網上也有很多應對面試的標準答案,我之前也寫過類似的面試技巧(面試必備: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等於“容量*負載因子”)時,會發生擴容。

id 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),連結串列就會被改造為樹形結構。
歡迎工作一到五年的Java工程師朋友們加入Java架構開發: 854393687
群內提供免費的Java架構學習資料(裡面有高可用、高併發、高效能及分散式、Jvm效能調優、Spring原始碼,MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多個知識點的架構資料)合理利用自己每一分每一秒的時間來學習提升自己,不要再用"沒有時間“來掩飾自己思想上的懶惰!趁年輕,使勁拼,給未來的自己一個交代!