2017年的秋招徹底結束了,感覺Java上面的最常見的集合相關的問題就是hash……系列和一些常用並發集合和隊列,堆等結合算法一起考察,不完全統計,本人經歷:先后百度、唯品會、58同城、新浪微博、趣分期、美團點評等都在1、2……面的時候被問過無數次,都問吐了&_&,其他公司筆試的時候,但凡有Java的題,都有集合相關考點,尤其hash表……現在總結下。
- 2016-12-15 更新:Java 8 對 HashMap 的改進
- 2016-12-12 整理jdk 1.8之前的HashMap實現
2016-12-15 更新:Java 8 對 HashMap 的改進
如果說Java的hashmap是數組+鏈表,那么JDK 8之后就是數組+鏈表+紅黑樹組成了hashmap。之前的實現機制和原理在下面12-12期整理過,這次只說下新加的紅黑樹機制。
在之前談過,如果hash算法不好,會使得hash表蛻化為順序查找,即使負載因子和hash算法優化再多,也無法避免出現鏈表過長的情景(這個概論雖然很低),於是在JDK1.8中,對hashmap做了優化,引入紅黑樹。具體原理就是當hash表中每個桶附帶的鏈表長度默認超過8時,鏈表就轉換為紅黑樹結構,提高HashMap的性能,因為紅黑樹的增刪改是O(logn),而不是O(n)。
紅黑樹的具體原理和實現以后再總結。
主要看put方法實現

public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
封裝了一個final方法,里面用到一個常量,具體用處看源碼:
static final int TREEIFY_THRESHOLD = 8;
下面是具體源代碼注釋:

1 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, 2 boolean evict) { 3 Node<K,V>[] tab; Node<K,V> p; int n, i; 4 if ((tab = table) == null || (n = tab.length) == 0) // 首先判斷hash表是否是空的,如果空,則resize擴容 5 n = (tab = resize()).length; 6 if ((p = tab[i = (n - 1) & hash]) == null) // 通過key計算得到hash表下標,如果下標處為null,就新建鏈表頭結點,在方法最后插入即可 7 tab[i] = newNode(hash, key, value, null); 8 else { // 如果下標處已經存在節點,則進入到這里 9 Node<K,V> e; K k; 10 if (p.hash == hash && 11 ((k = p.key) == key || (key != null && key.equals(k)))) // 先看hash表該處的頭結點是否和key一樣(hashcode和equals比較),一樣就更新 12 e = p; 13 else if (p instanceof TreeNode) // hash表頭結點和key不一樣,則判斷節點是不是紅黑樹,是紅黑樹就按照紅黑樹處理 14 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); 15 else { // 如果不是紅黑樹,則按照之前的hashmap原理處理 16 for (int binCount = 0; ; ++binCount) { // 遍歷鏈表 17 if ((e = p.next) == null) { 18 p.next = newNode(hash, key, value, null); 19 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st (原jdk注釋) 顯然當鏈表長度大於等於7的時候,也就是說大於8的話,就轉化為紅黑樹結構,針對紅黑樹進行插入(logn復雜度) 20 treeifyBin(tab, hash); 21 break; 22 } 23 if (e.hash == hash && 24 ((k = e.key) == key || (key != null && key.equals(k)))) 25 break; 26 p = e; 27 } 28 } 29 if (e != null) { // existing mapping for key 30 V oldValue = e.value; 31 if (!onlyIfAbsent || oldValue == null) 32 e.value = value; 33 afterNodeAccess(e); 34 return oldValue; 35 } 36 } 37 ++modCount; 38 if (++size > threshold) // 如果超過容量,即擴容 39 resize(); 40 afterNodeInsertion(evict); 41 return null; 42 }
resize是新的擴容方法,之前談過,擴容原理是使用新的(2倍舊長度)的數組代替,把舊數組的內容放到新數組,需要重新計算hash和計算hash表的位置,非常耗時,但是自從 JDK 1.8 對hashmap 引入了紅黑樹,它和之前的擴容方法有了改進。
擴容方法的改進

1 final Node<K,V>[] resize() { 2 Node<K,V>[] oldTab = table; 3 int oldCap = (oldTab == null) ? 0 : oldTab.length; 4 int oldThr = threshold; 5 int newCap, newThr = 0; 6 if (oldCap > 0) { 7 if (oldCap >= MAXIMUM_CAPACITY) { 8 threshold = Integer.MAX_VALUE; 9 return oldTab; 10 } 11 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && 12 oldCap >= DEFAULT_INITIAL_CAPACITY) // 如果長度沒有超過最大值,則擴容為2倍的關系 13 newThr = oldThr << 1; // double threshold 14 } 15 else if (oldThr > 0) // initial capacity was placed in threshold 16 newCap = oldThr; 17 else { // zero initial threshold signifies using defaults 18 newCap = DEFAULT_INITIAL_CAPACITY; 19 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); 20 } 21 if (newThr == 0) { 22 float ft = (float)newCap * loadFactor; 23 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? 24 (int)ft : Integer.MAX_VALUE); 25 } 26 threshold = newThr; 27 @SuppressWarnings({"rawtypes","unchecked"}) 28 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; 29 table = newTab; 30 if (oldTab != null) { // 進行新舊元素的轉移過程 31 for (int j = 0; j < oldCap; ++j) { 32 Node<K,V> e; 33 if ((e = oldTab[j]) != null) { 34 oldTab[j] = null; 35 if (e.next == null) 36 newTab[e.hash & (newCap - 1)] = e; 37 else if (e instanceof TreeNode) 38 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); 39 else { // preserve order(原注釋) 如果不是紅黑樹的情況這里改進了,沒有rehash的過程,如下分別記錄鏈表的頭尾 40 Node<K,V> loHead = null, loTail = null; 41 Node<K,V> hiHead = null, hiTail = null; 42 Node<K,V> next; 43 do { 44 next = e.next; 45 if ((e.hash & oldCap) == 0) { 46 if (loTail == null) 47 loHead = e; 48 else 49 loTail.next = e; 50 loTail = e; 51 } 52 else { 53 if (hiTail == null) 54 hiHead = e; 55 else 56 hiTail.next = e; 57 hiTail = e; 58 } 59 } while ((e = next) != null); 60 if (loTail != null) { 61 loTail.next = null; 62 newTab[j] = loHead; 63 } 64 if (hiTail != null) { 65 hiTail.next = null; 66 newTab[j + oldCap] = hiHead; 67 } 68 } 69 } 70 } 71 } 72 return newTab; 73 }
因為有這樣一個特點:比如hash表的長度是16,那么15對應二進制是:
0000 0000, 0000 0000, 0000 0000, 0000 1111 = 15
擴容之前有兩個key,分別是k1和k2:
k1的hash:
0000 0000, 0000 0000, 0000 0000, 0000 1111 = 15
k2的hash:
0000 0000, 0000 0000, 0000 0000, 0001 1111 = 15
hash值和15模得到:
k1:0000 0000, 0000 0000, 0000 0000, 0000 1111 = 15
k2:0000 0000, 0000 0000, 0000 0000, 0000 1111 = 15
擴容之后表長對應為32,則31二進制:
0000 0000, 0000 0000, 0000 0000, 0001 1111 = 31
重新hash之后得到:
k1:0000 0000, 0000 0000, 0000 0000, 0000 1111 = 15
k2:0000 0000, 0000 0000, 0000 0000, 0001 1111 = 31 = 15 + 16
觀察發現:如果擴容后新增的位是0,那么rehash索引不變,否則才會改變,並且變為原來的索引+舊hash表的長度,故我們只需看原hash表長新增的bit是1還是0,如果是0,索引不變,如果是1,索引變成原索引+舊表長,根本不用像JDK 7 那樣rehash,省去了重新計算hash值的時間,而且新增的bit是0還是1可以認為是隨機的,因此resize的過程,還能均勻的把之前的沖突節點分散。
故JDK 8對HashMap的優化是非常到位的。
如下是之前整理的舊hash的實現機制和原理,並和jdk古老的hashtable做了比較。
2016-12-12 整理jdk 1.8之前的HashMap實現:
- Java集合概述
- HashMap介紹
- HashMap源碼學習
- 關於HashMap的幾個經典問題
- HashTable介紹和源碼學習
- HashMap 和 HashTable 比較
先上圖
Set和List接口是Collection接口的子接口,分別代表無序集合和有序集合,Queue是Java提供的隊列實現。
Map用於保存具有key-value映射關系的數據
Java 中有四種常見的Map實現——HashMap, TreeMap, Hashtable和LinkedHashMap:
- HashMap就是一張hash表,鍵和值都沒有排序。
- TreeMap以紅黑樹結構為基礎,鍵值可以設置按某種順序排列。
- LinkedHashMap保存了插入時的順序。
- Hashtable是同步的(而HashMap是不同步的)。所以如果在線程安全的環境下應該多使用HashMap,而不是Hashtable,因為Hashtable對同步有額外的開銷,不過JDK 5之后的版本可以使用conncurrentHashMao代替HashTable。
本文重點總結HashMap,HashMap是基於哈希表實現的,每一個元素是一個key-value對,其內部通過單鏈表解決沖突問題,容量不足(超過了閥值)時,同樣會自動增長。
HashMap是非線程安全的,只用於單線程環境下,多線程環境下可以采用concurrent並發包下的concurrentHashMap。
HashMap 實現了Serializable接口,因此它支持序列化。
HashMap還實現了Cloneable接口,故能被克隆。
關於hashmap的用法,這里就不再贅述了,只說原理和一些注意點。
HashMap的存儲結構
紫色部分即代表哈希表本身(其實是一個數組),數組的每個元素都是一個單鏈表的頭節點,鏈表是用來解決hash地址沖突的,如果不同的key映射到了數組的同一位置處,就將其放入單鏈表中保存。
HashMap有四個構造方法,方法中有兩個很重要的參數:初始容量和加載因子
這兩個參數是影響HashMap性能的重要參數,其中容量表示哈希表中槽的數量(即哈希數組的長度),初始容量是創建哈希表時的容量(默認為16),加載因子是哈希表當前key的數量和容量的比值,當哈希表中的條目數超出了加載因子與當前容量的乘積時,則要對該哈希表提前進行 resize 操作(即擴容)。如果加載因子越大,對空間的利用更充分,但是查找效率會降低(鏈表長度會越來越長);如果加載因子太小,那么表中的數據將過於稀疏(很多空間還沒用,就開始擴容了),嚴重浪費。
JDK開發者規定的默認加載因子為0.75,因為這是一個比較理想的值。另外,無論指定初始容量為多少,構造方法都會將實際容量設為不小於指定容量的2的冪次方,且最大值不能超過2的30次方。
重點分析HashMap中用的最多的兩個方法put和get的源碼

1 // 獲取key對應的value 2 public V get(Object key) { 3 if (key == null) 4 return getForNullKey(); 5 // 獲取key的hash值 6 int hash = hash(key.hashCode()); 7 // 在“該hash值對應的鏈表”上查找“鍵值等於key”的元素 8 for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) { 9 Object k; 10 // 判斷key是否相同 11 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) 12 return e.value; 13 } 14 // 沒找到則返回null 15 return null; 16 } 17 18 // 獲取“key為null”的元素的值,HashMap將“key為null”的元素存儲在table[0]位置,但不一定是該鏈表的第一個位置! 20 private V getForNullKey() { 21 for (Entry<K, V> e = table[0]; e != null; e = e.next) { 22 if (e.key == null) 23 return e.value; 24 } 25 return null; 26 }
首先,如果key為null,則直接從哈希表的第一個位置table[0]對應的鏈表上查找。記住,key為null的鍵值對永遠都放在以table[0]為頭結點的鏈表中,當然不一定是存放在頭結點table[0]中。如果key不為null,則先求的key的hash值,根據hash值找到在table中的索引,在該索引對應的單鏈表中查找是否有鍵值對的key與目標key相等,有就返回對應的value,沒有則返回null。

1 // 將“key-value”添加到HashMap中 2 public V put(K key, V value) { 3 // 若“key為null”,則將該鍵值對添加到table[0]中。 4 if (key == null) 5 return putForNullKey(value); 6 // 若“key不為null”,則計算該key的哈希值,然后將其添加到該哈希值對應的鏈表中。 7 int hash = hash(key.hashCode()); 8 int i = indexFor(hash, table.length); 9 for (Entry<K, V> e = table[i]; e != null; e = e.next) { 10 Object k; 11 // 若“該key”對應的鍵值對已經存在,則用新的value取代舊的value。然后退出! 12 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { 13 V oldValue = e.value; 14 e.value = value; 15 e.recordAccess(this); 16 return oldValue; 17 } 18 } 19 20 // 若“該key”對應的鍵值對不存在,則將“key-value”添加到table中 21 modCount++; 22 // 將key-value添加到table[i]處 23 addEntry(hash, key, value, i); 24 return null; 25 }
如果key為null,則將其添加到table[0]對應的鏈表中,如果key不為null,則同樣先求出key的hash值,根據hash值得出在table中的索引,而后遍歷對應的單鏈表,如果單鏈表中存在與目標key相等的鍵值對,則將新的value覆蓋舊的value,且將舊的value返回,如果找不到與目標key相等的鍵值對,或者該單鏈表為空,則將該鍵值對插入到單鏈表的頭結點位置(每次新插入的節點都是放在頭結點的位置),該操作是有addEntry方法實現的,它的源碼如下:

// 新增Entry。將“key-value”插入指定位置,bucketIndex是位置索引。 void addEntry(int hash, K key, V value, int bucketIndex) { // 保存“bucketIndex”位置的值到“e”中 Entry<K, V> e = table[bucketIndex]; // 設置“bucketIndex”位置的元素為“新Entry”, // 設置“e”為“新Entry的下一個節點” table[bucketIndex] = new Entry<K, V>(hash, key, value, e); // 若HashMap的實際大小 不小於 “閾值”,則調整HashMap的大小 if (size++ >= threshold) resize(2 * table.length); }
注意這里倒數第三行的構造方法,將key-value鍵值對賦給table[bucketIndex],並將其next指向元素e,這便將key-value放到了頭結點中,並將之前的頭結點接在了它的后面。該方法也說明,每次put鍵值對的時候,總是將新的該鍵值對放在table[bucketIndex]處(即頭結點處)。兩外注意最后兩行代碼,每次加入鍵值對時,都要判斷當前已用的槽的數目是否大於等於閥值(容量*加載因子),如果大於等於,則進行擴容,將容量擴為原來容量的2倍。
重點來分析下求hash值和索引值的方法,這兩個方法便是HashMap設計的最為核心的部分,二者結合能保證哈希表中的元素盡可能均勻地散列。
由hash值找到對應索引的方法如下
static int indexFor(int h, int length) { return h & (length-1); }
因為容量初始還是設定都會轉化為2的冪次。故可以使用高效的位與運算替代模運算。下面會解釋原因。
計算hash值的方法如下
static int hash(int h) { h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
JDK 的 HashMap 使用了一個 hash 方法對hash值使用位的操作,使hash值的計算效率很高。為什么這樣做?主要是因為如果直接使用hashcode值,那么這是一個int值(8個16進制數,共32位),int值的范圍正負21億多,但是hash表沒有那么長,一般比如初始16,自然散列地址需要對hash表長度取模運算,得到的余數才是地址下標。假設某個key的hashcode是0AAA0000,hash數組長默認16,如果不經過hash函數處理,該鍵值對會被存放在hash數組中下標為0處,因為0AAA0000 & (16-1) = 0。過了一會兒又存儲另外一個鍵值對,其key的hashcode是0BBB0000,得到數組下標依然是0,這就說明這是個實現得很差的hash算法,因為hashcode的1位全集中在前16位了,導致算出來的數組下標一直是0。於是明明key相差很大的鍵值對,卻存放在了同一個鏈表里,導致以后查詢起來比較慢(蛻化為了順序查找)。故JDK的設計者使用hash函數的若干次的移位、異或操作,把hashcode的“1位”變得“松散”,非常巧妙。
下面是幾個常見的面試題
說下hashmap的 擴容機制?
前面說了,hashmap的構造器里指明了兩個對於理解HashMap比較重要的兩個參數 int initialCapacity, float loadFactor,這兩個參數會影響HashMap效率,HashMap底層采用的散列數組實現,利用initialCapacity這個參數我們可以設置這個數組的大小,也就是散列桶的數量,但是如果需要Map的數據過多,在不斷的add之后,這些桶可能都會被占滿,這是有兩種策略,一種是不改變Capacity,因為即使桶占滿了,我們還是可以利用每個桶附帶的鏈表增加元素。但是這有個缺點,此時HaspMap就退化成為了LinkedList,使get和put方法的時間開銷上升,這是就要采用另一種方法:增加Hash桶的數量,這樣get和put的時間開銷又回退到近於常數復雜度上。Hashmap就是采用的該方法。
關於擴容。看hashmap的擴容方法,resize方法,它的源碼如下

1 // 重新調整HashMap的大小,newCapacity是調整后的單位 2 void resize(int newCapacity) { 3 Entry[] oldTable = table; 4 int oldCapacity = oldTable.length; 5 if (oldCapacity == MAXIMUM_CAPACITY) { 6 threshold = Integer.MAX_VALUE; 7 return; 8 } 9 10 // 新建一個HashMap,將“舊HashMap”的全部元素添加到“新HashMap”中, 11 // 然后,將“新HashMap”賦值給“舊HashMap”。 12 Entry[] newTable = new Entry[newCapacity]; 13 transfer(newTable); 14 table = newTable; 15 threshold = (int) (newCapacity * loadFactor); 16 }
很明顯,是從新建了一個HashMap的底層數組,長度為原來的兩倍,而后調用transfer方法,將舊HashMap的全部元素添加到新的HashMap中(要重新計算元素在新的數組中的索引位置)。transfer方法的源碼如下:

1 // 將HashMap中的全部元素都添加到newTable中 2 void transfer(Entry[] newTable) { 3 Entry[] src = table; 4 int newCapacity = newTable.length; 5 for (int j = 0; j < src.length; j++) { 6 Entry<K, V> e = src[j]; 7 if (e != null) { 8 src[j] = null; 9 do { 10 Entry<K, V> next = e.next; 11 int i = indexFor(e.hash, newCapacity); 12 e.next = newTable[i]; 13 newTable[i] = e; 14 e = next; 15 } while (e != null); 16 } 17 } 18 }
很明顯,擴容是一個相當耗時的操作,因為它需要重新計算這些元素在新的數組中的位置並進行復制處理。因此,我們在用HashMap時,最好能提前預估下HashMap中元素的個數,這樣有助於提高HashMap的性能。
hashmap什么時候需要增加容量呢?
因為效率問題,JDK采用預處理法,這時前面說的loadFactor就派上了用場,當size > initialCapacity * loadFactor,hashmap內部resize方法就被調用,使得重新擴充hash桶的數量,在目前的實現中,是增加一倍,這樣就保證當你真正想put新的元素時效率不會明顯下降。所以一般情況下HashMap並不存在鍵值放滿的情況。當然並不排除極端情況,比如設置的JVM內存用完了,或者這個HashMap的Capacity已經達到了MAXIMUM_CAPACITY(目前的實現是2^30)。
initialCapacity和loadFactor參數設什么樣的值好呢?
initialCapacity的默認值是16,有些人可能會想如果內存足夠,是不是可以將initialCapacity設大一些,即使用不了這么大,就可避免擴容導致的效率的下降,反正無論initialCapacity大小,我們使用的get和put方法都是常數復雜度的。這么說沒什么不對,但是可能會忽略一點,實際的程序可能不僅僅使用get和put方法,也有可能使用迭代器,如initialCapacity容量較大,那么會使迭代器效率降低。所以理想的情況還是在使用HashMap前估計一下數據量。
加載因子默認值是0.75,是JDK權衡時間和空間效率之后得到的一個相對優良的數值。如果這個值過大,雖然空間利用率是高了,但是對於HashMap中的一些方法的效率就下降了,包括get和put方法,會導致每個hash桶所附加的鏈表增長,影響存取效率。如果比較小,除了導致空間利用率較低外沒有什么壞處,只要有的是內存,畢竟現在大多數人把時間看的比空間重要。但是實際中還是很少有人會將這個值設置的低於0.5。
HashMap的key和value都能為null么?如果k能為null,那么它是怎么樣查找值的?
如果key為null,則直接從哈希表的第一個位置table[0]對應的鏈表上查找。記住,key為null的鍵值對永遠都放在以table[0]為頭結點的鏈表中。
HashMap中put值的時候如果發生了沖突,是怎么處理的?
JDK使用了鏈地址法,hash表的每個元素又分別鏈接着一個單鏈表,元素為頭結點,如果不同的key映射到了相同的下標,那么就使用頭插法,插入到該元素對應的鏈表。
HashMap的key是如何散列到hash表的?相比較HashTable有什么改進?
我們一般對哈希表的散列很自然地會想到用hash值對length取模(即除留余數法),HashTable就是這樣實現的,這種方法基本能保證元素在哈希表中散列的比較均勻,但取模會用到除法運算,效率很低,且hashtable直接使用了hashcode值,沒有重新計算。
HashMap中則通過 h&(length-1) 的方法來代替取模,其中h是key的hash值,同樣實現了均勻的散列,但效率要高很多,這也是HashMap對Hashtable的一個改進。
接下來,我們分析下為什么哈希表的容量一定要是2的整數次冪。
首先,length為2的整數次冪的話,h&(length-1) 在數學上就相當於對length取模,這樣便保證了散列的均勻,同時也提升了效率;
其次,length為2的整數次冪的話,則一定為偶數,那么 length-1 一定為奇數,奇數的二進制的最后一位是1,這樣便保證了 h&(length-1) 的最后一位可能為0,也可能為1(這取決於h的值),即與后的結果可能為偶數,也可能為奇數,這樣便可以保證散列的均勻,而如果length為奇數的話,很明顯 length-1 為偶數,它的最后一位是0,這樣 h&(length-1) 的最后一位肯定為0,即只能為偶數,這樣導致了任何hash值都只會被散列到數組的偶數下標位置上,浪費了一半的空間,因此length取2的整數次冪,是為了使不同hash值發生碰撞的概率較小,這樣就能使元素在哈希表中均勻地散列。
作為對比,在討論一下Hashtable
HashTable同樣是基於哈希表實現的,其實類似HashMap,只不過有些區別,HashTable同樣每個元素是一個key-value對,其內部也是通過單鏈表解決沖突問題,容量不足(超過了閥值)時,同樣會自動增長。
HashTable比較古老, 是JDK1.0就引入的類,而HashMap 是 1.2 引進的 Map 的一個實現。
HashTable 是線程安全的,能用於多線程環境中。Hashtable同樣也實現了Serializable接口,支持序列化,也實現了Cloneable接口,能被克隆。
Hashtable繼承於Dictionary類,實現了Map接口。Dictionary是聲明了操作"鍵值對"函數接口的抽象類。 有一點注意,HashTable除了線程安全之外(其實是直接在方法上增加了synchronized關鍵字,比較古老,落后,低效的同步方式),還有就是它的key、value都不為null。另外Hashtable 也有 初始容量 和 加載因子。
public Hashtable() { this(11, 0.75f); }
默認加載因子也是 0.75,HashTable在不指定容量的情況下的默認容量為11,而HashMap為16,Hashtable不要求底層數組的容量一定要為2的整數次冪,而HashMap則要求一定為2的整數次冪。因為HashTable是直接使用除留余數法定位地址。且Hashtable計算hash值,直接用key的hashCode()。
還要注意:前面說了Hashtable中key和value都不允許為null,而HashMap中key和value都允許為null(key只能有一個為null,而value則可以有多個為null)。但如在Hashtable中有類似put(null,null)的操作,編譯同樣可以通過,因為key和value都是Object類型,但運行時會拋出NullPointerException異常,這是JDK的規范規定的。
最后針對擴容:Hashtable擴容時,將容量變為原來的2倍加1,而HashMap擴容時,將容量變為原來的2倍。
下面是幾個常見的筆試,面試題
HashTable和HashMap的區別有哪些?
HashMap和Hashtable都實現了Map接口,但決定用哪一個之前先要弄清楚它們之間的分別。主要的區別有:線程安全性,同步(synchronization),以及速度。
理解HashMap是Hashtable的輕量級實現(非線程安全的實現,hashtable是非輕量級,線程安全的),都實現Map接口,主要區別在於:
1、由於HashMap非線程安全,在只有一個線程訪問的情況下,效率要高於HashTable
2、HashMap允許將null作為一個entry的key或者value,而Hashtable不允許。
3、HashMap把Hashtable的contains方法去掉了,改成containsValue和containsKey。因為contains方法容易讓人引起誤解。
4、Hashtable繼承自陳舊的Dictionary類,而HashMap是Java1.2引進的Map 的一個實現。
5、Hashtable和HashMap擴容的方法不一樣,HashTable中hash數組默認大小11,擴容方式是 old*2+1。HashMap中hash數組的默認大小是16,而且一定是2的指數,增加為原來的2倍,沒有加1。
6、兩者通過hash值散列到hash表的算法不一樣,HashTbale是古老的除留余數法,直接使用hashcode,而后者是強制容量為2的冪,重新根據hashcode計算hash值,在使用hash 位與 (hash表長度 – 1),也等價取膜,但更加高效,取得的位置更加分散,偶數,奇數保證了都會分散到。前者就不能保證。
7、另一個區別是HashMap的迭代器(Iterator)是fail-fast迭代器,而Hashtable的enumerator迭代器不是fail-fast的。所以當有其它線程改變了HashMap的結構(增加或者移除元素),將會拋出ConcurrentModificationException,但迭代器本身的remove()方法移除元素則不會拋出ConcurrentModificationException異常。但這並不是一個一定發生的行為,要看JVM。這條同樣也是Enumeration和Iterator的區別。
- fail-fast和iterator迭代器相關。如果某個集合對象創建了Iterator或者ListIterator,然后其它的線程試圖“結構上”更改集合對象,將會拋出ConcurrentModificationException異常。但其它線程可以通過set()方法更改集合對象是允許的,因為這並沒有從“結構上”更改集合。但是假如已經從結構上進行了更改,再調用set()方法,將會拋出IllegalArgumentException異常。
- 結構上的更改指的是刪除或者插入一個元素,這樣會影響到map的結構。
- 該條說白了就是在使用迭代器的過程中有其他線程在結構上修改了map,那么將拋出ConcurrentModificationException,這就是所謂fail-fast策略。
為什么HashMap是線程不安全的,實際會如何體現?
第一,如果多個線程同時使用put方法添加元素
假設正好存在兩個put的key發生了碰撞(hash值一樣),那么根據HashMap的實現,這兩個key會添加到數組的同一個位置,這樣最終就會發生其中一個線程的put的數據被覆蓋。
第二,如果多個線程同時檢測到元素個數超過數組大小*loadFactor
這樣會發生多個線程同時對hash數組進行擴容,都在重新計算元素位置以及復制數據,但是最終只有一個線程擴容后的數組會賦給table,也就是說其他線程的都會丟失,並且各自線程put的數據也丟失。且會引起死循環的錯誤。
具體細節上的原因,可以參考:不正當使用HashMap導致cpu 100%的問題追究
能否讓HashMap實現線程安全,如何做?
1、直接使用Hashtable,但是當一個線程訪問HashTable的同步方法時,其他線程如果也要訪問同步方法,會被阻塞住。舉個例子,當一個線程使用put方法時,另一個線程不但不可以使用put方法,連get方法都不可以,效率很低,現在基本不會選擇它了。
2、HashMap可以通過下面的語句進行同步:
Collections.synchronizeMap(hashMap);
3、直接使用JDK 5 之后的 ConcurrentHashMap,如果使用Java 5或以上的話,請使用ConcurrentHashMap。
Collections.synchronizeMap(hashMap);又是如何保證了HashMap線程安全?
直接分析源碼吧

1 // synchronizedMap方法 2 public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) { 3 return new SynchronizedMap<>(m); 4 } 5 // SynchronizedMap類 6 private static class SynchronizedMap<K,V> 7 implements Map<K,V>, Serializable { 8 private static final long serialVersionUID = 1978198479659022715L; 9 10 private final Map<K,V> m; // Backing Map 11 final Object mutex; // Object on which to synchronize 12 13 SynchronizedMap(Map<K,V> m) { 14 this.m = Objects.requireNonNull(m); 15 mutex = this; 16 } 17 18 SynchronizedMap(Map<K,V> m, Object mutex) { 19 this.m = m; 20 this.mutex = mutex; 21 } 22 23 public int size() { 24 synchronized (mutex) {return m.size();} 25 } 26 public boolean isEmpty() { 27 synchronized (mutex) {return m.isEmpty();} 28 } 29 public boolean containsKey(Object key) { 30 synchronized (mutex) {return m.containsKey(key);} 31 } 32 public boolean containsValue(Object value) { 33 synchronized (mutex) {return m.containsValue(value);} 34 } 35 public V get(Object key) { 36 synchronized (mutex) {return m.get(key);} 37 } 38 39 public V put(K key, V value) { 40 synchronized (mutex) {return m.put(key, value);} 41 } 42 public V remove(Object key) { 43 synchronized (mutex) {return m.remove(key);} 44 } 45 // 省略其他方法 46 }
從源碼中看出 synchronizedMap()方法返回一個SynchronizedMap類的對象,而在SynchronizedMap類中使用了synchronized來保證對Map的操作是線程安全的,故效率其實也不高。
為什么HashTable的默認大小和HashMap不一樣?
前面分析了,Hashtable 的擴容方法是乘2再+1,不是簡單的乘2,故hashtable保證了容量永遠是奇數,結合之前分析hashmap的重算hash值的邏輯,就明白了,因為在數據分布在等差數據集合(如偶數)上時,如果公差與桶容量有公約數 n,則至少有(n-1)/n 數量的桶是利用不到的,故之前的hashmap 會在取模(使用位與運算代替)哈希前先做一次哈希運算,調整hash值。這里hashtable比較古老,直接使用了除留余數法,那么就需要設置容量起碼不是偶數(除(近似)質數求余的分散效果好)。而JDK開發者選了11。
JDK 8對HashMap有了什么改進?說說你對紅黑樹的理解?
參考更新的jdk 8對hashmap的的改進部分整理,並且還能引申出高級數據結構——紅黑樹,這又能引出很多問題……學無止境啊!
臨時小結:感覺針對Java的hashmap和hashtable面試,或者理解,到這里就可以了,具體就是多寫代碼實踐。
歡迎關注
dashuai的博客是終身學習踐行者,大廠程序員,且專注於工作經驗、學習筆記的分享和日常吐槽,包括但不限於互聯網行業,附帶分享一些PDF電子書,資料,幫忙內推,歡迎拍磚!