hashMap用了一個名字為table的數組;還有若干個名字為entry的鏈表。看hashMap是如何應用這些數據結構的。用插 入<key,value>舉例:hashMap首先會通過key得到其hashCode,具體的hash函數就不說了(因為沒多大意義);然 后把key的hashCode%table.length,就是拿hashCode模table數組大小,得到的余數就是key所在table數組中的下 標(實際不是key的下標,是entry類);但這樣做有個問題,可能不同key卻有一樣的hasdCode,所以求余后其必然會得到相同的下標,那如何 存儲了?有兩個辦法,一種是利用開放地址法,就是說后來相同的hashCode去找先來hashCode所在下標的相鄰下標。說的有點繞口,舉個例子,比 如<1,2>已經存在table數組的31的位置上了,再來一個<101,102>,其通過哈希后說:我也應該在31的位置上, 但是table說,你后來,你再在31附近找個空位安置下吧。當然,具體怎么找,有規則的。另外一種方式就是鏈地址法,還是拿以上的例子 說,<101,102>來到時,發現31的位置已經被占了,這時table說:<1,2>,你帶 下<101,102>;其實就是要<1,2>把<101,102>的引用存儲了。但是<1,2>說:我 怎么存儲<101,102>的引用了,我沒位置呀。所以table說:我給你們每個殼(entry類)吧,把你們都封裝了;於是就有了entry類。
那hashMap是使用那種方式了。先分析下開放地址和鏈地址法的優缺點。開放地址法一般需要2倍實際數據大小的空間,因為要留下一定的空閑地址去存儲相 同hashCode的<key,value>;並且查找相鄰空閑地址也是一項比較費時間的任務;鏈地址法,就不需要2倍的空間(table數 組),但是需要存儲額外的信息,比如next信息;總體來看,鏈地址法好點(關鍵是節省了查找相鄰地址的時間),所以,hashMap用的是鏈地址法。
還有問題,hashMap為什么用數組存儲index(hashCode%table.length)了,而不用鏈表了?因為數組有固定大小限制,而鏈表 沒有,而且map是沒有限制大小的?這主要考慮了查找效率的問題。從前面的分析可以看到,因為key的hashCode%table.length直接做 為entry的下標,所以其查詢key的速度很快,只要O(1)的時間;如果是鏈表,要一個一個的排查對比,需要O(N)的時間;這之間的效率,相差太遠 了。所以,hashMap用了數組。
最后一個問題,那數組的固定大小如何解決了?hashMap在每次插入數據前,會檢查table數組的實際容量,如果實際容量>=初始容量,則把 table的初始容量擴為原來的2倍,這時,就需要一個一個復制原來的數據項了,這是比較費時的!所以,初始容量很重要。
1、hashmap的數據結構
要知道hashmap是什么,首先要搞清楚它的數據結構,在java編程語言中,最基本的結構就是兩種,一個是數組,另外一個是模擬指針(引用),所有的 數據結構都可以用這兩個基本結構來構造的,hashmap也不例外。Hashmap實際上是一個數組和鏈表的結合體(在數據結構中,一般稱之為“鏈表散列 “),請看下圖(橫排表示數組,縱排表示數組元素【實際上是一個鏈表】)。
從圖中我們可以看到一個hashmap就是一個數組結構,當新建一個hashmap的時候,就會初始化一個數組。我們來看看java代碼:
/** * The table, resized as necessary. Length MUST Always be a power of two. * FIXME 這里需要注意這句話,至於原因后面會講到 */ 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; .......... }
當我們往hashmap中put元素的時候,先根據key的hash值得到這個元素在數組中的位置(即下標),然后就可以把這個元素放到對應的位置中了。 如果這個元素所在的位子上已經存放有其他元素了,那么在同一個位子上的元素將以鏈表的形式存放,新加入的放在鏈頭,最先加入的放在鏈尾。從hashmap 中get元素時,首先計算key的hashcode,找到數組中對應位置的某一元素,然后通過key的equals方法在對應位置的鏈表中找到需要的元素。從這里我們可以想象得到,如果每個位置上的鏈表只有一個元素,那么hashmap的get效率將是最高的,但是理想總是美好的,現實總是有困難需要我們去克服,哈哈~
2、hash算法
我們可以看到在hashmap中要找到某個元素,需要根據key的hash值來求得對應數組中的位置。如何計算這個位置就是hash算法。前面說過 hashmap的數據結構是數組和鏈表的結合,所以我們當然希望這個hashmap里面的元素位置盡量的分布均勻些,盡量使得每個位置上的元素數量只有一 個,那么當我們用hash算法求得這個位置的時候,馬上就可以知道對應位置的元素就是我們要的,而不用再去遍歷鏈表。
所以我們首先想到的就是把hashcode對數組長度取模運算,這樣一來,元素的分布相對來說是比較均勻的。但是,“模”運算的消耗還是比較大的,能不能找一種更快速,消耗更小的方式那?java中時這樣做的,
static int indexFor(int h, int length) { return h & (length-1); }
當length=2^n時,hashcode & (length-1) == hashcode % length
首先算得key得hashcode值,然后跟數組的長度-1做一次“與”運算(&)。看上去很簡單,其實比較有玄機。比如數組的長度是2的4次方, 那么hashcode就會和2的4次方-1做“與”運算。很多人都有這個疑問,為什么hashmap的數組初始化大小都是2的次方大小時,hashmap 的效率最高,我以2的4次方舉例,來解釋一下為什么數組大小為2的冪時hashmap訪問的性能最高。
看下圖,左邊兩組是數組長度為16(2的4次方),減一之后就是二進制的1111,右邊兩組是數組長度為15,減一之后就是二進制的1110。兩組的hashcode均為9(1001)和8(1000),但是很明顯,當它們和1110“與”的 時候,產生了相同的結果,也就是說它們會定位到數組中的同一個位置上去,這就產生了碰撞,8和9會被放到同一個鏈表上,那么查詢的時候就需要遍歷這個鏈表,得到8或者9,這樣就降低了查詢的效率。同時,我們也可以發現,當數組長度為15的時候,hashcode的值會與14(1110)進行“與”,那么最后一位永遠是0,而0001,0011,0101,1001,1011,0111,1101這幾個位置永遠都不能存放元素了,空間浪費相當大,更糟的是這種情況中,數組可以使用的位置比數組長度小了很多,這意味着進一步增加了碰撞的幾率,減慢了查詢的效率!
所以說,當數組長度為2的n次冪的時候,不同的key算得的index相同的幾率較小,那么數據在數組上分布就比較均勻,也就是說碰撞的幾率小,相對的,查詢的時候就不用遍歷某個位置上的鏈表,這樣查詢效率也就較高了。
說到這里,我們再回頭看一下hashmap中默認的數組大小是多少,查看源代碼可以得知是16,為什么是16,而不是15,也不是20呢,看到上面 annegu的解釋之后我們就清楚了吧,顯然是因為16是2的整數次冪的原因,在小數據量的情況下16比15和20更能減少key之間的碰撞,而加快查詢 的效率。
所以,在存儲大容量數據的時候,最好預先指定hashmap的size為2的整數次冪次方。就算不指定的話,也會以大於且最接近指定值大小的2次冪來初始化的,代碼如下(HashMap的構造方法中):
// Find a power of 2 >= initialCapacity int capacity = 1; while (capacity < initialCapacity) capacity <<= 1;
3、hashmap的resize
當hashmap中的元素越來越多的時候,碰撞的幾率也就越來越高(因為數組的長度是固定的),所以為了提高查詢的效率,就要對hashmap的數組進行擴容,數組擴容這個操作也會出現在ArrayList中,所以這是一個通用的操作,很多人對它的性能表示過懷疑,不過想想我們的“均攤”原理,就釋然了, 而在hashmap數組擴容之后,最消耗性能的點就出現了:原數組中的數據必須重新計算其在新數組中的位置,並放進去,這就是resize。
那么hashmap什么時候進行擴容呢?當hashmap中的元素個數超過數組大小*loadFactor時,就會進行數組擴容,loadFactor的默認值為0.75,也就是說,默認情況下,數組大小為16,那么當hashmap中元素個數超過16*0.75=12的時候,就把數組的大小擴展為 2*16=32,即擴大一倍,然后重新計算每個元素在數組中的位置,而這是一個非常消耗性能的操作,所以如果我們已經預知hashmap中元素的個數,那么預設元素的個數能夠有效的提高hashmap的性能。比如說,我們有1000個元素new HashMap(1000), 但是理論上來講new HashMap(1024)更合適,不過上面annegu已經說過,即使是1000,hashmap也自動會將其設置為1024。 但是new HashMap(1024)還不是更合適的,因為0.75*1000 < 1000, 也就是說為了讓0.75 * size > 1000, 我們必須這樣new HashMap(2048)才最合適,既考慮了&的問題,也避免了resize的問題。4、key的hashcode與equals方法改寫
在第一部分hashmap的數據結構中,annegu就寫了get方法的過程:首先計算key的hashcode,找到數組中對應位置的某一元素,然后通 過key的equals方法在對應位置的鏈表中找到需要的元素。所以,hashcode與equals方法對於找到對應元素是兩個關鍵方法。Hashmap的key可以是任何類型的對象,例如User這種對象,為了保證兩個具有相同屬性的user的hashcode相同,我們就需要改寫 hashcode方法,比方把hashcode值的計算與User對象的id關聯起來,那么只要user對象擁有相同id,那么他們的hashcode也能保持一致了,這樣就可以找到在hashmap數組中的位置了。如果這個位置上有多個元素,還需要用key的equals方法在對應位置的鏈表中找到需要的元素,所以只改寫了hashcode方法是不夠的,equals方法也是需要改寫滴~當然啦,按正常思維邏輯,equals方法一般都會根據實際的業務內容來定義,例如根據user對象的id來判斷兩個user是否相等。
在改寫equals方法的時候,需要滿足以下三點:(1) 自反性:就是說a.equals(a)必須為true。
(2) 對稱性:就是說a.equals(b)=true的話,b.equals(a)也必須為true。
(3) 傳遞性:就是說a.equals(b)=true,並且b.equals(c)=true的話,a.equals(c)也必須為true。
通過改寫key對象的equals和hashcode方法,我們可以將任意的業務對象作為map的key(前提是你確實有這樣的需要)。
總結:
本文主要描述了HashMap的結構,和hashmap中hash函數的實現,以及該實現的特性,同時描述了hashmap中resize帶來性能消耗的根本原因,以及將普通的域模型對象作為key的基本要求。尤其是hash函數的實現,可以說是整個HashMap的精髓所在,只有真正理解了這個hash 函數,才可以說對HashMap有了一定的理解。