HashMap源代码深入剖析
1. 概述
首先從一個(gè)例子來開始HashMap的學(xué)習(xí)
public class Test {public static void main(String[] args) {Map<String, Integer> map = new HashMap<String, Integer>();map.put("語文", 1);map.put("數(shù)學(xué)", 2);map.put("英語", 3);map.put("歷史", 4);map.put("政治", 5);map.put("地理", 6);map.put("生物", 7);map.put("化學(xué)", 8);for (Map.Entry<String, Integer> entry : map.entrySet()) {System.out.println(entry.getKey() + ": " + entry.getValue());}} }?程序的運(yùn)行結(jié)果:
政治: 5 生物: 7 歷史: 4 數(shù)學(xué): 2 化學(xué): 8 語文: 1 英語: 3 地理: 6發(fā)生了什么呢?下面是一個(gè)大致的結(jié)構(gòu),希望我們對HashMap的結(jié)構(gòu)有一個(gè)感性的認(rèn)識:
補(bǔ)充:
hash值對16取模的結(jié)果:
831312%16=0
828410%16=10
682778%16=10
從圖中可以看出:?
(01)HashMap繼承于AbstractMap類,實(shí)現(xiàn)了Map接口。Map是"key-value鍵值對"接口,AbstractMap實(shí)現(xiàn)了"鍵值對"的通用函數(shù)接口。
(02)HashMap是通過"拉鏈法"實(shí)現(xiàn)的哈希表。它包括幾個(gè)重要的成員變量:table, size, threshold, loadFactor, modCount。
table是一個(gè)Node[]數(shù)組類型,而Node(7中叫Entry)實(shí)際上就是一個(gè)單向鏈表。哈希表的"key-value鍵值對"都是存儲在Node數(shù)組中的。
size是HashMap的大小,它是HashMap保存的鍵值對的數(shù)量。
threshold是HashMap的閾值,用于判斷是否需要調(diào)整HashMap的容量。threshold的值="容量*加載因子",當(dāng)HashMap中存儲數(shù)據(jù)的數(shù)量達(dá)到threshold時(shí),就需要將HashMap的容量加倍。
loadFactor就是加載因子。
modCount是用來實(shí)現(xiàn)fail-fast機(jī)制的。
在官方文檔中是這樣描述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.幾個(gè)關(guān)鍵的信息:基于Map接口實(shí)現(xiàn)、允許null鍵/值、非同步、不保證有序、也不保證序不隨時(shí)間變化。
2. 影響HashMap實(shí)例性能的兩個(gè)參數(shù)
在HashMap中有兩個(gè)很重要的參數(shù)會(huì)影響HashMap的性能,初始容量(initial capacity)和負(fù)載因子(Load factor)
The capacity is the number of buckets in the hash table, and the initial capacity is simply the capacity at the time the hash table is created. The load factor is a measure of how full the hash table is allowed to get before its capacity is automatically increased. When the number of entries in the hash table exceeds the product of the load factor and the current capacity, the hash table is rehashed (that is, internal data structures are rebuilt) so that the hash table has approximately twice the number of buckets.hashMap 的實(shí)例有兩個(gè)參數(shù)影響其性能:"初始容量" 和 "加載因子"。容量是哈希表中桶的數(shù)量,初始容量只是哈希表在創(chuàng)建時(shí)的容量。加載因子是哈希表在其容量自動(dòng)增加之前可以達(dá)到多滿的一種尺度。當(dāng)哈希表中的條目數(shù)超出了加載因子與當(dāng)前容量的乘積時(shí),則要對該哈希表進(jìn)行rehash操作(即重建內(nèi)部數(shù)據(jù)結(jié)構(gòu)),從而哈希表將具有大約兩倍的桶數(shù)。
通常,默認(rèn)加載因子是0.75, 這是在時(shí)間和空間成本上尋求一種折衷。加載因子過高雖然減少了空間開銷,但同時(shí)也增加了查詢成本(在大多數(shù) HashMap 類的操作中,包括get 和put操作,都反映了這一點(diǎn))。在設(shè)置初始容量時(shí)應(yīng)該考慮到映射中所需的條目數(shù)及其加載因子,以便最大限度地減少rehash操作次數(shù)。如果初始容量大于最大條目數(shù)除以加載因子,則不會(huì)發(fā)生 rehash 操作。
3. put函數(shù)的實(shí)現(xiàn)
put函數(shù)大致的思路為:
(1)對key的hashCode()做hash,然后再計(jì)算index;
(2)如果沒碰撞直接放到bucket里;
(3)如果碰撞了,以鏈表的形式存在buckets后;
(4)如果碰撞導(dǎo)致鏈表過長(大于等于TREEIFY_THRESHOLD),就把鏈表轉(zhuǎn)換成紅黑樹;
(5)如果節(jié)點(diǎn)已經(jīng)存在就替換old value(保證key的唯一性)
(6)如果bucket滿了(超過load factor*current capacity),就要resize。
具體代碼的實(shí)現(xiàn)如下:
public V put(K key, V value) {// 對key的hashCode()做hashreturn 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;// 計(jì)算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é)點(diǎn)存在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 keyV oldValue = e.value;if (!onlyIfAbsent || oldValue == null)e.value = value;afterNodeAccess(e);return oldValue;}}++modCount;// 超過load factor*current capacity,resizeif (++size > threshold)resize();afterNodeInsertion(evict);return null; }4. get函數(shù)的實(shí)現(xiàn)
在理解了put之后,get就很簡單了。大致思路如下:
(1)bucket里的第一個(gè)節(jié)點(diǎn),直接命中;
(2)如果有沖突,則通過key.equals(k)去查找對應(yīng)的entry
若為樹,則在樹中通過key.equals(k)查找,O(logn);
若為鏈表,則在鏈表中通過key.equals(k)查找,O(n)。
具體代碼的實(shí)現(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) {// 在樹中g(shù)etif (first instanceof TreeNode)return ((TreeNode<K,V>)first).getTreeNode(hash, key);// 在鏈表中g(shù)etdo {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ù)的實(shí)現(xiàn)
在get和put的過程中,計(jì)算下標(biāo)時(shí),先對hashCode進(jìn)行hash操作,然后再通過hash值進(jìn)一步計(jì)算下標(biāo),
在對hashCode()計(jì)算hash時(shí)具體實(shí)現(xiàn)是這樣的:
?
static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }?
如下圖所示:
可以看到這個(gè)函數(shù)大概的作用就是:高16bit不變,低16bit和高16bit做了一個(gè)異或。其中代碼注釋是這樣寫的:
?
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.?
在設(shè)計(jì)hash函數(shù)時(shí),因?yàn)槟壳暗膖able長度n為2的冪,而計(jì)算下標(biāo)的時(shí)候,是這樣實(shí)現(xiàn)的(使用&位操作,而非%求余):
(n - 1) & hash設(shè)計(jì)者認(rèn)為這方法很容易發(fā)生碰撞。為什么這么說呢?不妨思考一下,在n - 1為15(1111)時(shí),其實(shí)散列真正生效的只是低4bit的有效位,當(dāng)然容易碰撞了。
因此,設(shè)計(jì)者想了一個(gè)顧全大局的方法(綜合考慮了速度、作用、質(zhì)量),就是把高16bit和低16bit異或了一下。設(shè)計(jì)者還解釋到因?yàn)楝F(xiàn)在大多數(shù)的hashCode的分布已經(jīng)很不錯(cuò)了,就算是發(fā)生了碰撞也用O(logn)的tree去做了。僅僅異或一下,既減少了系統(tǒng)的開銷,也不會(huì)造成因?yàn)楦呶粵]有參與下標(biāo)的計(jì)算(table長度比較小時(shí)),從而引起的碰撞。
之前已經(jīng)提過,在獲取HashMap的元素時(shí),基本分兩步:
在Java 8之前的實(shí)現(xiàn)中是用鏈表解決沖突的,在產(chǎn)生碰撞的情況下,進(jìn)行g(shù)et時(shí),兩步的時(shí)間復(fù)雜度是O(1)+O(n)。因此,當(dāng)碰撞很厲害的時(shí)候n很大,O(n)的速度顯然是影響速度的。
因此在Java 8中,利用紅黑樹替換鏈表,這樣復(fù)雜度就變成了O(1)+O(logn)了,這樣在n很大的時(shí)候,能夠比較理想的解決這個(gè)問題,在Java 8:HashMap的性能提升一文中有性能測試的結(jié)果。
6. resize的實(shí)現(xiàn)
當(dāng)put時(shí),如果發(fā)現(xiàn)目前的bucket占用程度已經(jīng)超過了Load Factor所希望的比例,那么就會(huì)發(fā)生resize。在resize的過程,簡單的說就是把bucket擴(kuò)充為2倍,之后重新計(jì)算index,把節(jié)點(diǎn)再放到新的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.
大致意思就是說,當(dāng)超過限制的時(shí)候會(huì)resize,然而又因?yàn)槲覀兪褂玫氖?次冪的擴(kuò)展(指長度擴(kuò)為原來2倍),所以,元素的位置要么是在原位置,要么是在原位置再移動(dòng)2次冪的位置。
怎么理解呢?例如我們從16擴(kuò)展為32時(shí),具體的變化如下所示:
因此元素在重新計(jì)算hash之后,因?yàn)閚變?yōu)?倍,那么n-1的mask范圍在高位多1bit(紅色),因此新的index就會(huì)發(fā)生這樣的變化:
因此,我們在擴(kuò)充HashMap的時(shí)候,不需要重新計(jì)算hash,只需要看看原來的hash值新增的那個(gè)bit是1還是0就好了,是0的話索引沒變,是1的話索引變成“原索引+oldCap”。可以看看下圖為16擴(kuò)充為32的resize示意圖:
這個(gè)設(shè)計(jì)確實(shí)非常的巧妙,既省去了重新計(jì)算hash值的時(shí)間,而且同時(shí),由于新增的1bit是0還是1可以認(rèn)為是隨機(jī)的,因此resize的過程,均勻的把之前的沖突的節(jié)點(diǎn)分散到新的bucket了。
下面是代碼的具體實(shí)現(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) {// 超過最大值就不再擴(kuò)充了,就只好隨你碰撞去吧if (oldCap >= MAXIMUM_CAPACITY) {threshold = Integer.MAX_VALUE;return oldTab;}// 沒超過最大值,就擴(kuò)充為原來的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 thresholdnewCap = oldThr;else { // zero initial threshold signifies using defaultsnewCap = DEFAULT_INITIAL_CAPACITY;newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);}// 計(jì)算新的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) {// 把每個(gè)bucket都移動(dòng)到新的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 orderNode<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;elseloTail.next = e;loTail = e;}// 原索引+oldCapelse {if (hiTail == null)hiHead = e;elsehiTail.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. 總結(jié)
1. 什么時(shí)候會(huì)使用HashMap?他有什么特點(diǎn)?
是基于Map接口的實(shí)現(xiàn),存儲鍵值對時(shí),它可以接收null的鍵值,是非同步的,HashMap存儲著Entry(hash, key, value, next)對象。
2. 你知道HashMap的工作原理嗎?
通過hash的方法,通過put和get存儲和獲取對象。存儲對象時(shí),我們將K/V傳給put方法時(shí),它調(diào)用hashCode計(jì)算hash從而得到bucket位置,進(jìn)一步存儲,HashMap會(huì)根據(jù)當(dāng)前 bucket的占用情況自動(dòng)調(diào)整容量(超過Load Facotr則resize為原來的2倍)。獲取對象時(shí),我們將K傳給get,它調(diào)用hashCode計(jì)算hash從而得到bucket位置,并進(jìn)一步調(diào) 用equals()方法確定鍵值對。如果發(fā)生碰撞的時(shí)候,Hashmap通過鏈表將產(chǎn)生碰撞沖突的元素組織起來,在Java 8中,如果一個(gè)bucket中碰撞沖突的元素超過某個(gè)限制(默認(rèn)是8),則使用紅黑樹來替換鏈表,從而提高速度。
3. 你知道get和put的原理嗎?equals()和hashCode()的都有什么作用?
通過對key的hashCode()進(jìn)行hashing,并計(jì)算下標(biāo)( n-1 & hash),從而獲得buckets的位置。如果產(chǎn)生碰撞,則利用key.equals()方法去鏈表或樹中去查找對應(yīng)的節(jié)點(diǎn)
4. 你知道hash的實(shí)現(xiàn)嗎?為什么要這樣實(shí)現(xiàn)?
在Java 1.8的實(shí)現(xiàn)中,是通過hashCode()的高16位異或低16位實(shí)現(xiàn)的:(h = k.hashCode()) ^ (h >>> 16),主要是從速度、功效、質(zhì)量來考慮的,這么做可以在bucket的n比較小的時(shí)候,也能保證考慮到高低bit都參與到hash的計(jì)算中,同時(shí)不會(huì)有太大的開銷。
5. 如果HashMap的大小超過了負(fù)載因子(load factor)定義的容量,怎么辦?
如果超過了負(fù)載因子(默認(rèn)0.75),則會(huì)重新resize一個(gè)原來長度兩倍的HashMap,并且重新調(diào)用hash方法。
關(guān)于Java集合的小抄中是這樣描述的:
以Entry[]數(shù)組實(shí)現(xiàn)的哈希桶數(shù)組,用Key的哈希值取模桶數(shù)組的大小可得到數(shù)組下標(biāo)。插入元素時(shí),如果兩條Key落在同一個(gè)桶(比如哈希值1和17取模16后都屬于第一個(gè)哈希桶),Entry用一個(gè)next屬性實(shí)現(xiàn)多個(gè)Entry以單向鏈表存放,后入桶的Entry將next指向桶當(dāng)前的Entry。查找哈希值為17的key時(shí),先定位到第一個(gè)哈希桶,然后以鏈表遍歷桶里所有元素,逐個(gè)比較其key值。當(dāng)Entry數(shù)量達(dá)到桶數(shù)量的75%時(shí)(很多文章說使用的桶數(shù)量達(dá)到了75%,但看代碼不是),會(huì)成倍擴(kuò)容桶數(shù)組,并重新分配所有原來的Entry,所以這里也最好有個(gè)預(yù)估值。取模用位運(yùn)算(hash & (arrayLength-1))會(huì)比較快,所以數(shù)組的大小永遠(yuǎn)是2的N次方, 你隨便給一個(gè)初始值比如17會(huì)轉(zhuǎn)為32。默認(rèn)第一次放入元素時(shí)的初始值是16。iterator()時(shí)順著哈希桶數(shù)組來遍歷,看起來是個(gè)亂序。在JDK8里,新增默認(rèn)為8的閥值,當(dāng)一個(gè)桶里的Entry超過閥值,就不以單向鏈表而以紅黑樹來存放以加快Key的查找速度。參考:
http://yikun.github.io/2015/04/01/Java-HashMap%E5%B7%A5%E4%BD%9C%E5%8E%9F%E7%90%86%E5%8F%8A%E5%AE%9E%E7%8E%B0/
總結(jié)
以上是生活随笔為你收集整理的HashMap源代码深入剖析的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: mxGraph实现按住ctrl键盘拖动图
- 下一篇: 怎么调试EXC_BAD_ACCESS错误