Hashmap是一種非常常用的、應(yīng)用廣泛的數(shù)據(jù)類型,最近研究到相關(guān)的內(nèi)容,就正好復(fù)習(xí)一下。網(wǎng)上關(guān)于hashmap的文章很多,但到底是自己學(xué)習(xí)的總結(jié),就發(fā)出來跟大家一起分享,一起討論。
1、hashmap的數(shù)據(jù)結(jié)構(gòu)
要知道hashmap是什么,首先要搞清楚它的數(shù)據(jù)結(jié)構(gòu),在java編程語言中,最基本的結(jié)構(gòu)就是兩種,一個(gè)是數(shù)組,另外一個(gè)是模擬指針(引用),所有的數(shù)據(jù)結(jié)構(gòu)都可以用這兩個(gè)基本結(jié)構(gòu)來構(gòu)造的,hashmap也不例外。Hashmap實(shí)際上是一個(gè)數(shù)組和鏈表的結(jié)合體(在數(shù)據(jù)結(jié)構(gòu)中,一般稱之為“鏈表散列“),請看下圖(橫排表示數(shù)組,縱排表示數(shù)組元素【實(shí)際上是一個(gè)鏈表】)。
從圖中我們可以看到一個(gè)hashmap就是一個(gè)數(shù)組結(jié)構(gòu),當(dāng)新建一個(gè)hashmap的時(shí)候,就會(huì)初始化一個(gè)數(shù)組。我們來看看java代碼:
/*** The table, resized as necessary. Length MUST Always be a power of two.* FIXME 這里需要注意這句話,至于原因后面會(huì)講到*/transient Entry[] table;
- static class Entry<K,V> implements Map.Entry<K,V> {
- final K key;
- V value;
- final int hash;
- Entry<K,V> next;
- ..........
- }
static class Entry<K,V> implements Map.Entry<K,V> {final K key;V value;final int hash;Entry<K,V> next;..........}
上面的Entry就是數(shù)組中的元素,它持有一個(gè)指向下一個(gè)元素的引用,這就構(gòu)成了鏈表。
當(dāng)我們往hashmap中put元素的時(shí)候,先根據(jù)key的hash值得到這個(gè)元素在數(shù)組中的位置(即下標(biāo)),然后就可以把這個(gè)元素放到對(duì)應(yīng)的位置中了。如果這個(gè)元素所在的位子上已經(jīng)存放有其他元素了,那么在同一個(gè)位子上的元素將以鏈表的形式存放,新加入的放在鏈頭,最先加入的放在鏈尾。從hashmap中g(shù)et元素時(shí),首先計(jì)算key的hashcode,找到數(shù)組中對(duì)應(yīng)位置的某一元素,然后通過key的equals方法在對(duì)應(yīng)位置的鏈表中找到需要的元素。從這里我們可以想象得到,如果每個(gè)位置上的鏈表只有一個(gè)元素,那么hashmap的get效率將是最高的,但是理想總是美好的,現(xiàn)實(shí)總是有困難需要我們?nèi)タ朔
2、hash算法
我們可以看到在hashmap中要找到某個(gè)元素,需要根據(jù)key的hash值來求得對(duì)應(yīng)數(shù)組中的位置。如何計(jì)算這個(gè)位置就是hash算法。前面說過hashmap的數(shù)據(jù)結(jié)構(gòu)是數(shù)組和鏈表的結(jié)合,所以我們當(dāng)然希望這個(gè)hashmap里面的元素位置盡量的分布均勻些,盡量使得每個(gè)位置上的元素?cái)?shù)量只有一個(gè),那么當(dāng)我們用hash算法求得這個(gè)位置的時(shí)候,馬上就可以知道對(duì)應(yīng)位置的元素就是我們要的,而不用再去遍歷鏈表。
所以我們首先想到的就是把hashcode對(duì)數(shù)組長度取模運(yùn)算,這樣一來,元素的分布相對(duì)來說是比較均勻的。但是,“模”運(yùn)算的消耗還是比較大的,能不能找一種更快速,消耗更小的方式那?java中時(shí)這樣做的, - static int indexFor(int h, int length) {
- return h & (length-1);
- }
static int indexFor(int h, int length) {return h & (length-1);}
首先算得key得hashcode值,然后跟數(shù)組的長度-1做一次“與”運(yùn)算(&)??瓷先ズ芎唵?,其實(shí)比較有玄機(jī)。比如數(shù)組的長度是2的4次方,那么hashcode就會(huì)和2的4次方-1做“與”運(yùn)算。很多人都有這個(gè)疑問,為什么hashmap的數(shù)組初始化大小都是2的次方大小時(shí),hashmap的效率最高,我以2的4次方舉例,來解釋一下為什么數(shù)組大小為2的冪時(shí)hashmap訪問的性能最高。
看下圖,左邊兩組是數(shù)組長度為16(2的4次方),右邊兩組是數(shù)組長度為15。兩組的hashcode均為8和9,但是很明顯,當(dāng)它們和1110“與”的時(shí)候,產(chǎn)生了相同的結(jié)果,也就是說它們會(huì)定位到數(shù)組中的同一個(gè)位置上去,這就產(chǎn)生了碰撞,8和9會(huì)被放到同一個(gè)鏈表上,那么查詢的時(shí)候就需要遍歷這個(gè)鏈表,得到8或者9,這樣就降低了查詢的效率。同時(shí),我們也可以發(fā)現(xiàn),當(dāng)數(shù)組長度為15的時(shí)候,hashcode的值會(huì)與14(1110)進(jìn)行“與”,那么最后一位永遠(yuǎn)是0,而0001,0011,0101,1001,1011,0111,1101這幾個(gè)位置永遠(yuǎn)都不能存放元素了,空間浪費(fèi)相當(dāng)大,更糟的是這種情況中,數(shù)組可以使用的位置比數(shù)組長度小了很多,這意味著進(jìn)一步增加了碰撞的幾率,減慢了查詢的效率!
所以說,當(dāng)數(shù)組長度為2的n次冪的時(shí)候,不同的key算得得index相同的幾率較小,那么數(shù)據(jù)在數(shù)組上分布就比較均勻,也就是說碰撞的幾率小,相對(duì)的,查詢的時(shí)候就不用遍歷某個(gè)位置上的鏈表,這樣查詢效率也就較高了。
說到這里,我們再回頭看一下hashmap中默認(rèn)的數(shù)組大小是多少,查看源代碼可以得知是16,為什么是16,而不是15,也不是20呢,看到上面annegu的解釋之后我們就清楚了吧,顯然是因?yàn)?6是2的整數(shù)次冪的原因,在小數(shù)據(jù)量的情況下16比15和20更能減少key之間的碰撞,而加快查詢的效率。
所以,在存儲(chǔ)大容量數(shù)據(jù)的時(shí)候,最好預(yù)先指定hashmap的size為2的整數(shù)次冪次方。就算不指定的話,也會(huì)以大于且最接近指定值大小的2次冪來初始化的,代碼如下(HashMap的構(gòu)造方法中): -
- int capacity = 1;
- while (capacity < initialCapacity)
- capacity <<= 1;
// Find a power of 2 >= initialCapacityint capacity = 1;while (capacity < initialCapacity)capacity <<= 1;
3、hashmap的resize
當(dāng)hashmap中的元素越來越多的時(shí)候,碰撞的幾率也就越來越高(因?yàn)閿?shù)組的長度是固定的),所以為了提高查詢的效率,就要對(duì)hashmap的數(shù)組進(jìn)行擴(kuò)容,數(shù)組擴(kuò)容這個(gè)操作也會(huì)出現(xiàn)在ArrayList中,所以這是一個(gè)通用的操作,很多人對(duì)它的性能表示過懷疑,不過想想我們的“均攤”原理,就釋然了,而在hashmap數(shù)組擴(kuò)容之后,最消耗性能的點(diǎn)就出現(xiàn)了:原數(shù)組中的數(shù)據(jù)必須重新計(jì)算其在新數(shù)組中的位置,并放進(jìn)去,這就是resize。
那么hashmap什么時(shí)候進(jìn)行擴(kuò)容呢?當(dāng)hashmap中的元素個(gè)數(shù)超過數(shù)組大小*loadFactor時(shí),就會(huì)進(jìn)行數(shù)組擴(kuò)容,loadFactor的默認(rèn)值為0.75,也就是說,默認(rèn)情況下,數(shù)組大小為16,那么當(dāng)hashmap中元素個(gè)數(shù)超過16*0.75=12的時(shí)候,就把數(shù)組的大小擴(kuò)展為2*16=32,即擴(kuò)大一倍,然后重新計(jì)算每個(gè)元素在數(shù)組中的位置,而這是一個(gè)非常消耗性能的操作,所以如果我們已經(jīng)預(yù)知hashmap中元素的個(gè)數(shù),那么預(yù)設(shè)元素的個(gè)數(shù)能夠有效的提高h(yuǎn)ashmap的性能。比如說,我們有1000個(gè)元素new HashMap(1000), 但是理論上來講new HashMap(1024)更合適,不過上面annegu已經(jīng)說過,即使是1000,hashmap也自動(dòng)會(huì)將其設(shè)置為1024。 但是new HashMap(1024)還不是更合適的,因?yàn)?.75*1000 < 1000, 也就是說為了讓0.75 * size > 1000, 我們必須這樣new HashMap(2048)才最合適,既考慮了&的問題,也避免了resize的問題。
4、key的hashcode與equals方法改寫
在第一部分hashmap的數(shù)據(jù)結(jié)構(gòu)中,annegu就寫了get方法的過程:首先計(jì)算key的hashcode,找到數(shù)組中對(duì)應(yīng)位置的某一元素,然后通過key的equals方法在對(duì)應(yīng)位置的鏈表中找到需要的元素。所以,hashcode與equals方法對(duì)于找到對(duì)應(yīng)元素是兩個(gè)關(guān)鍵方法。
Hashmap的key可以是任何類型的對(duì)象,例如User這種對(duì)象,為了保證兩個(gè)具有相同屬性的user的hashcode相同,我們就需要改寫hashcode方法,比方把hashcode值的計(jì)算與User對(duì)象的id關(guān)聯(lián)起來,那么只要user對(duì)象擁有相同id,那么他們的hashcode也能保持一致了,這樣就可以找到在hashmap數(shù)組中的位置了。如果這個(gè)位置上有多個(gè)元素,還需要用key的equals方法在對(duì)應(yīng)位置的鏈表中找到需要的元素,所以只改寫了hashcode方法是不夠的,equals方法也是需要改寫滴~當(dāng)然啦,按正常思維邏輯,equals方法一般都會(huì)根據(jù)實(shí)際的業(yè)務(wù)內(nèi)容來定義,例如根據(jù)user對(duì)象的id來判斷兩個(gè)user是否相等。
在改寫equals方法的時(shí)候,需要滿足以下三點(diǎn):
(1) 自反性:就是說a.equals(a)必須為true。
(2) 對(duì)稱性:就是說a.equals(b)=true的話,b.equals(a)也必須為true。
(3) 傳遞性:就是說a.equals(b)=true,并且b.equals(c)=true的話,a.equals(c)也必須為true。
通過改寫key對(duì)象的equals和hashcode方法,我們可以將任意的業(yè)務(wù)對(duì)象作為map的key(前提是你確實(shí)有這樣的需要)。
總結(jié):
本文主要描述了HashMap的結(jié)構(gòu),和hashmap中hash函數(shù)的實(shí)現(xiàn),以及該實(shí)現(xiàn)的特性,同時(shí)描述了hashmap中resize帶來性能消耗的根本原因,以及將普通的域模型對(duì)象作為key的基本要求。尤其是hash函數(shù)的實(shí)現(xiàn),可以說是整個(gè)HashMap的精髓所在,只有真正理解了這個(gè)hash函數(shù),才可以說對(duì)HashMap有了一定的理解。