轉載:https://tech.meituan.com/java-hashmap.html
Java為數(shù)據(jù)結構中的映射定義了一個接口java.util.Map守伸,此接口主要有四個常用的實現(xiàn)類施绎,分別是HashMap、Hashtable澎灸、LinkedHashMap和TreeMap,類繼承關系如下圖所示:
簡介
HashMap:
它根據(jù)鍵的hashCode值存儲數(shù)據(jù)爽撒,大多數(shù)情況下可以直接定位到它的值蕊唐,因而具有很快的訪問速度,但遍歷順序卻是不確定的简十。
HashMap最多只允許一條記錄的鍵為null檬某,允許多條記錄的值為null。
HashMap非線程安全螟蝙,即任一時刻可以有多個線程同時寫HashMap恢恼,可能會導致數(shù)據(jù)的不一致。如果需要滿足線程安全胰默,可以用 Collections的synchronizedMap方法使HashMap具有線程安全的能力场斑,或者使用ConcurrentHashMap漓踢。
Hashtable:
Hashtable是遺留類,很多映射的常用功能與HashMap類似漏隐,不同的是它承自Dictionary類喧半,并且是線程安全的,任一時間只有一個線程能寫Hashtable青责,并發(fā)性不如ConcurrentHashMap挺据,因為ConcurrentHashMap引入了分段鎖。Hashtable不建議在新代碼中使用脖隶,不需要線程安全的場合可以用HashMap替換扁耐,需要線程安全的場合可以用ConcurrentHashMap替換。
LinkedHashMap:
LinkedHashMap是HashMap的一個子類产阱,保存了記錄的插入順序做葵,在用Iterator遍歷LinkedHashMap時,先得到的記錄肯定是先插入的心墅,也可以在構造時帶參數(shù)酿矢,按照訪問次序排序。
LinkedHashMap和HashMap一樣也是非線程安全的怎燥。
TreeMap:
TreeMap實現(xiàn)SortedMap接口瘫筐,能夠把它保存的記錄根據(jù)鍵排序,默認是按鍵值的升序排序铐姚,也可以指定排序的比較器策肝,當用Iterator遍歷TreeMap時,得到的記錄是排過序的隐绵。如果使用排序的映射之众,建議使用TreeMap。在使用TreeMap時依许,key必須實現(xiàn)Comparable接口或者在構造TreeMap傳入自定義的Comparator棺禾,否則會在運行時拋出java.lang.ClassCastException類型的異常。
TreeMap非線程安全峭跳。
對于上述四種Map類型的類膘婶,要求映射中的key是不可變對象。不可變對象是該對象在創(chuàng)建后它的哈希值不會被改變蛀醉。如果對象的哈希值發(fā)生變化悬襟,Map對象很可能就定位不到映射的位置了。
通過上面的比較拯刁,我們知道了HashMap是Java的Map家族中一個普通成員脊岳,鑒于它可以滿足大多數(shù)場景的使用條件,所以是使用頻度最高的一個。下文我們主要結合源碼割捅,從存儲結構奶躯、常用方法分析、擴容以及安全性等方面深入講解HashMap的工作原理棺牧。
HashMap的內部實現(xiàn)
從結構實現(xiàn)來講巫糙,HashMap是數(shù)組+鏈表+紅黑樹(JDK1.8增加了紅黑樹部分)實現(xiàn)的朗儒,如下如所示:
- HashMap內部維護著一個非常重要的字段——table數(shù)組:
/**
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
* (We also tolerate length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
transient Node<K,V>[] table;
明顯它是一個Node的數(shù)組颊乘。
我們來看Node[JDK1.8]是何物。
/**
* Basic hash bin node, used for most entries. (See below for
* TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
*/
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
Node是HashMap的一個內部類醉锄,實現(xiàn)了Map.Entry接口乏悄,本質是就是一個映射(鍵值對)。上圖中的每個黑色圓點就是一個Node對象恳不。
- HashMap就是使用哈希表來存儲的
哈希表為解決沖突檩小,可以采用開放地址法和鏈地址法等來解決問題煤率,Java中HashMap采用了鏈地址法壳嚎。鏈地址法,簡單來說佑女,就是數(shù)組加鏈表的結合卵惦。在每個數(shù)組元素上都一個鏈表結構阻肿,當數(shù)據(jù)被Hash后,得到數(shù)組下標沮尿,把數(shù)據(jù)放在對應下標元素的鏈表上丛塌。例如程序執(zhí)行下面代碼:
map.put("美團","小美");
系統(tǒng)將調用"美團"這個key的hashCode()方法得到其hashCode 值(該方法適用于每個Java對象),然后再通過Hash算法的后兩步運算(高位運算和取模運算畜疾,下文有介紹)來定位該鍵值對的存儲位置赴邻,有時兩個key會定位到相同的位置,表示發(fā)生了Hash碰撞啡捶。當然Hash算法計算結果越分散均勻姥敛,Hash碰撞的概率就越小,map的存取效率就會越高瞎暑。
如果哈希桶數(shù)組很大徒溪,即使較差的Hash算法也會比較分散,如果哈希桶數(shù)組數(shù)組很小金顿,即使好的Hash算法也會出現(xiàn)較多碰撞臊泌,所以就需要在空間成本和時間成本之間權衡,其實就是在根據(jù)實際情況確定哈希桶數(shù)組的大小揍拆,并在此基礎上設計好的hash算法減少Hash碰撞渠概。那么通過什么方式來控制map使得Hash碰撞的概率又小,哈希桶數(shù)組(Node[] table)占用空間又少呢?答案就是好的Hash算法和擴容機制播揪。
在理解Hash和擴容流程之前贮喧,我們得先了解下HashMap的幾個字段。從HashMap的默認構造函數(shù)源碼可知猪狈,構造函數(shù)就是對下面幾個字段進行初始化箱沦,源碼如下:
/**
* The number of key-value mappings contained in this map.
*/
transient int size;
/**
* The number of times this HashMap has been structurally modified
* Structural modifications are those that change the number of mappings in
* the HashMap or otherwise modify its internal structure (e.g.,
* rehash). This field is used to make iterators on Collection-views of
* the HashMap fail-fast. (See ConcurrentModificationException).
*/
transient int modCount;
/**
* The next size value at which to resize (capacity * load factor).
*
* @serial
*/
// (The javadoc description is true upon serialization.
// Additionally, if the table array has not been allocated, this
// field holds the initial array capacity, or zero signifying
// DEFAULT_INITIAL_CAPACITY.)
int threshold; //capacity * load factor(容量×負載因子),當table數(shù)組被占用的個數(shù)超過該值是時雇庙,將調用resize函數(shù)
/**
* The load factor for the hash table.
*
* @serial
*/
final float loadFactor; //默認為0.75
首先谓形,Node[] table的初始化長度length(默認值是16),Load factor為負載因子(默認值是0.75)疆前,threshold是HashMap所能容納的最大數(shù)據(jù)量的Node(鍵值對)個數(shù)寒跳。threshold = length * Load factor。也就是說竹椒,在數(shù)組定義好長度之后童太,負載因子越大,所能容納的鍵值對個數(shù)越多胸完。
結合負載因子的定義公式可知书释,threshold就是在此Load factor和length(數(shù)組長度)對應下允許的最大元素數(shù)目,超過這個數(shù)目就重新resize(擴容)赊窥,擴容后的HashMap容量是之前容量的兩倍爆惧。默認的負載因子0.75是對空間和時間效率的一個平衡選擇,建議大家不要修改誓琼,除非在時間和空間比較特殊的情況下检激,如果內存空間很多而又對時間效率要求很高,可以降低負載因子Load factor的值腹侣;相反叔收,如果內存空間緊張而對時間效率要求不高,可以增加負載因子loadFactor的值傲隶,這個值可以大于1饺律。
size這個字段其實很好理解,就是HashMap中實際存在的鍵值對數(shù)量跺株。注意和table的長度length复濒、容納最大鍵值對數(shù)量threshold的區(qū)別。而modCount字段主要用來記錄HashMap內部結構發(fā)生變化的次數(shù)乒省,主要用于迭代的快速失敗巧颈。強調一點,內部結構發(fā)生變化指的是結構發(fā)生變化袖扛,例如put新鍵值對砸泛,但是某個key對應的value值被覆蓋不屬于結構變化十籍。
在HashMap中,哈希桶數(shù)組table的長度length大小必須為2的n次方(一定是合數(shù))唇礁,這是一種非常規(guī)的設計勾栗,常規(guī)的設計是把桶的大小設計為素數(shù)。相對來說素數(shù)導致沖突的概率要小于合數(shù)盏筐,具體證明可以參考http://blog.csdn.net/liuqiyao_01/article/details/14475159围俘,
Hashtable初始化桶大小為11,就是桶大小設計為素數(shù)的應用(Hashtable擴容后不能保證還是素數(shù))琢融。HashMap采用這種非常規(guī)設計界牡,主要是為了在取模和擴容時做優(yōu)化,同時為了減少沖突吏奸,HashMap定位哈希桶索引位置時欢揖,也加入了高位參與運算的過程陶耍。
這里存在一個問題奋蔚,即使負載因子和Hash算法設計的再合理,也免不了會出現(xiàn)拉鏈過長的情況烈钞,一旦出現(xiàn)拉鏈過長泊碑,則會嚴重影響HashMap的性能。于是毯欣,在JDK1.8版本中馒过,對數(shù)據(jù)結構做了進一步的優(yōu)化,引入了紅黑樹酗钞。而當鏈表長度太長(默認超過8)時腹忽,鏈表就轉換為紅黑樹,利用紅黑樹快速增刪改查的特點提高HashMap的性能砚作,其中會用到紅黑樹的插入窘奏、刪除、查找等算法葫录。本文不再對紅黑樹展開討論着裹,想了解更多紅黑樹數(shù)據(jù)結構的工作原理可以參考http://blog.csdn.net/v_july_v/article/details/6105630。
功能實現(xiàn)-方法
HashMap的內部功能實現(xiàn)很多米同,本文主要從根據(jù)key獲取哈希桶數(shù)組索引位置骇扇、put方法的詳細執(zhí)行、擴容過程三個具有代表性的點深入展開講解面粮。
確定哈希桶數(shù)組索引位置
不管增加少孝、刪除、查找鍵值對熬苍,定位到哈希桶數(shù)組的位置都是很關鍵的第一步稍走。前面說過HashMap的數(shù)據(jù)結構是數(shù)組和鏈表的結合,所以我們當然希望這個HashMap里面的元素位置盡量分布均勻些,盡量使得每個位置上的元素數(shù)量只有一個钱磅,那么當我們用hash算法求得這個位置的時候梦裂,馬上就可以知道對應位置的元素就是我們要的,不用遍歷鏈表盖淡,大大優(yōu)化了查詢的效率年柠。HashMap定位數(shù)組索引位置,直接決定了hash方法的離散性能褪迟。先看看源碼的實現(xiàn)(方法一+方法二):
方法一:
static final int hash(Object key) { //jdk1.8 & jdk1.7
int h;
// h = key.hashCode() 為第一步 取hashCode值
// h ^ (h >>> 16) 為第二步 高位參與運算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
方法二:
static int indexFor(int h, int length) { //jdk1.7的源碼冗恨,jdk1.8沒有這個方法,但是實現(xiàn)原理一樣的
return h & (length-1); //第三步 取模運算
}
這里的Hash算法本質上就是三步:取key的hashCode值味赃、高位運算掀抹、取模運算。
對于任意給定的對象心俗,只要它的hashCode()返回值相同傲武,那么程序調用方法一所計算得到的Hash碼值總是相同的。
我們首先想到的就是把hash值對數(shù)組長度取模運算城榛,這樣一來揪利,元素的分布相對來說是比較均勻的。但是狠持,模運算的消耗還是比較大的疟位,在HashMap中是這樣做的:調用方法二來計算該對象應該保存在table數(shù)組的哪個索引處。
這個方法非常巧妙喘垂,它通過h & (table.length -1)來得到該對象的保存位甜刻,而HashMap底層數(shù)組的長度總是2的n次方,這是HashMap在速度上的優(yōu)化正勒。當length總是2的n次方時得院,h& (length-1)運算等價于對length取模,也就是h%length昭齐,但是 & 比 % 具有更高的效率尿招。
在JDK1.8的實現(xiàn)中,優(yōu)化了高位運算的算法阱驾,通過hashCode()的高16位異或低16位實現(xiàn)的:(h = k.hashCode()) ^ (h >>> 16)就谜,主要是從速度、功效里覆、質量來考慮的丧荐,這么做可以在數(shù)組table的length比較小的時候,也能保證考慮到高低Bit都參與到Hash的計算中喧枷,同時不會有太大的開銷虹统。
下面舉例說明下弓坞,n為table的長度:
分析HashMap的put方法
HashMap的put方法執(zhí)行過程可以通過下圖來理解,自己有興趣可以去對比源碼更清楚地研究學習车荔。
①.判斷鍵值對數(shù)組table[i]是否為空或為null渡冻,否則執(zhí)行resize()進行擴容;
②.根據(jù)鍵值key計算hash值得到插入的數(shù)組索引i忧便,如果table[i]==null族吻,直接新建節(jié)點添加,轉向⑥珠增,如果table[i]不為空超歌,轉向③;
③.判斷table[i]的首個元素是否和key一樣蒂教,如果相同直接覆蓋value巍举,否則轉向④,這里的相同指的是hashCode以及equals凝垛;
④.判斷table[i] 是否為treeNode懊悯,即table[i] 是否是紅黑樹,如果是紅黑樹苔严,則直接在樹中插入鍵值對定枷,否則轉向⑤孤澎;
⑤.遍歷table[i]届氢,判斷鏈表長度是否大于8,大于8的話把鏈表轉換為紅黑樹覆旭,在紅黑樹中執(zhí)行插入操作退子,否則進行鏈表的插入操作;遍歷過程中若發(fā)現(xiàn)key已經存在直接覆蓋value即可型将;
⑥.插入成功后寂祥,判斷實際存在的鍵值對數(shù)量size是否超多了最大容量threshold,如果超過七兜,進行擴容丸凭。
map對象調用put成員函數(shù),put函數(shù)的實現(xiàn)如下:
/**
* Associates the specified value with the specified key in this map.
* If the map previously contained a mapping for the key, the old
* value is replaced.
*
* @param key key with which the specified value is to be associated
* @param value value to be associated with the specified key
* @return the previous value associated with <tt>key</tt>, or
* <tt>null</tt> if there was no mapping for <tt>key</tt>.
* (A <tt>null</tt> return can also indicate that the map
* previously associated <tt>null</tt> with <tt>key</tt>.)
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
根據(jù)源碼得知:put函數(shù)調用了putVal函數(shù)腕铸,并返回了putVal函數(shù)的返回值惜犀,而putVal函數(shù)的具體實現(xiàn)如下:
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 步驟①:tab為空則創(chuàng)建
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 步驟②:計算index,并對null做處理
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 步驟③:節(jié)點key存在狠裹,直接覆蓋value
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 {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key,value,null);
//鏈表長度大于8轉換為紅黑樹進行處理
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// key已經存在直接覆蓋value
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)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 步驟⑥:超過最大容量 就擴容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
擴容機制
擴容(resize)就是重新計算容量虽界,向HashMap對象里不停的添加元素,而HashMap對象內部的數(shù)組無法裝載更多的元素時涛菠,對象就需要擴大數(shù)組的長度莉御,以便能裝入更多的元素撇吞。當然Java里的數(shù)組是無法自動擴容的,方法是使用一個新的數(shù)組代替已有的容量小的數(shù)組礁叔,就像我們用一個小桶裝水牍颈,如果想裝更多的水,就得換大水桶琅关。
我們分析下resize的源碼颂砸,鑒于JDK1.8融入了紅黑樹,較復雜死姚,為了便于理解我們仍然使用JDK1.7的代碼人乓,好理解一些,本質上區(qū)別不大都毒,具體區(qū)別后文再說色罚。
JDK1.7中HashMap的resize()源碼:
void resize(int newCapacity) { //傳入新的容量
Entry[] oldTable = table; //引用擴容前的Entry數(shù)組
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) { //擴容前的數(shù)組大小如果已經達到最大(2^30)了
threshold = Integer.MAX_VALUE; //修改閾值為int的最大值(2^31-1),這樣以后就不會擴容了
return;
}
Entry[] newTable = new Entry[newCapacity]; //初始化一個新的Entry數(shù)組
transfer(newTable); //U司ⅰ戳护!將數(shù)據(jù)轉移到新的Entry數(shù)組里
table = newTable; //HashMap的table屬性引用新的Entry數(shù)組
threshold = (int)(newCapacity * loadFactor);//修改閾值
}
這里就是使用一個容量更大的數(shù)組來代替已有的容量小的數(shù)組,transfer()方法將原有Entry數(shù)組的元素拷貝到新的Entry數(shù)組里瀑焦。
void transfer(Entry[] newTable) {
Entry[] src = table; //src引用了舊的Entry數(shù)組
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) { //遍歷舊的Entry數(shù)組
Entry<K,V> e = src[j]; //取得舊Entry數(shù)組的每個元素
if (e != null) {
src[j] = null;//釋放舊Entry數(shù)組的對象引用(for循環(huán)后腌且,舊的Entry數(shù)組不再引用任何對象)
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity); //!榛瓮!重新計算每個元素在數(shù)組中的位置
e.next = newTable[i]; //標記[1]
newTable[i] = e; //將元素放在數(shù)組上
e = next; //訪問下一個Entry鏈上的元素
} while (e != null);
}
}
}
newTable[i]的引用賦給了e.next铺董,也就是使用了單鏈表的頭插入方式,同一位置上新元素總會被放在鏈表的頭部位置禀晓;這樣先放在一個索引上的元素終會被放到Entry鏈的尾部(如果發(fā)生了hash沖突的話)精续,這一點和Jdk1.8有區(qū)別,下文詳解粹懒。在舊數(shù)組中同一條Entry鏈上的元素重付,通過重新計算索引位置后,有可能被放到了新數(shù)組的不同位置上凫乖。
下面舉個例子說明下擴容過程确垫。假設了我們的hash算法就是簡單的用key mod 一下表的大小(也就是數(shù)組的長度)帽芽。其中的哈希桶數(shù)組table的size=2删掀, 所以key = 3、7嚣镜、5爬迟,put順序依次為 5、7菊匿、3付呕。在mod 2以后都沖突在table[1]這里了计福。這里假設負載因子 loadFactor=1,即當鍵值對的實際大小size 大于 table的實際大小時進行擴容徽职。接下來的三個步驟是哈希桶數(shù)組 resize成4象颖,然后所有的Node重新rehash的過程。
下面我們講解下JDK1.8做了哪些優(yōu)化姆钉。經過觀測可以發(fā)現(xiàn)说订,我們使用的是2次冪的擴展(指長度擴為原來2倍),所以潮瓶,元素的位置要么是在原位置陶冷,要么是在原位置再移動2次冪的位置√焊ǎ看下圖可以明白這句話的意思埂伦,n為table的長度,圖(a)表示擴容前的key1和key2兩種key確定索引位置的示例思恐,圖(b)表示擴容后key1和key2兩種key確定索引位置的示例沾谜,其中hash1是key1對應的哈希與高位運算結果。
元素在重新計算hash之后胀莹,因為n變?yōu)?倍基跑,那么n-1的mask范圍在高位多1bit(紅色),因此新的index就會發(fā)生這樣的變化:
因此描焰,我們在擴充HashMap的時候媳否,不需要像JDK1.7的實現(xiàn)那樣重新計算hash,只需要看看原來的hash值新增的那個bit是1還是0就好了栈顷,是0的話索引沒變逆日,是1的話索引變成“原索引+oldCap”,可以看看下圖為16擴充為32的resize示意圖:
這個設計確實非常的巧妙萄凤,既省去了重新計算hash值的時間,而且同時搪哪,由于新增的1bit是0還是1可以認為是隨機的靡努,因此resize的過程,均勻的把之前的沖突的節(jié)點分散到新的bucket了晓折。這一塊就是JDK1.8新增的優(yōu)化點惑朦。有一點注意區(qū)別,JDK1.7中rehash的時候漓概,舊鏈表遷移新鏈表的時候漾月,如果在新表的數(shù)組索引位置相同,則鏈表元素會倒置胃珍,但是從上圖可以看出梁肿,JDK1.8不會倒置蜓陌。有興趣的同學可以研究下JDK1.8的resize源碼,寫的很贊吩蔑,如下:
/**
* Initializes or doubles table size. If null, allocates in
* accord with initial capacity target held in field threshold.
* Otherwise, because we are using power-of-two expansion, the
* elements from each bin must either stay at same index, or move
* with a power of two offset in the new table.
*
* @return the table
*/
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
// 超過最大值就不再擴充了钮热,就只好隨你碰撞去吧
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 沒超過最大值,就擴充為原來的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 計算新的resize上限
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes"烛芬,"unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 把每個bucket都移動到新的buckets中
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
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 { // 鏈表優(yōu)化重hash的代碼塊
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;
}
// 原索引+oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到bucket里
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引+oldCap放到bucket里
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
線程安全性
在多線程使用場景中隧期,應該盡量避免使用線程不安全的HashMap,而使用線程安全的ConcurrentHashMap赘娄。那么為什么說HashMap是線程不安全的仆潮,下面舉例子說明在并發(fā)的多線程使用場景中使用HashMap可能造成死循環(huán)。代碼例子如下(便于理解遣臼,仍然使用JDK1.7的環(huán)境):
public class HashMapInfiniteLoop {
private static HashMap<Integer,String> map = new HashMap<Integer,String>(2鸵闪,0.75f);
public static void main(String[] args) {
map.put(5, "C");
new Thread("Thread1") {
public void run() {
map.put(7, "B");
System.out.println(map);
};
}.start();
new Thread("Thread2") {
public void run() {
map.put(3, "A");
System.out.println(map);
};
}.start();
}
}
其中暑诸,map初始化為一個長度為2的數(shù)組蚌讼,loadFactor=0.75,threshold=2*0.75=1个榕,也就是說當put第二個key的時候篡石,map就需要進行resize。
通過設置斷點讓線程1和線程2同時debug到transfer方法(3.3小節(jié)代碼塊)的首行西采。注意此時兩個線程已經成功添加數(shù)據(jù)凰萨。放開thread1的斷點至transfer方法的“Entry next = e.next;” 這一行;然后放開線程2的的斷點械馆,讓線程2進行resize胖眷。結果如下圖。
注意霹崎,Thread1的 e 指向了key(3)珊搀,而next指向了key(7),其在線程二rehash后尾菇,指向了線程二重組后的鏈表境析。
線程一被調度回來執(zhí)行,先是執(zhí)行 newTalbe[i] = e派诬, 然后是e = next劳淆,導致了e指向了key(7),而下一次循環(huán)的next = e.next導致了next指向了key(3)默赂。
e.next = newTable[i] 導致 key(3).next 指向了 key(7)沛鸵。注意:此時的key(7).next 已經指向了key(3), 環(huán)形鏈表就這樣出現(xiàn)了缆八。
于是曲掰,當我們用線程一調用map.get(11)時疾捍,悲劇就出現(xiàn)了——Infinite Loop。
JDK1.8與JDK1.7的性能對比
HashMap中蜈缤,如果key經過hash算法得出的數(shù)組索引位置全部不相同拾氓,即Hash算法非常好,那樣的話底哥,getKey方法的時間復雜度就是O(1)咙鞍,如果Hash算法技術的結果碰撞非常多,假如Hash算極其差趾徽,所有的Hash算法結果得出的索引位置一樣续滋,那樣所有的鍵值對都集中到一個桶中,或者在一個鏈表中孵奶,或者在一個紅黑樹中疲酌,時間復雜度分別為O(n)和O(lgn)。 鑒于JDK1.8做了多方面的優(yōu)化了袁,總體性能優(yōu)于JDK1.7朗恳,下面我們從兩個方面用例子證明這一點。
Hash較均勻的情況
為了便于測試载绿,我們先寫一個類Key粥诫,如下:
class Key implements Comparable<Key> {
private final int value;
Key(int value) {
this.value = value;
}
@Override
public int compareTo(Key o) {
return Integer.compare(this.value, o.value);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass())
return false;
Key key = (Key) o;
return value == key.value;
}
@Override
public int hashCode() {
return value;
}
}
這個類復寫了equals方法,并且提供了相當好的hashCode函數(shù)怀浆,任何一個值的hashCode都不會相同,因為直接使用value當做hashcode怕享。為了避免頻繁的GC执赡,我將不變的Key實例緩存了起來,而不是一遍一遍的創(chuàng)建它們函筋。代碼如下:
public class Keys {
public static final int MAX_KEY = 10_000_000;
private static final Key[] KEYS_CACHE = new Key[MAX_KEY];
static {
for (int i = 0; i < MAX_KEY; ++i) {
KEYS_CACHE[i] = new Key(i);
}
}
public static Key of(int value) {
return KEYS_CACHE[value];
}
}
現(xiàn)在開始我們的試驗沙合,測試需要做的僅僅是,創(chuàng)建不同size的HashMap(1驻呐、10灌诅、100、……10000000)含末,屏蔽了擴容的情況,代碼如下:
static void test(int mapSize) {
HashMap<Key, Integer> map = new HashMap<Key,Integer>(mapSize);
for (int i = 0; i < mapSize; ++i) {
map.put(Keys.of(i), i);
}
long beginTime = System.nanoTime(); //獲取納秒
for (int i = 0; i < mapSize; i++) {
map.get(Keys.of(i));
}
long endTime = System.nanoTime();
System.out.println(endTime - beginTime);
}
public static void main(String[] args) {
for(int i=10;i<= 1000 0000;i*= 10){
test(i);
}
}
在測試中會查找不同的值即舌,然后度量花費的時間佣盒,為了計算getKey的平均時間,我們遍歷所有的get方法顽聂,計算總的時間肥惭,除以key的數(shù)量盯仪,計算一個平均值,主要用來比較蜜葱,絕對值可能會受很多環(huán)境因素的影響全景。結果如下:
通過觀測測試結果可知,JDK1.8的性能要高于JDK1.7 15%以上牵囤,在某些size的區(qū)域上爸黄,甚至高于100%。由于Hash算法較均勻揭鳞,JDK1.8引入的紅黑樹效果不明顯炕贵,下面我們看看Hash不均勻的的情況。
Hash極不均勻的情況
假設我們又一個非常差的Key野崇,它們所有的實例都返回相同的hashCode值称开。這是使用HashMap最壞的情況。代碼修改如下:
class Key implements Comparable<Key> {
//...
@Override
public int hashCode() {
return 1;
}
}
仍然執(zhí)行main方法乓梨,得出的結果如下表所示:
從表中結果中可知鳖轰,隨著size的變大,JDK1.7的花費時間是增長的趨勢扶镀,而JDK1.8是明顯的降低趨勢蕴侣,并且呈現(xiàn)對數(shù)增長穩(wěn)定。當一個鏈表太長的時候狈惫,HashMap會動態(tài)的將它替換成一個紅黑樹睛蛛,這話的話會將時間復雜度從O(n)降為O(logn)。hash算法均勻和不均勻所花費的時間明顯也不相同胧谈,這兩種情況的相對比較忆肾,可以說明一個好的hash算法的重要性。
測試環(huán)境:處理器為2.2 GHz Intel Core i7菱肖,內存為16 GB 1600 MHz DDR3客冈,SSD硬盤,使用默認的JVM參數(shù)稳强,運行在64位的OS X 10.10.1上场仲。
小結
(1) 擴容是一個特別耗性能的操作,所以當程序員在使用HashMap的時候退疫,估算map的大小渠缕,初始化的時候給一個大致的數(shù)值,避免map進行頻繁的擴容褒繁。
(2) 負載因子是可以修改的亦鳞,也可以大于1,但是建議不要輕易修改,除非情況非常特殊燕差。
(3) HashMap是線程不安全的遭笋,不要在并發(fā)的環(huán)境中同時操作HashMap,建議使用ConcurrentHashMap徒探。
(4) JDK1.8引入紅黑樹大程度優(yōu)化了HashMap的性能瓦呼。
(5) 還沒升級JDK1.8的,現(xiàn)在開始升級吧测暗。HashMap的性能提升僅僅是JDK1.8的冰山一角央串。
參考
- JDK1.7&JDK1.8 源碼。
- CSDN博客頻道偷溺,HashMap多線程死循環(huán)問題蹋辅,2014。
- 紅黑聯(lián)盟挫掏,Java類集框架之HashMap(JDK1.8)源碼剖析侦另,2015。
- CSDN博客頻道尉共,教你初步了解紅黑樹褒傅,2010。
- Java Code Geeks袄友,HashMap performance improvements in Java 8殿托,2014。
- Importnew剧蚣,危險支竹!在HashMap中將可變對象用作Key,2014鸠按。
- CSDN博客頻道礼搁,為什么一般hashtable的桶數(shù)會取一個素數(shù),2013。