通俗易懂的HashMap(Java8)源碼解讀!

要點

  1. Java8對Java7的HashMap做了修改,最大的區別就是利用了紅黑樹。

  2. Java7的結構中,查找數據的時候,我們會根據hash值快速定位到數組的具體下標。但是後面是需要通過 鏈表 去遍歷數據,所以查詢的速度就依賴於鏈表的長度,時間複雜度也自然是O(n)

  3. 爲了減少2中出現的問題,在Java8中,當鏈表的個數大於8的時候,就會把鏈表轉化爲紅黑樹。那麼在紅黑樹查找數據的時候,時間複雜度就變味了O(logN)

結構

結構圖

描述

  1. 數組中存放的是節點。

  2. 如果是鏈表,就是Node節點,紅黑樹的話則是TreeNode節點。

  3. 在Node節點中,都是keyt,value,hash,next這幾個屬性,和Java7的基本一樣。

  4. 我們根據存放在數組中的節點的類型,判斷是紅黑樹還是鏈表

構造方法

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的實現中我們看到

  1. 如果key爲null,那麼這個值就是放在數組的第一個位置的。

  2. 如果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

  1. resize()用於HashMap的 初始化數組 和 數組擴容 .

  2. 數組擴容之後,容量都是之前的2倍

  3. 進行數據遷移

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”

  1. 沒擴容前就是用hash(key)之後得到的hash值 與 (n-1)相與 得到位置, 在上面的例子中也就是取出hash值的低4位 ,結果爲a。(結果肯定在0-15之間)

  2. 擴容後,數組變爲之前的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文檔教程,可以自行下載

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章