title: Java HashMap工作原理及實現(xiàn)
tags:
- Java
1. 概述
從本文你可以學習到:
- 什么時候會使用HashMap?他有什么特點?
- 你知道HashMap的工作原理嗎?
- 你知道get和put的原理嗎?equals()和hashCode()的都有什么作用?
- 你知道hash的實現(xiàn)嗎?為什么要這樣實現(xiàn)?
- 如果HashMap的大小超過了負載因子(load factor)定義的容量,怎么辦?
當我們執(zhí)行下面的操作時:
HashMap<String, Integer> map = new HashMap<String, Integer>();
map.put("語文", 1);
map.put("數(shù)學", 2);
map.put("英語", 3);
map.put("歷史", 4);
map.put("政治", 5);
map.put("地理", 6);
map.put("生物", 7);
map.put("化學", 8);
for(Entry<String, Integer> entry : map.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}
運行結果是
政治: 5
生物: 7
歷史: 4
數(shù)學: 2
化學: 8
語文: 1
英語: 3
地理: 6
發(fā)生了什么呢?下面是一個大致的結構,希望我們對HashMap的結構有一個感性的認識:

在官方文檔中是這樣描述HashMap的:
Hash table based implementation of the Map interface. This implementation provides all of the optional map operations, and permits null values and the null key. (The HashMap class is roughly equivalent to Hashtable, except that it is unsynchronized and permits nulls.) This class makes no guarantees as to the order of the map; in particular, it does not guarantee that the order will remain constant over time.
幾個關鍵的信息:基于Map接口實現(xiàn)、允許null鍵/值、非同步、不保證有序(比如插入的順序)、也不保證序不隨時間變化。
2. 兩個重要的參數(shù)
在HashMap中有兩個很重要的參數(shù),容量(Capacity)和負載因子(Load factor)
- Initial capacity The capacity is the number of buckets in the hash table, The initial capacity is simply the capacity at the time the hash table is created.
- Load factor The load factor is a measure of how full the hash table is allowed to get before its capacity is automatically increased.
簡單的說,Capacity就是buckets的數(shù)目,Load factor就是buckets填滿程度的最大比例。如果對迭代性能要求很高的話不要把capacity設置過大,也不要把load factor設置過小。當bucket填充的數(shù)目(即hashmap中元素的個數(shù))大于capacity*load factor時就需要調(diào)整buckets的數(shù)目為當前的2倍。
3. put函數(shù)的實現(xiàn)
put函數(shù)大致的思路為:
- 對key的hashCode()做hash,然后再計算index;
- 如果沒碰撞直接放到bucket里;
- 如果碰撞了,以鏈表的形式存在buckets后;
- 如果碰撞導致鏈表過長(大于等于TREEIFY_THRESHOLD),就把鏈表轉換成紅黑樹;
- 如果節(jié)點已經(jīng)存在就替換old value(保證key的唯一性)
- 如果bucket滿了(超過load factor*current capacity),就要resize。
具體代碼的實現(xiàn)如下:
public V put(K key, V value) {
// 對key的hashCode()做hash
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// tab為空則創(chuàng)建
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 計算index,并對null做處理
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 節(jié)點存在
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 該鏈為樹
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;
}
}
// 寫入
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 超過load factor*current capacity,resize
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
4. get函數(shù)的實現(xiàn)
在理解了put之后,get就很簡單了。大致思路如下:
- bucket里的第一個節(jié)點,直接命中;
- 如果有沖突,則通過key.equals(k)去查找對應的entry
若為樹,則在樹中通過key.equals(k)查找,O(logn);
若為鏈表,則在鏈表中通過key.equals(k)查找,O(n)。
具體代碼的實現(xiàn)如下:
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 直接命中
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 未命中
if ((e = first.next) != null) {
// 在樹中get
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 在鏈表中get
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
5. hash函數(shù)的實現(xiàn)
在get和put的過程中,計算下標時,先對hashCode進行hash操作,然后再通過hash值進一步計算下標,如下圖所示:

在對hashCode()計算hash時具體實現(xiàn)是這樣的:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
可以看到這個函數(shù)大概的作用就是:高16bit不變,低16bit和高16bit做了一個異或。其中代碼注釋是這樣寫的:
Computes key.hashCode() and spreads (XORs) higher bits of hash to lower. Because the table uses power-of-two masking, sets of hashes that vary only in bits above the current mask will always collide. (Among known examples are sets of Float keys holding consecutive whole numbers in small tables.) So we apply a transform that spreads the impact of higher bits downward. There is a tradeoff between speed, utility, and quality of bit-spreading. Because many common sets of hashes are already reasonably distributed (so don't benefit from spreading), and because we use trees to handle large sets of collisions in bins, we just XOR some shifted bits in the cheapest possible way to reduce systematic lossage, as well as to incorporate impact of the highest bits that would otherwise never be used in index calculations because of table bounds.
在設計hash函數(shù)時,因為目前的table長度n為2的冪,而計算下標的時候,是這樣實現(xiàn)的(使用&位操作,而非%求余):
(n - 1) & hash
設計者認為這方法很容易發(fā)生碰撞。為什么這么說呢?不妨思考一下,在n - 1為15(0x1111)時,其實散列真正生效的只是低4bit的有效位,當然容易碰撞了。
因此,設計者想了一個顧全大局的方法(綜合考慮了速度、作用、質(zhì)量),就是把高16bit和低16bit異或了一下。設計者還解釋到因為現(xiàn)在大多數(shù)的hashCode的分布已經(jīng)很不錯了,就算是發(fā)生了碰撞也用O(logn)的tree去做了。僅僅異或一下,既減少了系統(tǒng)的開銷,也不會造成的因為高位沒有參與下標的計算(table長度比較小時),從而引起的碰撞。
如果還是產(chǎn)生了頻繁的碰撞,會發(fā)生什么問題呢?作者注釋說,他們使用樹來處理頻繁的碰撞(we use trees to handle large sets of collisions in bins),在JEP-180中,描述了這個問題:
Improve the performance of java.util.HashMap under high hash-collision conditions by using balanced trees rather than linked lists to store map entries. Implement the same improvement in the LinkedHashMap class.
之前已經(jīng)提過,在獲取HashMap的元素時,基本分兩步:
- 首先根據(jù)hashCode()做hash,然后確定bucket的index;
- 如果bucket的節(jié)點的key不是我們需要的,則通過keys.equals()在鏈中找。
在Java 8之前的實現(xiàn)中是用鏈表解決沖突的,在產(chǎn)生碰撞的情況下,進行get時,兩步的時間復雜度是O(1)+O(n)。因此,當碰撞很厲害的時候n很大,O(n)的速度顯然是影響速度的。
因此在Java 8中,利用紅黑樹替換鏈表,這樣復雜度就變成了O(1)+O(logn)了,這樣在n很大的時候,能夠比較理想的解決這個問題,在Java 8:HashMap的性能提升一文中有性能測試的結果。
6. resize的實現(xiàn)
當put時,如果發(fā)現(xiàn)目前的bucket占用程度已經(jīng)超過了Load Factor所希望的比例,那么就會發(fā)生resize。在resize的過程,簡單的說就是把bucket擴充為2倍,之后重新計算index,把節(jié)點再放到新的bucket中。resize的注釋是這樣描述的:
Initializes or doubles table size. If null, allocates in accord with initial capacity target held in field threshold. Otherwise, because we are using power-of-two expansion, the elements from each bin must either stay at same index, or move with a power of two offset in the new table.
大致意思就是說,當超過限制的時候會resize,然而又因為我們使用的是2次冪的擴展(指長度擴為原來2倍),所以,元素的位置要么是在原位置,要么是在原位置再移動2次冪的位置。
怎么理解呢?例如我們從16擴展為32時,具體的變化如下所示:

因此元素在重新計算hash之后,因為n變?yōu)?倍,那么n-1的mask范圍在高位多1bit(紅色),因此新的index就會發(fā)生這樣的變化:

因此,我們在擴充HashMap的時候,不需要重新計算hash,只需要看看原來的hash值新增的那個bit是1還是0就好了,是0的話索引沒變,是1的話索引變成“原索引+oldCap”??梢钥纯聪聢D為16擴充為32的resize示意圖:

這個設計確實非常的巧妙,既省去了重新計算hash值的時間,而且同時,由于新增的1bit是0還是1可以認為是隨機的,因此resize的過程,均勻的把之前的沖突的節(jié)點分散到新的bucket了。
下面是代碼的具體實現(xià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;
}
// 沒超過最大值,就擴充為原來的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
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);
}
// 計算新的resize上限
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) {
// 把每個bucket都移動到新的buckets中
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
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;
}
// 原索引+oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到bucket里
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引+oldCap放到bucket里
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
7. 總結
我們現(xiàn)在可以回答開始的幾個問題,加深對HashMap的理解:
1. 什么時候會使用HashMap?他有什么特點?
是基于Map接口的實現(xiàn),存儲鍵值對時,它可以接收null的鍵值,是非同步的,HashMap存儲著Entry(hash, key, value, next)對象。
2. 你知道HashMap的工作原理嗎?
通過hash的方法,通過put和get存儲和獲取對象。存儲對象時,我們將K/V傳給put方法時,它調(diào)用hashCode計算hash從而得到bucket位置,進一步存儲,HashMap會根據(jù)當前bucket的占用情況自動調(diào)整容量(超過Load Facotr則resize為原來的2倍)。獲取對象時,我們將K傳給get,它調(diào)用hashCode計算hash從而得到bucket位置,并進一步調(diào)用equals()方法確定鍵值對。如果發(fā)生碰撞的時候,Hashmap通過鏈表將產(chǎn)生碰撞沖突的元素組織起來,在Java 8中,如果一個bucket中碰撞沖突的元素超過某個限制(默認是8),則使用紅黑樹來替換鏈表,從而提高速度。
3. 你知道get和put的原理嗎?equals()和hashCode()的都有什么作用?
通過對key的hashCode()進行hashing,并計算下標( n-1 & hash),從而獲得buckets的位置。如果產(chǎn)生碰撞,則利用key.equals()方法去鏈表或樹中去查找對應的節(jié)點
4. 你知道hash的實現(xiàn)嗎?為什么要這樣實現(xiàn)?
在Java 1.8的實現(xiàn)中,是通過hashCode()的高16位異或低16位實現(xiàn)的:(h = k.hashCode()) ^ (h >>> 16),主要是從速度、功效、質(zhì)量來考慮的,這么做可以在bucket的n比較小的時候,也能保證考慮到高低bit都參與到hash的計算中,同時不會有太大的開銷。
5. 如果HashMap的大小超過了負載因子(load factor)定義的容量,怎么辦?
如果超過了負載因子(默認0.75),則會重新resize一個原來長度兩倍的HashMap,并且重新調(diào)用hash方法。
關于Java集合的小抄中是這樣描述的:
以Entry[]數(shù)組實現(xiàn)的哈希桶數(shù)組,用Key的哈希值取模桶數(shù)組的大小可得到數(shù)組下標。
插入元素時,如果兩條Key落在同一個桶(比如哈希值1和17取模16后都屬于第一個哈希桶),我們稱之為哈希沖突。
JDK的做法是鏈表法,Entry用一個next屬性實現(xiàn)多個Entry以單向鏈表存放。查找哈希值為17的key時,先定位到哈希桶,然后鏈表遍歷桶里所有元素,逐個比較其Hash值然后key值。
在JDK8里,新增默認為8的閾值,當一個桶里的Entry超過閥值,就不以單向鏈表而以紅黑樹來存放以加快Key的查找速度。
當然,最好還是桶里只有一個元素,不用去比較。所以默認當Entry數(shù)量達到桶數(shù)量的75%時,哈希沖突已比較嚴重,就會成倍擴容桶數(shù)組,并重新分配所有原來的Entry。擴容成本不低,所以也最好有個預估值。
取模用與操作(hash & (arrayLength-1))會比較快,所以數(shù)組的大小永遠是2的N次方, 你隨便給一個初始值比如17會轉為32。默認第一次放入元素時的初始值是16。
iterator()時順著哈希桶數(shù)組來遍歷,看起來是個亂序
參考資料
HashMap的工作原理
Java 8:HashMap的性能提升
JEP 180: Handle Frequent HashMap Collisions with Balanced Trees
ConurrentHashMap和Hashtable的區(qū)別
HashMap和Hashtable的區(qū)別