之前很早就在博客中寫過HashMap的一些東西:
徹底搞懂HashMap,HashTableConcurrentHashMap關聯:
HashMap和HashTable的區別:
今天來講HashMap是分JDK7和JDK8 對比着來講的, 因為JDK8中針對於HashMap有些小的改動, 這也是一些面試會經常問到的點。
一:JDK7中的HashMap:
HashMap底層維護一個數組table, 數組中的每一項是一個key,value形式的Entry。


我們往HashMap中所放置的對象實際是存儲在該數組中。
Map中的key,value則以Entry的形式存放在數組中。


這個Entry應該放在數組的哪一個位置上, 是通過key的hashCode來計算的。這個位置也成為hash桶。


通過hash計算出來的值將通過indexFor方法找到它所在的table下標:


這個方法其實是對table.length取模,
當兩個key通過
hashCode計算相同時,則發生了hash沖突(碰撞),HashMap解決hash沖突的方式是用鏈表。當發生hash沖突時,則將存放在數組中的Entry設置為新值的next(這里要注意的是,比如A和B都hash后都映射到下標i中,之前已經有A了,當map.put(B)時,將B放到下標i中,A則為B的next,所以新值存放在數組中,舊值在新值的鏈表上)。


例如上圖, 一個長度為16的數組中,每個元素存儲的是一個鏈表的頭結點。那么這些元素是按照什么樣的規則存儲到數組中呢。一般情況是通過hash(key)%len獲得,也就是元素的key的哈希值對數組長度取模得到。比如上述哈希表中,12%16=12,28%16=12,108%16=12,140%16=12。所以12、28、108以及140都存儲在數組下標為12的位置。它的內部其實是用一個Entity數組來實現的,屬性有key、value、next。
接着看看put方法:


469行, 如果key為空, 則把這個對象放到第一個數組上。
471行, 計算key的hash值
472行, 通過indexFor方法返回分散到數組table中的下標
473行, 通過table[i]獲取新Entry的值, 如果值不為空,則判斷key的hash值和equals來判斷新的Entry和舊的Entry值是否相同, 如果相同則覆蓋舊Entry的值並返回。
484行, 往數組上添加新的Entry。
添加Entry時,當table的容量大於theshold(
(int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1)), 這里實際上就是16*0.75=12


如上, 當滿足一定條件后 table就開始擴容, 這個過程也稱為rehash, 具體請看下圖:


559行: 創建一個新的Entry數組
564行: 將數組轉移到新的Entry數組中
565行: 修改resize的條件threshold
再具體的實現大家可以看下jdk7中HashMap的相關源碼。
二:JDK8中的HashMap:
一直到JDK7為止,HashMap的結構都是這么簡單,基於一個數組以及多個鏈表的實現,hash值沖突的時候,就將對應節點以鏈表的形式存儲。
這樣子的HashMap性能上就抱有一定疑問,如果說成百上千個節點在hash時發生碰撞,存儲一個鏈表中,那么如果要查找其中一個節點,那就不可避免的花費O(N)的查找時間,這將是多么大的性能損失。這個問題終於在JDK8中得到了解決。再最壞的情況下,鏈表查找的時間復雜度為O(n),而紅黑樹一直是O(logn),這樣會提高HashMap的效率。
JDK7中HashMap采用的是位桶+鏈表的方式,即我們常說的
散列鏈表的方式,而JDK8中采用的是位
桶+鏈表/紅黑樹的方式,也是非線程安全的。當
某個位桶的鏈表的長度達到某個閥值的時候,這個鏈表就將轉換成紅黑樹。
JDK8中,當同一個hash值的節點數大於等於8時,將不再以單鏈表的形式存儲了,會被調整成一顆紅黑樹(上圖中null節點沒畫)。這就是JDK7與JDK8中HashMap實現的最大區別。
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; }
treeifyBin()就是將鏈表轉換成紅黑樹。
之前的indefFor()方法消失 了,直接用(tab.length-1)&hash,所以看到這個,代表的就是數組的角標。


具體紅黑樹的實現大家可以看下JDK8中HashMap的實現。
三:需要注意的地方:
再談HashCode的重要性
前面講到了,HashMap中對Key的HashCode要做一次rehash,防止一些糟糕的Hash算法生成的糟糕的HashCode,那么為什么要防止糟糕的HashCode?
糟糕的HashCode意味着的是Hash沖突,即多個不同的Key可能得到的是同一個HashCode,糟糕的Hash算法意味着的就是Hash沖突的概率增大,這意味着HashMap的性能將下降,表現在兩方面:
1、有10個Key,可能6個Key的HashCode都相同,另外四個Key所在的Entry均勻分布在table的位置上,而某一個位置上卻連接了6個Entry。這就失去了HashMap的意義,HashMap這種數據結構性高性能的前提是,Entry均勻地分布在table位置上,但現在確是1 1 1 1 6的分布。所以,我們要求HashCode有很強的隨機性,這樣就盡可能地可以保證了Entry分布的隨機性,提升了HashMap的效率。
2、HashMap在一個某個table位置上遍歷鏈表的時候的代碼:
if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
看到,由於采用了"&&"運算符,因此先比較HashCode,HashCode都不相同就直接pass了,不會再進行equals比較了。HashCode因為是int值,比較速度非常快,而equals方法往往會對比一系列的內容,速度會慢一些。Hash沖突的概率大,意味着equals比較的次數勢必增多,必然降低了HashMap的效率了。
HashMap的table為什么是transient的
一個非常細節的地方:
transient Entry[] table;
看到table用了transient修飾,也就是說table里面的內容全都不會被序列化,不知道大家有沒有想過這么寫的原因?
在我看來,這么寫是非常必要的。因為HashMap是基於HashCode的,HashCode作為Object的方法,是native的:
public native int hashCode();
這意味着的是:HashCode和底層實現相關,不同的虛擬機可能有不同的HashCode算法。再進一步說得明白些就是,可能同一個Key在虛擬機A上的HashCode=1,在虛擬機B上的HashCode=2,在虛擬機C上的HashCode=3。
這就有問題了,Java自誕生以來,就以跨平台性作為最大賣點,好了,如果table不被transient修飾,在虛擬機A上可以用的程序到虛擬機B上可以用的程序就不能用了,失去了跨平台性,因為:
1、Key在虛擬機A上的HashCode=100,連在table[4]上
2、Key在虛擬機B上的HashCode=101,這樣,就去table[5]上找Key,明顯找不到
整個代碼就出問題了。因此,為了避免這一點,Java采取了重寫自己序列化table的方法,在writeObject選擇將key和value追加到序列化的文件最后面:
private void writeObject(java.io.ObjectOutputStream s) throws IOException { Iterator<Map.Entry<K,V>> i = (size > 0) ? entrySet0().iterator() : null; // Write out the threshold, loadfactor, and any hidden stuff s.defaultWriteObject(); // Write out number of buckets s.writeInt(table.length); // Write out size (number of Mappings) s.writeInt(size); // Write out keys and values (alternating) if (size > 0) { for(Map.Entry<K,V> e : entrySet0()) { s.writeObject(e.getKey()); s.writeObject(e.getValue()); } } }
而在readObject的時候重構HashMap數據結構:
private void readObject(java.io.ObjectInputStream s) throws IOException, ClassNotFoundException { // Read in the threshold (ignored), loadfactor, and any hidden stuff s.defaultReadObject(); if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new InvalidObjectException("Illegal load factor: " + loadFactor); // set hashSeed (can only happen after VM boot) Holder.UNSAFE.putIntVolatile(this, Holder.HASHSEED_OFFSET, sun.misc.Hashing.randomHashSeed(this)); // Read in number of buckets and allocate the bucket array; s.readInt(); // ignored // Read number of mappings int mappings = s.readInt(); if (mappings < 0) throw new InvalidObjectException("Illegal mappings count: " + mappings); int initialCapacity = (int) Math.min( // capacity chosen by number of mappings // and desired load (if >= 0.25) mappings * Math.min(1 / loadFactor, 4.0f), // we have limits... HashMap.MAXIMUM_CAPACITY); int capacity = 1; // find smallest power of two which holds all mappings while (capacity < initialCapacity) { capacity <<= 1; } table = new Entry[capacity]; threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1); useAltHashing = sun.misc.VM.isBooted() && (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD); init(); // Give subclass a chance to do its thing. // Read the keys and values, and put the mappings in the HashMap for (int i=0; i<mappings; i++) { K key = (K) s.readObject(); V value = (V) s.readObject(); putForCreate(key, value); } }
一種麻煩的方式,但卻保證了跨平台性。
這個例子也告訴了我們:盡管使用的虛擬機大多數情況下都是HotSpot,但是也不能對其它虛擬機不管不顧,有跨平台的思想是一件好事。
HashMap和Hashtable的區別
HashMap和Hashtable是一組相似的鍵值對集合,它們的區別也是面試常被問的問題之一,我這里簡單總結一下HashMap和Hashtable的區別:
1、Hashtable是線程安全的,Hashtable所有對外提供的方法都使用了synchronized,也就是同步,而HashMap則是線程非安全的
2、Hashtable不允許空的value,空的value將導致空指針異常,而HashMap則無所謂,沒有這方面的限制
3、上面兩個缺點是最主要的區別,另外一個區別無關緊要,我只是提一下,就是兩個的rehash算法不同,Hashtable的是:


這個hashSeed是使用sun.misc.Hashing類的randomHashSeed方法產生的。HashMap的rehash算法上面看過了,也就是:

