Java集合專題總結(1):HashMap 和 HashTable 源碼學習和面試總結


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);
    }
put方法

封裝了一個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     }
View Code

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     }
View Code

因為有這樣一個特點:比如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     }
get方法源碼

首先,如果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     }
put方法源碼

如果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);
    }
View Code

注意這里倒數第三行的構造方法,將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     }
擴容的resize方法

很明顯,是從新建了一個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     }
transfer方法源碼

很明顯,擴容是一個相當耗時的操作,因為它需要重新計算這些元素在新的數組中的位置並進行復制處理。因此,我們在用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    }
View Code

從源碼中看出 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電子書,資料,幫忙內推,歡迎拍磚!

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM