考點:
1.hashing的概念
2.HashMap中解決碰撞的方法
3.equals()和hashCode()的應用,以及它們在HashMap中的重要性
4.不可變對象的好處
5.HashMap多線程的條件競爭
6.重新調整HashMap的大小
常見面試問題:
1.“你知道HashMap的工作原理嗎?” “你知道HashMap的get()方法的工作原理嗎?”
HashMap基於hashing原理,我們通過put()和get()方法儲存和獲取對象。當我們將鍵值對傳遞給put()方法時,它調用鍵對象的hashCode()方法來計算hashcode,讓后找到bucket位置來儲存Entry對象。當獲取對象時,通過鍵對象的equals()方法找到正確的鍵值對,然后返回值對象。HashMap使用鏈表來解決碰撞問題,當發生碰撞了,對象將會儲存在鏈表的下一個節點中。 HashMap在每個鏈表節點中儲存鍵值對對象。
這里關鍵點在於指出,HashMap是在bucket中儲存鍵對象和值對象,作為Map.Entry。這一點有助於理解獲取對象的邏輯。
2.我們能否讓HashMap同步?
HashMap可以通過下面的語句進行同步:
Map m = Collections.synchronizeMap(hashMap);
3.什么是HashSet?
HashSet實現了Set接口,它不允許集合中有重復的值,當我們提到HashSet時,第一件事情就是在將對象存儲在HashSet之前,要先確保對象重寫equals()和hashCode()方法,這樣才能比較對象的值是否相等,以確保set中沒有儲存相等的對象。如果我們沒有重寫這兩個方法,將會使用這個方法的默認實現。
public boolean add(Object o)方法用來在Set中添加元素,當元素值重復時則會立即返回false,如果成功添加的話會返回true。
4.“你用過HashMap嗎?” “什么是HashMap?你為什么用到它?”
HashMap實現了Map接口,Map接口對鍵值對進行映射。Map中不允許重復的鍵。Map接口有兩個基本的實現,HashMap和TreeMap。TreeMap保存了對象的排列次序,而HashMap則不能。HashMap存儲的是鍵值對,允許鍵和值為null。HashMap是非synchronized的,但collection框架提供方法能保證HashMap synchronized,這樣多個線程同時訪問HashMap時,能保證只有一個線程更改Map。
public Object put(Object Key,Object value)方法用來將元素添加到map中。
5.HashSet與HashMap的區別?
6.關於HashMap中的碰撞探測(collision detection)以及碰撞的解決方法?
當兩個對象的hashcode相同會發生什么?
因為hashcode相同,所以它們的bucket位置相同,‘碰撞’會發生。因為HashMap使用鏈表存儲對象,這個Entry(包含有鍵值對的Map.Entry對象)會存儲在鏈表中。
7.如果兩個鍵的hashcode相同,你如何獲取值對象?
當我們調用get()方法,HashMap會使用鍵對象的hashcode找到bucket位置,找到bucket位置之后,會調用keys.equals()方法去找到鏈表中正確的節點,最終找到要找的值對象。
注意:面試官會問因為你並沒有值對象去比較,你是如何確定確定找到值對象的?除非面試者直到HashMap在鏈表中存儲的是鍵值對,否則他們不可能回答出這一題。
一些優秀的開發者會指出使用不可變的、聲明作final的對象,並且采用合適的equals()和hashCode()方法的話,將會減少碰撞的發生,提高效率。不可變性使得能夠緩存不同鍵的hashcode,這將提高整個獲取對象的速度,使用String,Interger這樣的wrapper類作為鍵是非常好的選擇。
8.如果HashMap的大小超過了負載因子(load factor)定義的容量,怎么辦?
除非你真正知道HashMap的工作原理,否則你將回答不出這道題。默認的負載因子大小為0.75,也就是說,當一個map填滿了75%的bucket時候,和其它集合類(如ArrayList等)一樣,將會創建原來HashMap大小的兩倍的bucket數組,來重新調整map的大小,並將原來的對象放入新的bucket數組中。這個過程叫作rehashing,因為它調用hash方法找到新的bucket位置。
9.你了解重新調整HashMap大小存在什么問題嗎?
當多線程的情況下,可能產生條件競爭。
當重新調整HashMap大小的時候,確實存在條件競爭,因為如果兩個線程都發現HashMap需要重新調整大小了,它們會同時試着調整大小。在調整大小的過程中,存儲在鏈表中的元素的次序會反過來,因為移動到新的bucket位置的時候,HashMap並不會將元素放在鏈表的尾部,而是放在頭部,這是為了避免尾部遍歷(tail traversing)。如果條件競爭發生了,那么就死循環了。這個時候,你可以質問面試官,為什么這么奇怪,要在多線程的環境下使用HashMap呢?:)-------未理解
注意,jdk1.8閾值是8,面試時被問到過!
10.為什么String, Interger這樣的wrapper類適合作為鍵?
String, Interger這樣的wrapper類作為HashMap的鍵是再適合不過了,而且String最為常用。因為String是不可變的,也是final的,而且已經重寫了equals()和hashCode()方法了。其他的wrapper類也有這個特點。不可變性是必要的,因為為了要計算hashCode(),就要防止鍵值改變,如果鍵值在放入時和獲取時返回不同的hashcode的話,那么就不能從HashMap中找到你想要的對象。不可變性還有其他的優點如線程安全。如果你可以僅僅通過將某個field聲明成final就能保證hashCode是不變的,那么請這么做吧。因為獲取對象的時候要用到equals()和hashCode()方法,那么鍵對象正確的重寫這兩個方法是非常重要的。如果兩個不相等的對象返回不同的hashcode的話,那么碰撞的幾率就會小些,這樣就能提高HashMap的性能。
11.我們可以使用自定義的對象作為鍵嗎?
這是前一個問題的延伸。當然你可能使用任何對象作為鍵,只要它遵守了equals()和hashCode()方法的定義規則,並且當對象插入到Map中之后將不會再改變了。如果這個自定義對象時不可變的,那么它已經滿足了作為鍵的條件,因為當它創建之后就已經不能改變了。
12.我們可以使用CocurrentHashMap來代替Hashtable嗎?
這是另外一個很熱門的面試題,因為ConcurrentHashMap越來越多人用了。我們知道Hashtable是synchronized的,但是ConcurrentHashMap同步性能更好,因為它僅僅根據同步級別對map的一部分進行上鎖。ConcurrentHashMap當然可以代替HashTable,但是HashTable提供更強的線程安全性。
13.hashmap的存儲過程?
HashMap內部維護了一個存儲數據的Entry數組,HashMap采用鏈表解決沖突,每一個Entry本質上是一個單向鏈表。當准備添加一個key-value對時,首先通過hash(key)方法計算hash值,然后通過indexFor(hash,length)求該key-value對的存儲位置,計算方法是先用hash&0x7FFFFFFF后,再對length取模,這就保證每一個key-value對都能存入HashMap中,當計算出的位置相同時,由於存入位置是一個鏈表,則把這個key-value對插入鏈表頭。
HashMap中key和value都允許為null。key為null的鍵值對永遠都放在以table[0]為頭結點的鏈表中。
14.hashMap擴容問題?
擴容是是新建了一個HashMap的底層數組,而后調用transfer方法,將就HashMap的全部元素添加到新的HashMap中(要重新計算元素在新的數組中的索引位置)。 很明顯,擴容是一個相當耗時的操作,因為它需要重新計算這些元素在新的數組中的位置並進行復制處理。因此,我們在用HashMap的時,最好能提前預估下HashMap中元素的個數,這樣有助於提高HashMap的性能。
HashMap共有四個構造方法。構造方法中提到了兩個很重要的參數:初始容量和加載因子。這兩個參數是影響HashMap性能的重要參數,其中容量表示哈希表中槽的數量(即哈希數組的長度),初始容量是創建哈希表時的容量(從構造函數中可以看出,如果不指明,則默認為16),加載因子是哈希表在其容量自動增加之前可以達到多滿的一種尺度,當哈希表中的條目數超出了加載因子與當前容量的乘積時,則要對該哈希表進行 resize 操作(即擴容)。
默認加載因子為0.75,如果加載因子越大,對空間的利用更充分,但是查找效率會降低(鏈表長度會越來越長);如果加載因子太小,那么表中的數據將過於稀疏(很多空間還沒用,就開始擴容了),對空間造成嚴重浪費。如果我們在構造方法中不指定,則系統默認加載因子為0.75,這是一個比較理想的值,一般情況下我們是無需修改的。
15.什么是hash,什么是碰撞,什么是equals ?
Hash:是一種信息摘要算法,它還叫做哈希,或者散列。我們平時使用的MD5,SHA1都屬於Hash算法,通過輸入key進行Hash計算,就可以獲取key的HashCode(),比如我們通過校驗MD5來驗證文件的完整性。
對於HashCode,它是一個本地方法,實質就是地址取樣運算
碰撞:好的Hash算法可以出計算幾乎出獨一無二的HashCode,如果出現了重復的hashCode,就稱作碰撞;
就算是MD5這樣優秀的算法也會發生碰撞,即兩個不同的key也有可能生成相同的MD5。
HashCode,它是一個本地方法,實質就是地址取樣運算;
==是用於比較指針是否在同一個地址;
equals與==是相同的。
16.如何減少碰撞?
使用不可變的、聲明作final的對象,並且采用合適的equals()和hashCode()方法的話,將會減少碰撞的發生,提高效率。不可變性使得能夠緩存不同鍵的hashcode,這將提高整個獲取對象的速度,使用String,Interger這樣的wrapper類作為鍵是非常好的選擇
17.HashMap的復雜度
HashMap整體上性能都非常不錯,但是不穩定,為O(N/Buckets),N就是以數組中沒有發生碰撞的元素。
18.為什么HashMap是線程不安全的,實際會如何體現?
第一,如果多個線程同時使用put方法添加元素
假設正好存在兩個put的key發生了碰撞(hash值一樣),那么根據HashMap的實現,這兩個key會添加到數組的同一個位置,這樣最終就會發生其中一個線程的put的數據被覆蓋。
第二,如果多個線程同時檢測到元素個數超過數組大小*loadFactor
這樣會發生多個線程同時對hash數組進行擴容,都在重新計算元素位置以及復制數據,但是最終只有一個線程擴容后的數組會賦給table,也就是說其他線程的都會丟失,並且各自線程put的數據也丟失。且會引起死循環的錯誤。
19.能否讓HashMap實現線程安全,如何做?
1、直接使用Hashtable,但是當一個線程訪問HashTable的同步方法時,其他線程如果也要訪問同步方法,會被阻塞住。舉個例子,當一個線程使用put方法時,另一個線程不但不可以使用put方法,連get方法都不可以,效率很低,現在基本不會選擇它了。
2、HashMap可以通過下面的語句進行同步:
Collections.synchronizeMap(hashMap);
3、直接使用JDK 5 之后的 ConcurrentHashMap,如果使用Java 5或以上的話,請使用ConcurrentHashMap。
Collections.synchronizeMap(hashMap);又是如何保證了HashMap線程安全?
// synchronizedMap方法 public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) { return new SynchronizedMap<>(m); } // SynchronizedMap類 private static class SynchronizedMap<K,V> implements Map<K,V>, Serializable { private static final long serialVersionUID = 1978198479659022715L; private final Map<K,V> m; // Backing Map final Object mutex; // Object on which to synchronize SynchronizedMap(Map<K,V> m) { this.m = Objects.requireNonNull(m); mutex = this; } SynchronizedMap(Map<K,V> m, Object mutex) { this.m = m; this.mutex = mutex; } public int size() { synchronized (mutex) {return m.size();} } public boolean isEmpty() { synchronized (mutex) {return m.isEmpty();} } public boolean containsKey(Object key) { synchronized (mutex) {return m.containsKey(key);} } public boolean containsValue(Object value) { synchronized (mutex) {return m.containsValue(value);} } public V get(Object key) { synchronized (mutex) {return m.get(key);} } public V put(K key, V value) { synchronized (mutex) {return m.put(key, value);} } public V remove(Object key) { synchronized (mutex) {return m.remove(key);} } // 省略其他方法 }
從源碼中看出 synchronizedMap()方法返回一個SynchronizedMap類的對象,而在SynchronizedMap類中使用了synchronized來保證對Map的操作是線程安全的,故效率其實也不高。
19.為什么HashTable的默認大小和HashMap不一樣?
前面分析了,Hashtable 的擴容方法是乘2再+1,不是簡單的乘2,故hashtable保證了容量永遠是奇數,合之前分析hashmap的重算hash值的邏輯,就明白了,因為在數據分布在等差數據集合(如偶數)上時,如果公差與桶容量有公約數 n,則至少有(n-1)/n 數量的桶是利用不到的,故之前的hashmap 會在取模(使用位與運算代替)哈希前先做一次哈希運算,調整hash值。這里hashtable比較古老,直接使用了除留余數法,那么就需要設置容量起碼不是偶數(除(近似)質數求余的分散效果好)。
20.對key進行Hash計算
在JDK8中,由於使用了紅黑樹來處理大的鏈表開銷,所以hash這邊可以更加省力了,只用計算hashCode並移動到低位就可以了
static final int hash(Object key) {
int h;
//計算hashCode,並無符號移動到低位
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
21.幾個常用的哈希碼的算法
1:Object類的hashCode.返回對象的內存地址經過處理后的結構,由於每個對象的內存地址都不一樣,所以哈希碼也不一樣。
public int hashCode() { int lockWord = shadow$_monitor_; final int lockWordMask = 0xC0000000; // Top 2 bits. final int lockWordStateHash = 0x80000000; // Top 2 bits are value 2 (kStateHash). if ((lockWord & lockWordMask) == lockWordStateHash) { return lockWord & ~lockWordMask; } return System.identityHashCode(this); }
2:String類的hashCode.根據String類包含的字符串的內容,根據一種特殊算法返回哈希碼,只要字符串所在的堆空間相同,返回的哈希碼也相同。
@Override public int hashCode() { int hash = hashCode; if (hash == 0) { if (count == 0) { return 0; } final int end = count + offset; final char[] chars = value; for (int i = offset; i < end; ++i) { hash = 31*hash + chars[i]; } hashCode = hash; } return hash; }
3:Integer類,返回的哈希碼就是Integer對象里所包含的那個整數的數值,例如Integer i1=new Integer(100),i1.hashCode的值就是100 。由此可見,2個一樣大小的Integer對象,返回的哈希碼也一樣。
public int hashCode() { return value; }
int,char這樣的基本類型,它們不需要hashCode.
插入包裝類到數組
(1). 如果輸入當前的位置是空的,就插進去
(2). 如果當前位置已經有了node,且它們發生了碰撞,則新的放到前面,舊的放到后面,這叫做鏈地址法處理沖突。
失敗的hashCode算法會導致HashMap的性能下降為鏈表,所以想要避免發生碰撞,就要提高hashCode結果的均勻性。當然,在JDK8中,采用了紅黑二叉樹進行了處理,這個我們后面詳細介紹。
什么是Hash攻擊?
通過請求大量key不同,但是hashCode相同的數據,讓HashMap不斷發生碰撞,硬生生的變成了SingleLinkedList
0
|
1 -> a ->b -> c -> d(撞!撞!撞!復雜度由O(1)變成了O(N))
|
2 -> null(本應該均勻分布,這里卻是空的)
|
3 -> null
|
4 -> null
這樣put/get性能就從O(1)變成了O(N),CPU負載呈直線上升,形成了放大版DDOS的效果,這種方式就叫做hash攻擊。在Java8中通過使用TreeMap,提升了處理性能,可以一定程度的防御Hash攻擊。
擴容
(threshold = capacity * load factor ) < size
它主要有兩個步驟:
1. 容量加倍
左移N位,就是2^n,用位運算取代了乘法運算
newCap = oldCap << 1;
newThr = oldThr << 1;
2. 遍歷計算Hash
for (int j = 0; j < oldCap; ++j) { Node<K,V> e; //如果發現當前有Bucket if ((e = oldTab[j]) != null) { oldTab[j] = null; //如果這里沒有碰撞 if (e.next == null) //重新計算Hash,分配位置 newTab[e.hash & (newCap - 1)] = e; //這個見下面的新特性介紹,如果是樹,就填入樹 else if (e instanceof TreeNode) ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); //如果是鏈表,就保留順序....目前就看懂這點 else { // preserve order Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { next = e.next; if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } }
由此可以看出擴容需要遍歷並重新賦值,成本非常高,所以選擇一個好的初始容量非常重要。
如何提升性能?
解決擴容損失:如果知道大致需要的容量,把初始容量設置好以解決擴容損失;
比如我現在有1000個數據,需要 1000/0.75 = 1333 ,又 1024 < 1333 < 2048,所以最好使用2048作為初始容量。
2048=Collections.roundUpToPowerOfTwo(1333)
解決碰撞損失:使用高效的HashCode與loadFactor,這個...由於JDK8的高性能出現,這兒問題也不大了。
解決數據結構選擇的錯誤:在大型的數據與搜索中考慮使用別的結構比如TreeMap,這個就是積累了;
JDK8中HashMap的新特性
如果某個桶中的鏈表記錄過大的話(當前是TREEIFY_THRESHOLD = 8),就會把這個鏈動態變成紅黑二叉樹,使查詢最差復雜度由O(N)變成了O(logN)。