HashMap的相關問題能夠說是面試中很常見的問題了,網上也能看到很是多的講解。可是我的感受,看的再多都不如本身實打實的寫一篇總結來的收穫多。
html
首先介紹什麼是hash表,散列表(Hash table,也叫哈希表),是根據鍵(Key)而直接訪問在內存存儲位置的數據結構。也就是說,它經過計算一個關於鍵值的函數,將所需查詢的數據映射到表中一個位置來訪問記錄,這加快了查找速度。這個映射函數稱作散列函數,存放記錄的數組稱作散列表。(描述引自維基百科)在String類的hashcode()方法中,能夠看到這個計算哈希值的過程:java
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
複製代碼
可是注意: Hash算法有兩條性質:不可逆和無衝突
node
說到hashCode就讓我想到了Java世界的兩大約定,既equals與hashCode約定。 equals約定,直接看Object類的Java doc:面試
* Indicates whether some other object is "equal to" this one.
* <p>
* The {@code equals} method implements an equivalence relation
* on non-null object references:
* <ul>
* <li>It is <i>reflexive</i>: for any non-null reference value
* {@code x}, {@code x.equals(x)} should return
* {@code true}.
* <li>It is <i>symmetric</i>: for any non-null reference values
* {@code x} and {@code y}, {@code x.equals(y)}
* should return {@code true} if and only if
* {@code y.equals(x)} returns {@code true}.
* <li>It is <i>transitive</i>: for any non-null reference values
* {@code x}, {@code y}, and {@code z}, if
* {@code x.equals(y)} returns {@code true} and
* {@code y.equals(z)} returns {@code true}, then
* {@code x.equals(z)} should return {@code true}.
* <li>It is <i>consistent</i>: for any non-null reference values
* {@code x} and {@code y}, multiple invocations of
* {@code x.equals(y)} consistently return {@code true}
* or consistently return {@code false}, provided no
* information used in {@code equals} comparisons on the
* objects is modified.
* <li>For any non-null reference value {@code x},
* {@code x.equals(null)} should return {@code false}.
* </ul>
* <p>
複製代碼
這個不用翻譯,直接看也能看明白。
hashCode的約定是Java世界第二重要的約定:
算法
<p>
* The general contract of {@code hashCode} is:
* <ul>
* <li>Whenever it is invoked on the same object more than once during
* an execution of a Java application, the {@code hashCode} method
* must consistently return the same integer, provided no information
* used in {@code equals} comparisons on the object is modified.
* This integer need not remain consistent from one execution of an
* application to another execution of the same application.
* <li>If two objects are equal according to the {@code equals(Object)}
* method, then calling the {@code hashCode} method on each of
* the two objects must produce the same integer result.
* <li>It is <em>not</em> required that if two objects are unequal
* according to the {@link java.lang.Object#equals(java.lang.Object)}
* method, then calling the {@code hashCode} method on each of the
* two objects must produce distinct integer results. However, the
* programmer should be aware that producing distinct integer results
* for unequal objects may improve the performance of hash tables.
* </ul>
* <p>
複製代碼
翻譯一下就是:shell
第三重要的約定是compareTo約定
編程
因此問出第一個問題:設計模式
在Java7中的hashMap就是採用的這種結構,既數組+鏈表的實現,這樣作的好處是查找、插入、刪除的時間複雜度都是O(1),可是致命的缺陷就是哈希桶的碰撞。也就是全部的value都對應一個key值,好比這種狀況:數組
public static void main(String[] args) {
HashMap<String,String> hashMap = new HashMap<>();
List<String> list = Arrays.asList("Aa","BB","C#");
for (String s:list
) {
hashMap.put(s,s);
System.out.println(s.hashCode());
}
}
複製代碼
結果:安全
2112
2112
2112
Process finished with exit code 0
複製代碼
這樣hash表就成了一個鏈表,性能急劇退化。
因此在Java8以後,就採用了數組+鏈表+紅黑樹的結構來實現HashMap,也就是當鏈表達到必定的長度以後,會轉換成一棵紅黑樹。具體過程在後面作詳細介紹。
hashMap的Java doc初始化容量上面有這一句話:
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
複製代碼
那麼咱們就恰恰定義一個初始容量不是2的冪的hashMap,
好像也沒事? 回答這個問題以前,咱們先看下hashMap的源代碼是怎麼保證初始容量必定是2的冪的。看源碼:public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
複製代碼
這裏有個tableSizeFor()方法,
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
複製代碼
乍一看好像看不太懂,沒事,拿着本身的例子進去試一遍就知道這個方法的做用了。就拿9爲例(>>>表示無符號右移一位,<<<則表示無符號左移一位):
獲得的是15最終返回16,而16是比9大而且離的9最近的爲2的冪的數字,因此這就是這個方法的做用。
那麼爲何hashMap要保證初始容量爲2的冪的呢?先問本身這樣一個問題,int的範圍是(至),可是你一個HashMap的大小,剛開始的時候也就是幾十個,那麼是怎麼把哈希值放入這數組大小爲幾十的桶中呢?最容易想到的就是取模運算了,可是有問題:若是哈希值是負數呢?數組的位置可沒有負數。而且取模運算在磁盤中就是在作一遍又一遍的減法,這是很沒有效率的。那麼實際上HashMap是怎麼作的呢?從JDK1.7,HashMap的put方法中能夠看到:
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
//參數的索引值是根據indexFor方法計算出來的
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
//使用位運算獲取數組的下標
return h & (length-1);
}
複製代碼
演示一下這個過程,隨便取一個數,以HashMap的大小爲32爲例:
那若是是30呢?
能夠看到倒數第二位被擦除了,也就是說,不管hash值算的是多少,它的這一位都是0,那就帶來了索引不連續的問題,就不能保證元素在HashMap中是連續存放的。因此爲了保證連續,HashMap的大小-1必須保證每一位上都是1,故而HashMap的大小必須爲2的冪。
首先咱們知道,若是一直往Map裏面丟元素,那麼某個桶裏面的元素個數超過某個數值的時候,鏈表會轉換成紅黑樹。因此,想知道閾值是多少,就去看HashMap的put方法,put方法的源碼解讀網上也有不少。我這就直接給出答案了: 截取put方法的一小部分:
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
複製代碼
/**
* The bin count threshold for using a tree rather than list for a
* bin. Bins are converted to trees when adding an element to a
* bin with at least this many nodes. The value must be greater
* than 2 and should be at least 8 to mesh with assumptions in
* tree removal about conversion back to plain bins upon
* shrinkage.
*/
static final int TREEIFY_THRESHOLD = 8;
複製代碼
能夠看到,當桶裏面的元素個數大於等於7個的時候,進入數化方法,再進去數化方法:
/**
* The smallest table capacity for which bins may be treeified.
* (Otherwise the table is resized if too many nodes in a bin.)
* Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
* between resizing and treeification thresholds.
*/
static final int MIN_TREEIFY_CAPACITY = 64;
複製代碼
能夠看到,不光是鏈表個數要超過8,還要桶的個數超過64纔會發生由鏈表向二叉樹的轉換。
關於爲何是8這個問題, 源碼中給出了答案:
* with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million
複製代碼
解釋一下就是:由於桶裏面元素的個數的機率服從參數爲0.5的泊松分佈。由計算結果可知,在正常狀況下,桶裏面元素個數爲8的機率爲千萬分之級,很是小。若是超過8,說明出現了碰撞,這時候將鏈表轉換爲紅黑樹能夠及時的遏制性能降低的問題。聽到的另一個說法就是說,由於鏈表的平均查找時間複雜度爲n/2,二叉樹的查找爲,當個數爲8的時候,=3<4,進行數化纔會提升查找效率,不然不必,感受也蠻有道理的。
至於另外一個閾值爲何是64,緣由在於:
進行樹化本質上是爲了提升查找效率,節約查找時間而作的操做。可是若是桶的個數不多,達不到必定的規模,就不必進行樹化,直接擴容便可。至於爲何是64?我暫時還沒看到有關的解析,之後找到了再作補充。
爲何是0.75?
這個問題在在Stack Overflow上找到了答案:
一般,默認負載係數(0.75)在時間和空間成本之間提供了良好的折衷。較高的值減小了空間開銷,但會增長查找成本(反映在HashMap類的大多數操做中,包括GET和PUT),會下降擴容的效率。可是過小的話,擴容十分頻繁,又會佔用大量的內存空間。
在HashMap的put方法中(jdk1.7更加簡單易懂)能夠看到:
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i);
return null;
}
複製代碼
當沒有找到相同的元素時,會給這個元素加一個桶。
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
複製代碼
在這個增長桶的方法中就能夠看出,當桶的個數大於閾值(負載因子*容量)的時候,就產生一個大小是原來兩倍的新表。
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
複製代碼
經過這個調整大小的方法,咱們能夠看出,擴容的機制就是把原來的數據丟到一個大小是原來兩倍大的新表中,而且會從新計算索引值(注意,就是這個過程,將會致使一個很致命的問題)。
這裏推薦一篇講這部分過程的文章 coolshell.cn/articles/96… 裏面說的很清楚:
因爲在JDK1.8以前,往HashMap中插入元素都是往前插,因此致使原來3->7的順序變爲7->3,這樣循環鏈表就產生了。 這時候再去調用get()方法的話,就會產生死鎖。 首先,紅黑樹是一中自平衡二叉查找樹,由於傳統的二叉樹在特殊情$況下會造成一個近似鏈表的樹,很影響效率,因此引出紅黑樹做爲替代。
紅黑樹的特性(源自維基百科):
紅黑樹是每一個節點都帶有顏色屬性的二叉查找樹,顏色爲紅色或黑色。在二叉查找樹強制通常要求之外,對於任何有效的紅黑樹咱們增長了以下的額外要求:
首先,這四種數據結構都實現了Map接口,可是各有各的不一樣。
數據結構 | 特色 |
---|---|
HashMap | HashMap不是線程安全的,且不保證順序,最多隻容許一條記錄的鍵爲null。可是因爲它是根據數據的hash值獲取數據,因此查找效率很高 |
LinkedHashMap | 能夠插入空值,查找時根據插入的順序來獲取數據,因此先插入的先被找到。可是問題就是這樣會致使查找效率下降。一樣,也是線程不安全的。 |
HashTable | HashTable是不能存放空值的,另外若是去看HashTable中的方法及參數,發現基本上都是加了synchornized標識的,這就說明HashTable在同一時刻只能被單獨一條線程所訪問,這就保證了在多線程狀況下的安全性,可是一樣帶來了寫入效率較慢的問題。 |
TreeMap | treeMap不一樣於HashMap之處在於它是有序的,傳入treeMap的參數必須實現Comparable接口,也就是爲treeMap指定排序方法,不然就會報錯。且鍵、值不能爲空,也不支持在多線程環境下保證安全性。 |
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
複製代碼
拿到key的哈希值,多態進入另外一個get()方法。截取部分代碼:
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
複製代碼
根據拿到的Hash值進入桶中,若是第一個元素就是要找的數那就返回,否咋就按照鏈表或者紅黑樹進行查找。
注意:onlyifAbsent表示插入重複的鍵值對時是否保留原來的,ture表示保留原來的,false表示用新的覆蓋掉舊的。evict表示建造者模式,設計模式的一種。 接下來就一段段分析代碼:
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
複製代碼
若是table爲空,就初始化一個。
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
複製代碼
若是要插入的位置上爲空,則直接new一個Node在這個位置上。
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
複製代碼
若是插入的位置上key,value都重複了,直接覆蓋。
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
複製代碼
若是是樹節點,就按樹節點來插入。
else {
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
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
複製代碼
若是是鏈表節點,就按鏈表節點插入。
在JDK1.7中,計算hash值的方法是:
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
複製代碼
咋一看可能不知道這段代碼在幹什麼,其實隨便拿兩個數字試一試就明白了, 本身模擬這個過程:
private static int cacIndex(int h, int size) {
return h & (size - 1);
}
public static int cacHashCode(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
複製代碼
隨便用兩個按道理會產生hash碰撞的數字:
public static void main(String[] args) {
System.out.println(cacIndex(212123371, 4));
System.out.println(cacIndex(121311123, 4));
}
複製代碼
結果以下:
3
3
Process finished with exit code 0
複製代碼
調用擾動方法:
public static void main(String[] args) {
System.out.println(cacIndex(cacHashCode(212123371), 4));
System.out.println(cacIndex(cacHashCode(121311123), 4));
}
複製代碼
結果:
0
1
Process finished with exit code 0
複製代碼
那麼給出結論:
HashMap減小哈希碰撞的方法就在於使用擾動方法,使得hashCode的高低位都參與計算,因此下降了因高位不一樣,低位相同的hashCode在HashMap的容量較小時而致使哈希碰撞的機率。
與此同時,這個方法在JDK1.8中簡化爲:
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
複製代碼
其餘解決哈希碰撞的方法:
都知道在多線程狀況下可使用ConcurrentHashMap來建立線程安全的HashMap,可是它爲何是線程安全的?
先看JDK1.7中的源代碼:
/**
* Segments are specialized versions of hash tables. This
* subclasses from ReentrantLock opportunistically, just to
* simplify some locking and avoid separate construction.
*/
static final class Segment<K,V> extends ReentrantLock implements Serializable
複製代碼
從ConcurrentHashMap的描述中能夠看到,在JDK1.7中使用的是分段鎖技術,既使用繼承了ReentrantLock類的Segment對每一段進行加鎖,而後在put方法以前,會先檢查當前線程有沒有持有鎖,put方法部分源代碼:
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
複製代碼
而put()方法
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
int hash = hash(key);
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
複製代碼
中的value更是加上了volatile關鍵字,也保證了線程安全。
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;
HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
複製代碼
那麼在Java8中,狀況又不一樣了起來:
Java8捨棄了分段式鎖的模式,而是採用synchronized關鍵字以及CAS(compare and swap)算法相結合的機制,實現線程安全。何謂CAS算法:比較並交換(compare and swap, CAS),是原子操做的一種,可用於在多線程編程中實現不被打斷的數據交換操做,從而避免多線程同時改寫某一數據時因爲執行順序不肯定性以及中斷的不可預知性產生的數據不一致問題。 該操做經過將內存中的值與指定數據進行比較,當數值同樣時將內存中的數據替換爲新的值。(源自維基百科)
那麼進入ConcurrentHashMap源代碼中:
截取put()方法中的部分源碼:
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
複製代碼
能夠看到,對於put方法的插入操做都是在synchornized操做下的,因此同一時間最多隻能有一條線程進行操做,而且對於交換數值的操做:
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
複製代碼
是經過實現CAS算法來實現先比較再交換的,保證了當前位置處的數值爲空時,進行交換操做的安全性。
參考資料: