要點
-
Java8對Java7的HashMap做了修改,最大的區別就是利用了紅黑樹。
-
Java7的結構中,查找數據的時候,我們會根據hash值快速定位到數組的具體下標。但是後面是需要通過 鏈表 去遍歷數據,所以查詢的速度就依賴於鏈表的長度,時間複雜度也自然是O(n)
-
爲了減少2中出現的問題,在Java8中,當鏈表的個數大於8的時候,就會把鏈表轉化爲紅黑樹。那麼在紅黑樹查找數據的時候,時間複雜度就變味了O(logN)
結構
結構圖
描述
-
數組中存放的是節點。
-
如果是鏈表,就是Node節點,紅黑樹的話則是TreeNode節點。
-
在Node節點中,都是keyt,value,hash,next這幾個屬性,和Java7的基本一樣。
-
我們根據存放在數組中的節點的類型,判斷是紅黑樹還是鏈表
構造方法
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); } 複製代碼
這個構造方法初始化了閾值和負載因子。
在構造方法中,是不會指定HashMap的容量大小的,就算是用 HashMap(int initCapacity)
的構造方法,傳入的數運算之後的結果後面只是初始化閾值,並沒有馬上構建內部的數組,至於初始化內部數組只有第一次put的時候纔會執行,初始化閾值是用來方便後面put的時候初始化數組。具體的還得需要讀者往下看, 只是說明下內部數組並不是在構造函數執行就已經初始化了。
tableSizeFor
這個函數就是將傳進來的數向上取到最接近2的冪次的數(包括等於)。比如傳入15,返回則是16;傳入18,返回32。傳入32,返回32。我們來看看他如何實現吧!
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; } 複製代碼
解釋
在解釋源碼之前我先說一下,傳入一個cap,可能它不是2的幾次冪,要找到大於等於cap的最小的2的冪
怎麼找呢?我們看看開始舉的例子(這裏先把18-1先,至於這個-1後面會講)
00000000,00000000, 00000000, 0001 0001 十進制:17
00000000,00000000, 00000000, 0010 0000 十進制:32
再看32-1的二進制是多少,和32和17的對比一下
00000000,00000000, 00000000, 00 10 0000 十進制:32
00000000,00000000, 00000000, 000 1 1111 十進制:31
00000000,00000000, 00000000, 000 1 0001 十進制:17
我們看到只要將17的後面5位全部變爲1,那麼就成31的二進制,後面再+1就可以變爲32,這就達到我們的目的了!一句話說,這個函數的目的就是從 最左邊的1開始往右,都要變爲1,後面再+1就可以達到我們想要的目的了!
過程
n|=n>>>1
由於n大於0,那麼在二進制中高位肯定有一位是1,那麼無符號右移1位與自己相或,那麼肯定是接近原來數的後面的數變爲1.比如10xxxx,那麼運算 n|= n>>>1之後,肯定是11xxxx。
n|=n>>>2
在上面的例子中,n已經由10xxxx變爲11xxxx了。那麼我們們要讓後面xxxx繼續變爲1,此時有兩位是1,那麼就讓xxxx的前兩位繼續和11相或唄。所以無符號右移兩位再與自己相或,就可以從11xxxx變爲1111xx了。
那麼現在有4個1,那麼後面就移4位。變爲8個1,就移8位....
依次類推。
如果要把32位變爲全1的話,只要先把前16位變爲1,那麼後面右移16位就可以把32位全部變爲1啦。
我們也可以看到,32位的1肯定是超過MAXIMUM_CAPACITY(1<<30),那麼後面結果就會變爲MAXIMUM_CAPACITY啦。
給大家看個圖把,或許會更加清楚(用的是別人圖)。其實上面說得很清楚啦!
概括
這裏說下爲什麼傳進來要先減1。有前面我們都知道,
相信上面的解釋和例子中你都應該理解吧! 二進制最左邊的1開始往右,都要變爲1 。如果傳進來的剛好是2的m次冪,那麼後面的n的二進制會變爲1+上m個1,那返回的時候再+1的話,就變爲1加上m+1個0了。 那麼就變爲傳進來的數字的兩倍了 。比如說傳進32,二進制爲1 00000 ,1後面5個0。後面是n變爲1 11111 ,返回的時候+1,那麼就返回1 000000 ,對應的十進制就是64了。這顯然不符我們的邏輯。
至於其它不是2的幾次冪的數,不管減不減1,只要最 左邊的那個1位置沒變 ,右邊不管是什麼,到後面都是可以變爲大於等於傳進來的數的最小的2的冪的。
所以綜上,傳進來的cap-1,就是爲了防止傳進來的cap是剛好的2的冪次數,避免後面返回的時候翻倍。
Put
public V put(K key, V value) { //關於hash函數,後面會說 return putVal(hash(key), key, value, false, true); } //---------putVal //onlyIfAbsent如果爲true的表示的是:如果key不存在就存入,存在就不存入 //爲false:key存在也存入,只不過會覆蓋舊值,然後把舊值返回 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; //第一次put,會執行下面的if裏面的 resize() //第一次resize就是相當於初始化, 一般都會設置爲16,後面擴容就不一樣了。 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //假設是第一次擴容,(n-1) & hash 相當於 hash對 n 求模 //這裏也就是將hash值對15求模就可以隨機得到一個下標啦 //如果這個位置沒有值,那麼就直接初始化一下Node並且放在hash映射到的位置 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); //這裏hash映射的數據已經有節點啦 else { Node<K,V> e; K k; //如果第一個節點就是我們想要找的那個節點,那麼e就執行這第一個節點 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); //到這裏,就是想要放入的key可能在第一個節點後邊或者這個key在鏈表中也不存在 else { //這裏binCount進行計數,主要是爲了記錄是否到達8個節點從而進行變形爲紅黑樹 for (int binCount = 0; ; ++binCount) { //如果到達最後一個節點,也就是這個key不存在的情況 if ((e = p.next) == null) { //新鍵節點放在鏈表的尾節點的後面,此時e已經爲null p.next = newNode(hash, key, value, null); //如果此時節點已經到達7個,那麼加入這個節點就成爲8個了,那麼就進行轉化爲紅黑樹啦 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); //這裏有兩種情況 1 是成功加入鏈表尾部,並且總數沒超過8 ,2是 加入節點之後,總數到達8,那麼就轉爲紅黑樹,就退出循環了 break; } //put的時候,如果key已經存在在鏈表中,那麼就退出,後面再進行 覆蓋 操作 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; //這裏是一直沒找到,遍歷鏈表的操作 p = e; } } //這裏e不爲null的情況就是put的key已經在之前的鏈表中, //爲null的話就是不在之前的鏈表中並且已經加入到之前鏈表的尾部 if (e != null) { V oldValue = e.value; //1 這個onlyIfAbsent之前也說過,爲false就可以 覆蓋 舊值 //2 或者之前就沒有值 //1 或者 2 就執行下面的if if (!onlyIfAbsent || oldValue == null) e.value = value; //這個函數只在LinkedHashMap中用到, 這裏是空函數 afterNodeAccess(e); return oldValue; } } ++modCount; //如果增加這個節點之後,超過了閾值,那麼就進行擴容 if (++size > threshold) resize(); //這個函數只在LinkedHashMap中用到, 這裏是空函數 afterNodeInsertion(evict); return null; } 複製代碼
hash方法
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } 複製代碼
在java中, hash函數是一個native方法, 這個定義在Object類中, 所以所有的對象都會繼承.
public native int hashCode(); 複製代碼
因爲這是一個本地方法, 所以無法確定它的具體實現, 但是從函數簽名上可以看出, 該方法將任意對象映射成一個整型值.調用該方法, 我們就完成了 Object -> int
的映射
在hash的實現中我們看到
-
如果key爲null,那麼這個值就是放在數組的第一個位置的。
-
如果key不爲null,那麼就會先去key的hashCode右移16位然後再與自己異或。
大家可能關於第2點有點疑問
其實也就是說,通過讓hashcode的高16位和低16位異或,通過高位對低位進行了干擾。目的就是爲了讓hashcode映射的數組下標更加平均。下面這段是引用論壇的匿名用戶的解釋,個人覺得解釋得很詳細
作者:匿名用戶
鏈接: www.zhihu.com/question/20…
來源:知乎
我們創建一個hashmap,其entry數組爲默認大小16。 現在有一個key、value的pair需要存儲到hashmap裏,該key的hashcode是0ABC0000(8個16進制數,共32位),如果不經過hash函數處理這個hashcode,這個pair過會兒將會被存放在entry數組中下標爲0處。下標=ABCD0000 & (16-1) = 0。 然後我們又要存儲另外一個pair,其key的hashcode是0DEF0000,得到數組下標依然是0。 想必你已經看出來了,這是個實現得很差的hash算法,因爲hashcode的1位全集中在前16位了,導致算出來的數組下標一直是0。於是,明明key相差很大的pair,卻存放在了同一個鏈表裏,導致以後查詢起來比較慢。 hash函數的通過若干次的移位、異或操作,把hashcode的“1位”變得“鬆散”,比如,經過hash函數處理後,0ABC0000變爲A02188B,0DEF0000變爲D2AFC70,他們的數組下標不再是清一色的0了。 hash函數具體的做法很簡單,你畫個圖就知道了,無非是讓各數位上的值受到其他數位的值的影響。
在源碼中我們看到 h&(n-1)
的操作,其實這樣是和 h % n
一樣的。只不過是一個很大的求模的時候會影響效率,但是通過位運算就快很多啦!
resize
-
resize()用於HashMap的 初始化數組 和 數組擴容 .
-
數組擴容之後,容量都是之前的2倍
-
進行數據遷移
final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; //如果是初始化,oldTab肯定null,反之就不是null 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) //到這裏,前面的容量就已經擴大一倍 //閾值也擴大了一倍 newThr = oldThr << 1; // double threshold } //這裏調用new HashMap(initCapacity),第一次put else if (oldThr > 0) //指定了容量,比如initCapacity指定了22,那麼newCap就是32 newCap = oldThr; //這裏調用new HashMap(),第一次put else { //容量就是默認類內部指定的容量,也就是16 newCap = DEFAULT_INITIAL_CAPACITY; //默認的加載因子是0.75,所以閾值就是16*0.75 = 12 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } //這裏的情況是 調用了new HashMap(initCapacity)或者 //new HashMap(initCapacity,loadFactor)的情況 //因爲上面的兩個構造函數都會初始化 loadFactor //就是根據新的容量初始化新的閾值 if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; //創建新的數組,賦給table,也就是實現了擴容 @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; if (oldTab != null) { //遍歷原數組進行數據轉移 for (int j = 0; j < oldCap; ++j) { Node<K,V> e; //獲取對應數組位置的節點,如果爲null表示沒節點 //否則就轉移 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 { //這裏定義了兩個鏈表,lo和hi //此時 e 就是數組所對應的第一個節點 Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; //下面這個do-while就是遍歷數組當前位置的鏈表,然後 //根據某些規則,把鏈表的節點放在擴容後的數組的不同位置 do { //獲取e的下一個節點 next = e.next; //下面的解釋可能有點難懂,待會看下面的解釋再看這裏即可 //1 如果節點hash運算後是老位置,那麼就用lo鏈表存儲 if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } //2 那麼就是新位置,就用hi鏈表存儲 else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); //這裏很簡單,lo鏈表不爲空,那麼數組的老位置就放lo的頭結點 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } //hi鏈表不爲空,那麼數組的老位置就放hi的頭結點 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; } 複製代碼
下面用一張圖解釋下數據轉移的過程。
擴容解釋
現在來解釋下resize源碼中的疑問
大家可能對 (e.hash & oldCap) == 0
這個判斷有點迷糊,下面就來解釋下。
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
由put方法的這兩個語句我們知道,這裏是通過 hash和數組的長度-1相與 得到節點映射到數組的哪個位置的。
一:
其實很簡單,n就是數組的長度,我們都知道,在HashMap中的數組長度肯定是2的m方。那麼n-1在二進制中就是m個全1咯,比如說數組的長度是16,16就是2的4次方,那麼16-1 = 15 就是4個全1(2進制) “1111”
-
沒擴容前就是用hash(key)之後得到的hash值 與 (n-1)相與 得到位置, 在上面的例子中也就是取出hash值的低4位 ,結果爲a。(結果肯定在0-15之間)
-
擴容後,數組變爲之前的2倍,那麼數組的長度就成爲32了,二進制就是100000,那麼照葫蘆畫瓢, 同一個 hash值 與(32-1)相與得到位置。也就是 取出hash值的低5位 ,結果爲b。(結果肯定在0-31之間)
二:
由1和2得知, b的二進制比a的二進制多了1位 ,前面4位是相同的。並且在二進制中,不是0就是1。
- 如果多出的1位是0,那麼b和a是一樣的。比如a 爲1001,b是01001,那麼a和b是相等的,也就是說 同一個節點在新數組的位置就是舊數組的原來的位置。
- 如果多出的1位是1,那麼b比a就是多了2的m次方。比如a 也是1001,十進制是9,b是11001,十進制是25,那麼b就比a多了2的4次方,而這個2的4次方剛好就是原來數組的長度oldCap。也就是說 也就是說同一個節點在新數組的位置就是舊數組的原來的位置加上2的4次方(沒擴容的數組的長度(oldCap) )。
所以我們得出結論,只要我們可以判斷擴容之後b比a多的那一位是1還是0(在例子中也就是第5位),就可以得出同一個節點在新數組的哪個位置了,一句話總結就是。 數組擴容後,同一個節點要麼在原來的位置,要麼在原來的位置加上沒擴容的數組的長度(oldCap) 。
那麼我們如何得到b比a多的那一位到底是啥呢?很簡單,就是 用hash值和oldCap相與即可 !比如說這裏的oldCap爲16,那麼二進制就是1後面加上m個0,也就是10000,也就是第m+1位爲1,用hash值與oldCap相與, 就可以得出hash值的第5位是啥啦 ,那麼就可以根據前面的"二"去判斷節點到底在新數組哪個位置啦。
讀者們看到這裏,就可以繼續回到源碼的1處去看,這時候應該就會豁然開朗啦!
擴容總結
擴容時,會將原table中的節點re-hash到新的table中, 但節點在新舊table中的位置存在一定聯繫: 要麼下標相同, 要麼相差一個 oldCap
(原table的大小).
從去年到現在,我根據市場技術棧的需求,整理了一套JAVA的最新教程,如果你現在也在學習Java,在入門學習Java的過程當中缺乏系統的學習教程,你可以加我的Java學習交流羣:【94687,1227】,獲取,羣裏還有學習手冊,面試題,開發工具,PDF文檔教程,可以自行下載