如果說Java的HashMap是數(shù)組+鏈表,那么JDK 8之后就是數(shù)組+鏈表+紅黑樹組成了HashMap。
在之前談過,如果hash算法不好,會使得hash表蛻化為順序查找,即使負(fù)載因子和hash算法優(yōu)化再多,也無法避免出現(xiàn)鏈表過長的情景(這個概論雖然很低),于是在JDK1.8中,對HashMap做了優(yōu)化,引入紅黑樹。具體原理就是當(dāng)hash表中每個桶附帶的鏈表長度默認(rèn)超過8時,鏈表就轉(zhuǎn)換為紅黑樹結(jié)構(gòu),提高HashMap的性能,因為紅黑樹的增刪改是O(logn),而不是O(n)。
紅黑樹的具體原理和實現(xiàn)以后再總結(jié)。
主要看put方法實現(xiàn)
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
封裝了一個final方法,里面用到一個常量,具體用處看源碼:
static final int TREEIFY_THRESHOLD = 8;
下面是具體源代碼注釋:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0) // 首先判斷hash表是否是空的,如果空,則resize擴(kuò)容
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null) // 通過key計算得到hash表下標(biāo),如果下標(biāo)處為null,就新建鏈表頭結(jié)點,在方法最后插入即可
tab[i] = newNode(hash, key, value, null);
else { // 如果下標(biāo)處已經(jīng)存在節(jié)點,則進(jìn)入到這里
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))) // 先看hash表該處的頭結(jié)點是否和key一樣(hashcode和equals比較),一樣就更新
e = p;
else if (p instanceof TreeNode) // hash表頭結(jié)點和key不一樣,則判斷節(jié)點是不是紅黑樹,是紅黑樹就按照紅黑樹處理
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else { // 如果不是紅黑樹,則按照之前的HashMap原理處理
for (int binCount = 0; ; ++binCount) { // 遍歷鏈表
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st (原jdk注釋) 顯然當(dāng)鏈表長度大于等于7的時候,也就是說大于8的話,就轉(zhuǎn)化為紅黑樹結(jié)構(gòu),針對紅黑樹進(jìn)行插入(logn復(fù)雜度)
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)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold) // 如果超過容量,即擴(kuò)容
resize();
afterNodeInsertion(evict);
return null;
}
resize是新的擴(kuò)容方法,之前談過,擴(kuò)容原理是使用新的(2倍舊長度)的數(shù)組代替,把舊數(shù)組的內(nèi)容放到新數(shù)組,需要重新計算hash和hash表的位置,非常耗時,但是自從 JDK 1.8 對HashMap引入了紅黑樹,它和之前的擴(kuò)容方法相比有了改進(jìn)。
擴(kuò)容方法的改進(jìn)
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY) // 如果長度沒有超過最大值,則擴(kuò)容為2倍的關(guān)系
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) { // 進(jìn)行新舊元素的轉(zhuǎn)移過程
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order(原注釋) 如果不是紅黑樹的情況這里改進(jìn)了,沒有rehash的過程,如下分別記錄鏈表的頭尾
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;
}
}
}
}
}
return newTab;
}
因為有這樣一個特點:比如hash表的長度是16,那么15對應(yīng)二進(jìn)制是:
0000 0000, 0000 0000, 0000 0000, 0000 1111 = 15
擴(kuò)容之前有兩個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
擴(kuò)容之后表長對應(yīng)為32,則31二進(jìn)制:
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
觀察發(fā)現(xiàn):如果擴(kuò)容后新增的位是0,那么rehash索引不變,否則才會改變,并且變?yōu)樵瓉淼乃饕?舊hash表的長度,故我們只需看原h(huán)ash表長新增的bit是1還是0,如果是0,索引不變,如果是1,索引變成原索引+舊表長,根本不用像JDK 7 那樣rehash,省去了重新計算hash值的時間,而且新增的bit是0還是1可以認(rèn)為是隨機(jī)的,因此resize的過程,還能均勻的把之前的沖突節(jié)點分散。
故JDK 8對HashMap的優(yōu)化是非常到位的。
如下是之前整理的舊hash的實現(xiàn)機(jī)制和原理,并和jdk古老的Hashtable做了比較。
整理jdk 1.8之前的HashMap實現(xiàn):
- Java集合概述
- HashMap介紹
- HashMap源碼學(xué)習(xí)
- 關(guān)于HashMap的幾個經(jīng)典問題
- Hashtable介紹和源碼學(xué)習(xí)
- HashMap 和 Hashtable比較
先上圖
Set和List接口是Collection接口的子接口,分別代表無序集合和有序集合,Queue是Java提供的隊列實現(xiàn)。
Map用于保存具有key-value映射關(guān)系的數(shù)據(jù)。
Java 中有四種常見的Map實現(xiàn)——HashMap,TreeMap,Hashtable和LinkedHashMap。
- HashMap就是一張hash表,鍵和值都沒有排序。
- TreeMap以紅黑樹結(jié)構(gòu)為基礎(chǔ),鍵值可以設(shè)置按某種順序排列。
- LinkedHashMap保存了插入時的順序。
- Hashtable是同步的(而HashMap是不同步的)。所以如果在線程安全的環(huán)境下應(yīng)該多使用HashMap,而不是Hashtable,因為Hashtable對同步有額外的開銷,不過JDK 5之后的版本可以使用conncurrentHashMap代替Hashtable。
本文重點總結(jié)HashMap,HashMap是基于哈希表實現(xiàn)的,每一個元素是一個key-value對,其內(nèi)部通過單鏈表解決沖突問題,容量不足(超過了閥值)時,同樣會自動增長。
HashMap是非線程安全的,只用于單線程環(huán)境下,多線程環(huán)境下可以采用concurrent并發(fā)包下的concurrentHashMap。
HashMap 實現(xiàn)了Serializable接口,因此它支持序列化。
HashMap還實現(xiàn)了Cloneable接口,故能被克隆。
關(guān)于HashMap的用法,這里就不再贅述了,只說原理和一些注意點。
HashMap的存儲結(jié)構(gòu)
紫色部分即代表哈希表本身(其實是一個數(shù)組),數(shù)組的每個元素都是一個單鏈表的頭節(jié)點,鏈表是用來解決hash地址沖突的,如果不同的key映射到了數(shù)組的同一位置處,就將其放入單鏈表中保存。
HashMap有四個構(gòu)造方法,方法中有兩個很重要的參數(shù):初始容量和加載因子
這兩個參數(shù)是影響HashMap性能的重要參數(shù),其中容量表示哈希表中槽的數(shù)量(即哈希數(shù)組的長度),初始容量是創(chuàng)建哈希表時的容量(默認(rèn)為16),加載因子是哈希表當(dāng)前key的數(shù)量和容量的比值,當(dāng)哈希表中的條目數(shù)超出了加載因子與當(dāng)前容量的乘積時,則要對該哈希表提前進(jìn)行 resize 操作(即擴(kuò)容)。如果加載因子越大,對空間的利用更充分,但是查找效率會降低(鏈表長度會越來越長);如果加載因子太小,那么表中的數(shù)據(jù)將過于稀疏(很多空間還沒用,就開始擴(kuò)容了),嚴(yán)重浪費。
JDK開發(fā)者規(guī)定的默認(rèn)加載因子為0.75,因為這是一個比較理想的值。另外,無論指定初始容量為多少,構(gòu)造方法都會將實際容量設(shè)為不小于指定容量的2的冪次方,且最大值不能超過2的30次方。
重點分析HashMap中用的最多的兩個方法put和get的源碼
// 獲取key對應(yīng)的value
public V get(Object key) {
if (key == null)
return getForNullKey();
// 獲取key的hash值
int hash = hash(key.hashCode());
// 在“該hash值對應(yīng)的鏈表”上查找“鍵值等于key”的元素
for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
Object k;
// 判斷key是否相同
if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
return e.value;
}
// 沒找到則返回null
return null;
}
// 獲取“key為null”的元素的值,HashMap將“key為null”的元素存儲在table[0]位置,但不一定是該鏈表的第一個位置!
private V getForNullKey() {
for (Entry<K, V> e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;
}
首先,如果key為null,則直接從哈希表的第一個位置table[0]對應(yīng)的鏈表上查找。記住,key為null的鍵值對永遠(yuǎn)都放在以table[0]為頭結(jié)點的鏈表中,當(dāng)然不一定是存放在頭結(jié)點table[0]中。如果key不為null,則先求的key的hash值,根據(jù)hash值找到在table中的索引,在該索引對應(yīng)的單鏈表中查找是否有鍵值對的key與目標(biāo)key相等,有就返回對應(yīng)的value,沒有則返回null。
// 將“key-value”添加到HashMap中
public V put(K key, V value) {
// 若“key為null”,則將該鍵值對添加到table[0]中。
if (key == null)
return putForNullKey(value);
// 若“key不為null”,則計算該key的哈希值,然后將其添加到該哈希值對應(yīng)的鏈表中。
int hash = hash(key.hashCode());
int i = indexFor(hash, table.length);
for (Entry<K, V> e = table[i]; e != null; e = e.next) {
Object k;
// 若“該key”對應(yīng)的鍵值對已經(jīng)存在,則用新的value取代舊的value。然后退出!
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
// 若“該key”對應(yīng)的鍵值對不存在,則將“key-value”添加到table中
modCount++;
// 將key-value添加到table[i]處
addEntry(hash, key, value, i);
return null;
}
如果key為null,則將其添加到table[0]對應(yīng)的鏈表中,如果key不為null,則同樣先求出key的hash值,根據(jù)hash值得出在table中的索引,而后遍歷對應(yīng)的單鏈表,如果單鏈表中存在與目標(biāo)key相等的鍵值對,則將新的value覆蓋舊的value,且將舊的value返回,如果找不到與目標(biāo)key相等的鍵值對,或者該單鏈表為空,則將該鍵值對插入到單鏈表的頭結(jié)點位置(每次新插入的節(jié)點都是放在頭結(jié)點的位置),該操作是有addEntry方法實現(xiàn)的,它的源碼如下:
// 新增Entry。將“key-value”插入指定位置,bucketIndex是位置索引。
void addEntry(int hash, K key, V value, int bucketIndex) {
// 保存“bucketIndex”位置的值到“e”中
Entry<K, V> e = table[bucketIndex];
// 設(shè)置“bucketIndex”位置的元素為“新Entry”,
// 設(shè)置“e”為“新Entry的下一個節(jié)點”
table[bucketIndex] = new Entry<K, V>(hash, key, value, e);
// 若HashMap的實際大小 不小于 “閾值”,則調(diào)整HashMap的大小
if (size++ >= threshold)
resize(2 * table.length);
}
注意這里倒數(shù)第三行的構(gòu)造方法,將key-value鍵值對賦給table[bucketIndex],并將其next指向元素e,這便將key-value放到了頭結(jié)點中,并將之前的頭結(jié)點接在了它的后面。該方法也說明,每次put鍵值對的時候,總是將新的該鍵值對放在table[bucketIndex]處(即頭結(jié)點處)。兩外注意最后兩行代碼,每次加入鍵值對時,都要判斷當(dāng)前已用的槽的數(shù)目是否大于等于閥值(容量*加載因子),如果大于等于,則進(jìn)行擴(kuò)容,將容量擴(kuò)為原來容量的2倍。
重點來分析下求hash值和索引值的方法,這兩個方法便是HashMap設(shè)計的最為核心的部分,二者結(jié)合能保證哈希表中的元素盡可能均勻地散列。
由hash值找到對應(yīng)索引的方法如下
static int indexFor(int h, int length) {
return h & (length-1);
}
因為容量初始還是設(shè)定都會轉(zhuǎn)化為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進(jìn)制數(shù),共32位),int值的范圍正負(fù)21億多,但是hash表沒有那么長,一般比如初始16,自然散列地址需要對hash表長度取模運算,得到的余數(shù)才是地址下標(biāo)。假設(shè)某個key的hashcode是0AAA0000,hash數(shù)組長默認(rèn)16,如果不經(jīng)過hash函數(shù)處理,該鍵值對會被存放在hash數(shù)組中下標(biāo)為0處,因為0AAA0000 & (16-1) = 0。過了一會兒又存儲另外一個鍵值對,其key的hashcode是0BBB0000,得到數(shù)組下標(biāo)依然是0,這就說明這是個實現(xiàn)得很差的hash算法,因為hashcode的1位全集中在前16位了,導(dǎo)致算出來的數(shù)組下標(biāo)一直是0。于是明明key相差很大的鍵值對,卻存放在了同一個鏈表里,導(dǎo)致以后查詢起來比較慢(蛻化為了順序查找)。故JDK的設(shè)計者使用hash函數(shù)的若干次的移位、異或操作,把hashcode的“1位”變得“松散”,非常巧妙。
下面是幾個常見的面試題
說下HashMap的 擴(kuò)容機(jī)制?
前面說了,hashmap的構(gòu)造器里指明了兩個對于理解HashMap比較重要的兩個參數(shù) int initialCapacity,float loadFactor,這兩個參數(shù)會影響HashMap效率,HashMap底層采用的散列數(shù)組實現(xiàn),利用initialCapacity這個參數(shù)我們可以設(shè)置這個數(shù)組的大小,也就是散列桶的數(shù)量,但是如果需要Map的數(shù)據(jù)過多,在不斷的add之后,這些桶可能都會被占滿,這是有兩種策略,一種是不改變Capacity,因為即使桶占滿了,我們還是可以利用每個桶附帶的鏈表增加元素。但是這有個缺點,此時HaspMap就退化成為了LinkedList,使get和put方法的時間開銷上升,這是就要采用另一種方法:增加Hash桶的數(shù)量,這樣get和put的時間開銷又回退到近于常數(shù)復(fù)雜度上。Hashmap就是采用的該方法。
關(guān)于擴(kuò)容。看HashMap的擴(kuò)容方法,resize方法,它的源碼如下:
// 重新調(diào)整HashMap的大小,newCapacity是調(diào)整后的單位
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
// 新建一個HashMap,將“舊HashMap”的全部元素添加到“新HashMap”中,
// 然后,將“新HashMap”賦值給“舊HashMap”。
Entry[] newTable = new Entry[newCapacity];
transfer(newTable);
table = newTable;
threshold = (int) (newCapacity * loadFactor);
}
很明顯,是從新建了一個HashMap的底層數(shù)組,長度為原來的兩倍,而后調(diào)用transfer方法,將舊HashMap的全部元素添加到新的HashMap中(要重新計算元素在新的數(shù)組中的索引位置)。
transfer方法的源碼如下:
// 將HashMap中的全部元素都添加到newTable中
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry<K, V> e = src[j];
if (e != null) {
src[j] = null;
do {
Entry<K, V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
很明顯,擴(kuò)容是一個相當(dāng)耗時的操作,因為它需要重新計算這些元素在新的數(shù)組中的位置并進(jìn)行復(fù)制處理。因此,我們在用HashMap時,最好能提前預(yù)估下HashMap中元素的個數(shù),這樣有助于提高HashMap的性能。
HashMap什么時候需要增加容量呢?
因為效率問題,JDK采用預(yù)處理法,這時前面說的loadFactor就派上了用場,當(dāng)size > initialCapacity * loadFactor,HashMap內(nèi)部resize方法就被調(diào)用,使得重新擴(kuò)充hash桶的數(shù)量,在目前的實現(xiàn)中,是增加一倍,這樣就保證當(dāng)你真正想put新的元素時效率不會明顯下降。所以一般情況下HashMap并不存在鍵值放滿的情況。當(dāng)然并不排除極端情況,比如設(shè)置的JVM內(nèi)存用完了,或者這個HashMap的Capacity已經(jīng)達(dá)到了MAXIMUM_CAPACITY(目前的實現(xiàn)是2^30)。
initialCapacity和loadFactor參數(shù)設(shè)什么樣的值好呢?
initialCapacity的默認(rèn)值是16,有些人可能會想如果內(nèi)存足夠,是不是可以將initialCapacity設(shè)大一些,即使用不了這么大,就可避免擴(kuò)容導(dǎo)致的效率的下降,反正無論initialCapacity大小,我們使用的get和put方法都是常數(shù)復(fù)雜度的。這么說沒什么不對,但是可能會忽略一點,實際的程序可能不僅僅使用get和put方法,也有可能使用迭代器,如initialCapacity容量較大,那么會使迭代器效率降低。所以理想的情況還是在使用HashMap前估計一下數(shù)據(jù)量。
加載因子默認(rèn)值是0.75,是JDK權(quán)衡時間和空間效率之后得到的一個相對優(yōu)良的數(shù)值。如果這個值過大,雖然空間利用率是高了,但是對于HashMap中的一些方法的效率就下降了,包括get和put方法,會導(dǎo)致每個hash桶所附加的鏈表增長,影響存取效率。如果比較小,除了導(dǎo)致空間利用率較低外沒有什么壞處,只要有的是內(nèi)存,畢竟現(xiàn)在大多數(shù)人把時間看的比空間重要。但是實際中還是很少有人會將這個值設(shè)置的低于0.5。
HashMap的key和value都能為null么?如果k能為null,那么它是怎么樣查找值的?
如果key為null,則直接從哈希表的第一個位置table[0]對應(yīng)的鏈表上查找。記住,key為null的鍵值對永遠(yuǎn)都放在以table[0]為頭結(jié)點的鏈表中。
HashMap中put值的時候如果發(fā)生了沖突,是怎么處理的?
JDK使用了鏈地址法,hash表的每個元素又分別鏈接著一個單鏈表,元素為頭結(jié)點,如果不同的key映射到了相同的下標(biāo),那么就使用頭插法,插入到該元素對應(yīng)的鏈表。
HashMap的key是如何散列到hash表的?相比較Hashtable有什么改進(jìn)?
我們一般對哈希表的散列很自然地會想到用hash值對length取模(即除留余數(shù)法),Hashtable就是這樣實現(xiàn)的,這種方法基本能保證元素在哈希表中散列的比較均勻,但取模會用到除法運算,效率很低,且Hashtable直接使用了hashcode值,沒有重新計算。
HashMap中則通過 h&(length-1) 的方法來代替取模,其中h是key的hash值,同樣實現(xiàn)了均勻的散列,但效率要高很多,這也是HashMap對Hashtable的一個改進(jìn)。
接下來,我們分析下為什么哈希表的容量一定要是2的整數(shù)次冪。
首先,length為2的整數(shù)次冪的話,h&(length-1) 在數(shù)學(xué)上就相當(dāng)于對length取模,這樣便保證了散列的均勻,同時也提升了效率;
其次,length為2的整數(shù)次冪的話,則一定為偶數(shù),那么 length-1 一定為奇數(shù),奇數(shù)的二進(jìn)制的最后一位是1,這樣便保證了 h&(length-1) 的最后一位可能為0,也可能為1(這取決于h的值),即與后的結(jié)果可能為偶數(shù),也可能為奇數(shù),這樣便可以保證散列的均勻,而如果length為奇數(shù)的話,很明顯 length-1 為偶數(shù),它的最后一位是0,這樣 h&(length-1) 的最后一位肯定為0,即只能為偶數(shù),這樣導(dǎo)致了任何hash值都只會被散列到數(shù)組的偶數(shù)下標(biāo)位置上,浪費了一半的空間,因此length取2的整數(shù)次冪,是為了使不同hash值發(fā)生碰撞的概率較小,這樣就能使元素在哈希表中均勻地散列。
作為對比,再討論一下Hashtable
Hashtable同樣是基于哈希表實現(xiàn)的,其實類似HashMap,只不過有些區(qū)別,Hashtable同樣每個元素是一個key-value對,其內(nèi)部也是通過單鏈表解決沖突問題,容量不足(超過了閥值)時,同樣會自動增長。
Hashtable比較古老, 是JDK1.0就引入的類,而HashMap 是 1.2 引進(jìn)的 Map 的一個實現(xiàn)。
Hashtable是線程安全的,能用于多線程環(huán)境中。Hashtable同樣也實現(xiàn)了Serializable接口,支持序列化,也實現(xiàn)了Cloneable接口,能被克隆。
Hashtable繼承于Dictionary類,實現(xiàn)了Map接口。Dictionary是聲明了操作"鍵值對"函數(shù)接口的抽象類。 有一點注意,Hashtable除了線程安全之外(其實是直接在方法上增加了synchronized關(guān)鍵字,比較古老,落后,低效的同步方式),還有就是它的key、value都不為null。另外Hashtable 也有 初始容量 和 加載因子。
public Hashtable() {
this(11, 0.75f);
}
默認(rèn)加載因子也是 0.75,Hashtable在不指定容量的情況下的默認(rèn)容量為11,而HashMap為16,Hashtable不要求底層數(shù)組的容量一定要為2的整數(shù)次冪,而HashMap則要求一定為2的整數(shù)次冪。因為Hashtable是直接使用除留余數(shù)法定位地址。且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的規(guī)范規(guī)定的。
最后針對擴(kuò)容:Hashtable擴(kuò)容時,將容量變?yōu)樵瓉淼?倍加1,而HashMap擴(kuò)容時,將容量變?yōu)樵瓉淼?倍。
下面是幾個常見的筆試,面試題
Hashtable和HashMap的區(qū)別有哪些?
HashMap和Hashtable都實現(xiàn)了Map接口,但決定用哪一個之前先要弄清楚它們之間的分別。主要的區(qū)別有:線程安全性,同步(synchronization),以及速度。
理解HashMap是Hashtable的輕量級實現(xiàn)(非線程安全的實現(xiàn),Hashtable是非輕量級,線程安全的),都實現(xiàn)Map接口,主要區(qū)別在于:
- 由于HashMap非線程安全,在只有一個線程訪問的情況下,效率要高于Hashtable。
- HashMap允許將null作為一個entry的key或者value,而Hashtable不允許。
- HashMap把Hashtable的contains方法去掉了,改成containsValue和containsKey。因為contains方法容易讓人引起誤解。
- Hashtable繼承自陳舊的Dictionary類,而HashMap是Java1.2引進(jìn)的Map 的一個實現(xiàn)。
- Hashtable和HashMap擴(kuò)容的方法不一樣,Hashtable中hash數(shù)組默認(rèn)大小11,擴(kuò)容方式是 old*2+1。HashMap中hash數(shù)組的默認(rèn)大小是16,而且一定是2的指數(shù),增加為原來的2倍,沒有加1。
- 兩者通過hash值散列到hash表的算法不一樣,HashTbale是古老的除留余數(shù)法,直接使用hashcode,而后者是強(qiáng)制容量為2的冪,重新根據(jù)hashcode計算hash值,在使用hash 位與 (hash表長度 – 1),也等價取模,但更加高效,取得的位置更加分散,偶數(shù),奇數(shù)保證了都會分散到。前者就不能保證。
- 另一個區(qū)別是HashMap的迭代器(Iterator)是fail-fast迭代器,而Hashtable的enumerator迭代器不是fail-fast的。所以當(dāng)有其它線程改變了HashMap的結(jié)構(gòu)(增加或者移除元素),將會拋出ConcurrentModificationException,但迭代器本身的remove()方法移除元素則不會拋出ConcurrentModificationException異常。但這并不是一個一定發(fā)生的行為,要看JVM。這條同樣也是Enumeration和Iterator的區(qū)別。
- fail-fast和iterator迭代器相關(guān)。如果某個集合對象創(chuàng)建了Iterator或者ListIterator,然后其它的線程試圖“結(jié)構(gòu)上”更改集合對象,將會拋出ConcurrentModificationException異常。但其它線程可以通過set()方法更改集合對象是允許的,因為這并沒有從“結(jié)構(gòu)上”更改集合。但是假如已經(jīng)從結(jié)構(gòu)上進(jìn)行了更改,再調(diào)用set()方法,將會拋出IllegalArgumentException異常。
- 結(jié)構(gòu)上的更改指的是刪除或者插入一個元素,這樣會影響到map的結(jié)構(gòu)。
- 該條說白了就是在使用迭代器的過程中有其他線程在結(jié)構(gòu)上修改了map,那么將拋出ConcurrentModificationException,這就是所謂fail-fast策略。
為什么HashMap是線程不安全的,實際會如何體現(xiàn)?
第一,如果多個線程同時使用put方法添加元素
假設(shè)正好存在兩個put的key發(fā)生了碰撞(hash值一樣),那么根據(jù)HashMap的實現(xiàn),這兩個key會添加到數(shù)組的同一個位置,這樣最終就會發(fā)生其中一個線程的put的數(shù)據(jù)被覆蓋。
第二,如果多個線程同時檢測到元素個數(shù)超過數(shù)組大小*loadFactor
這樣會發(fā)生多個線程同時對hash數(shù)組進(jìn)行擴(kuò)容,都在重新計算元素位置以及復(fù)制數(shù)據(jù),但是最終只有一個線程擴(kuò)容后的數(shù)組會賦給table,也就是說其他線程的都會丟失,并且各自線程put的數(shù)據(jù)也丟失。且會引起死循環(huán)的錯誤。
具體細(xì)節(jié)上的原因,可以參考:不正當(dāng)使用HashMap導(dǎo)致cpu 100%的問題追究
能否讓HashMap實現(xiàn)線程安全,如何做?
- 直接使用Hashtable,但是當(dāng)一個線程訪問Hashtable的同步方法時,其他線程如果也要訪問同步方法,會被阻塞住。舉個例子,當(dāng)一個線程使用put方法時,另一個線程不但不可以使用put方法,連get方法都不可以,效率很低,現(xiàn)在基本不會選擇它了。
- HashMap可以通過下面的語句進(jìn)行同步:
Collections.synchronizeMap(hashMap);
- 直接使用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的操作是線程安全的,故效率其實也不高。
為什么Hashtable的默認(rèn)大小和HashMap不一樣?
前面分析了,Hashtable 的擴(kuò)容方法是乘2再+1,不是簡單的乘2,故Hashtable保證了容量永遠(yuǎn)是奇數(shù),結(jié)合之前分析HashMap的重算hash值的邏輯,就明白了,因為在數(shù)據(jù)分布在等差數(shù)據(jù)集合(如偶數(shù))上時,如果公差與桶容量有公約數(shù) n,則至少有(n-1)/n 數(shù)量的桶是利用不到的,故之前的HashMap會在取模(使用位與運算代替)哈希前先做一次哈希運算,調(diào)整hash值。這里Hashtable比較古老,直接使用了除留余數(shù)法,那么就需要設(shè)置容量起碼不是偶數(shù)(除(近似)質(zhì)數(shù)求余的分散效果好)。而JDK開發(fā)者選了11。
JDK 8對HashMap有了什么改進(jìn)?說說你對紅黑樹的理解?
參考更新的jdk 8對HashMap的的改進(jìn)部分整理,并且還能引申出高級數(shù)據(jù)結(jié)構(gòu)——紅黑樹,這又能引出很多問題……學(xué)無止境啊!
臨時小結(jié):感覺針對Java的HashMap和Hashtable面試,或者理解,到這里就可以了,具體就是多寫代碼實踐。