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,其它學不動的版本之後再說。sql

1、初窺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在併發的狀況下可能會造成鏈表環。安全

2、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;

/**app

  • 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)。

3、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; } 以上代碼中的特別之處以下: 1 2 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大量佔用,這就構成了哈希碰撞拒絕服務攻擊,國內一線互聯網公司就發生過相似攻擊事件。

4、分析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等多個知識點的架構資料)合理利用本身每一分每一秒的時間來學習提高本身,不要再用"沒有時間「來掩飾本身思想上的懶惰!趁年輕,使勁拼,給將來的本身一個交代!

相關文章
相關標籤/搜索