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

前言

開局一張圖java

要點

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

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

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

結構

結構圖函數

描述oop

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

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

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

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

構造方法

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, 0010 0000 十進制:32

00000000,00000000, 00000000, 0001 1111 十進制:31

00000000,00000000, 00000000, 0001 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,二進制爲100000,1後面5個0。後面是n變爲111111,返回的時候+1,那麼就返回1000000,對應的十進制就是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的大小).

總結

  1. 本片文章主要說明了HashMap的一些重要的方法的源碼解析,也讓本身對HashMap有了進一步深刻的瞭解。
  2. 因爲HashMap是線程不安全的類,若是用線程安全的話,可使用CocurrentHashMap或者Collections.synchronizedMap將HashMap對象封裝成線程安全的。
  3. HashMap的key值是容許爲null的,它會把元素放在其維護的數組的第一個位置。
  4. HashMap在Java8以後引入了紅黑樹,1.8以前是沒有的,只是單純的鏈表,因此1.8版本更加靈活。
  5. 經過分析源碼瞭解到了各類位操做運算,也增強了本身的基礎知識。

參考

相關文章
相關標籤/搜索