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底層維護的是數組+連結清單,我們可以通過一小段源碼來看看:
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等多個知識點的架構資料)合理利用自己每一分每一秒的時間來學習提升自己,不要再用"沒有時間“來掩飾自己思想上的懶惰!趁年輕,使勁拼,給未來的自己一個交代!