java面試常問系列之HashMap詳細源碼分析。

HashMap源碼分析

hashmap一直都是面試的高頻問題。這次讓我們一起徹底幹掉它,以後再也不怕面試官問這個問題了。

首先我們先對這個方法的文檔閱讀一下,下面做個簡單大概的翻譯,先提前瞭解一下hashmap。

在這裏插入圖片描述

那麼,我們再來看一下hashmap中的一些成員變量

在這裏插入圖片描述
熟悉了這些變量之後,我們先大概瞭解一下在JDK8中,hashmap的結構是什麼樣子的。
在這裏插入圖片描述

在hashmap中,元素最小單位是entry,裏面存放的是一個key-value鍵值對,還有一種叫法是bucket(桶)。

在扣源碼的時候,我們先補點位運算知識:

符號 描述 運算規則
& 兩個位都爲1時,結果才爲1
| 兩個位都爲0時,結果才爲0
^ 異或 兩個位相同爲0,相異爲1
~ 取反 0變1,1變0
<< 左移 各二進位全部左移若干位,高位丟棄,低位補0
>> 右移 各二進位全部右移若干位,對無符號數,高位補0,有符號數,各編譯器處理方法不一樣,有的補符號位(算術右移),有的補0(邏輯右移)

那麼,我們從頭開始,先上一段代碼。

public static void main(String[] args) {
        //實例化一個無參HashMap對象
    	Map map = new HashMap();
    	//put一個值
        map.put("1","2");
    }

首先,先new一個HashMap對象,不傳入任何參數,使用默認參數。

//無參構造只是設置了一個默認加載因子爲0.75.並沒有初始化容量,懶加載機制
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

其次,調用其put方法:

//1、該方法是有返回值的。返回啥呢?
//2、調用putVal方法
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

//onlyIfAbsent和evict兩個參數暫時忽略掉
//我們分析一下這個putVal方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    //這個Node其實就是我們的entry,只是表示不一樣。
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //這個if條件其實就是用於初始化的。當數組tab爲空或者長度爲0時
    if ((tab = table) == null || (n = tab.length) == 0)
        //調用一下resize方法。在JDK8中,初始化容量和擴容都用resize方法進行.後面詳解
        n = (tab = resize()).length;
    //如果當前數組爲空,則直接put進去。(n-1)& hash涉及位運算,作用就是計算出tab數組下標。n-1是爲了避免
    //剛好n是2的冪次方。出現數組下標越界的情況
    //舉例:hash:0101 1010  n-1(15):0000 1111  進行&位運算,0000 1010(10)
    //即i=10,此時,數組爲空
    if ((p = tab[i = (n - 1) & hash]) == null)
        //將k-v:"1"-"2"放到tab數組下標爲10的位置。newNode其實就是一個entry,不展開講
        tab[i] = newNode(hash, key, value, null);
    //這個地方是什麼情況呢?其實就是當hash衝突時,即數組tab上已經存在k-v時的情況
    else {
        Node<K,V> e; K k;
        //先判斷hash衝突時,數組中Node是否是key相等,將e賦值爲p,即爲目標值
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            //如果是key相等,將新建k-v對象e賦值爲p(當前數組中的k-v對象)
            e = p;
        //判斷是否是紅黑樹中節點
        else if (p instanceof TreeNode)
            //是的話,存放到紅黑樹中,能力有限,紅黑樹就不展開講。
            //用紅黑樹是介於平衡二叉樹和單鏈表結構綜合考量,讀寫效率介於兩者之間
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            //循環鏈表中的Node對象
            for (int binCount = 0; ; ++binCount) {
                //如果尾結點爲null,則將對象放置在尾部
                //在JDK7中,是採用頭插法,多線程出現循環鏈表,JDK8中使用尾插法,避免了該情況 
                if ((e = p.next) == null) {
                    //循環鏈表
                    //剛好循環到尾部時。put這個新元素
                    p.next = newNode(hash, key, value, null);
                    //當鏈表大小超過8-1=7時,鏈表轉換成紅黑樹
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                //這個其實就是判斷是否要覆蓋鏈表中有key相等的value
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                //p爲當前對象e,循環下去
                p = e;
            }
        }
        //如果e不爲null,則表示存在key相同,使用傳入的value覆蓋當前value
        if (e != null) { // existing mapping for key
            //原值賦值給oldVlaue
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            //並返回這個oldValue值
            return oldValue;
        }
    }
    ++modCount;
    //如果當前元素個數大於16*0.75=12.觸發擴容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    //沒有重複key則返回null值
    return null;
}

上面是HashMap的put方法,我們可以根據下面的流程圖進行一個簡單的理解。

在這裏插入圖片描述

在putVal方法中,我們可以看到,當map初始化或者Node個數超過閾值,默認也就是12時,會觸發擴容機制。其實還有一種情況會觸發擴容,後再說。

resize方法詳解

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    //初始化時,oldCap爲0,擴容時則爲原數組長度
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    //將參數threshold閾值設置爲當前map的閾值
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        //如果達到最大值,就不擴容了
        if (oldCap >= MAXIMUM_CAPACITY) {
            //閾值設爲int的最大值
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //擴容爲原來的2倍,並保證要小於最大容量,且原來容量大於默認初始容量
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            //閾值也同樣翻倍
            newThr = oldThr << 1; // double threshold
    }
    //這個地方注意,是給有參HashMap的初始化,利用閾值做參數,具體如果操作我們後面
    //結合有參構造和tableSizeFor方法講一下。
    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);
    }
    
    //給閾值賦值,針對有參構造。即新閾值爲0時,尚未進行賦值
    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
    table = newTab;
    //開始數組的轉移,前提是舊數組不爲空
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                //清空當前數組
                oldTab[j] = null;
                //如果當前節點的下一個節點爲空,即該節點爲尾部
                if (e.next == null)
                    //將該節點放入到新數組,並重新計算下標。其實下標是有規律的
                    //前一個例子:hash:0101 1010  n-1(擴容後31):0001 1111  進行&位運算,0001 1010(26)
                    //轉移到新數組後要麼是原來的位置(10),要麼是原位置下標+擴容的大小(10+16).
                    newTab[e.hash & (newCap - 1)] = e;
                //如果是紅黑樹節點
                else if (e instanceof TreeNode)
                    //調用紅黑樹的方法
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    //鏈表拆分,這段很精彩同學們,注意聽
                    //定義了兩個鏈表,lo鏈表和hi鏈表。其中loHead,loTail,hiHead,hiTail可以分別看做頭結點和尾結點
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    //循環體
                    do {
                        next = e.next;
                        //hash衝突並不是hash值一模一樣,所以運算出結果會有兩個,要麼等於0,要麼不等於0;
                        //我們將運算結果爲0的看做是lo鏈表,就好理解了。可以去翻一下後面那張圖,比較形象
                        if ((e.hash & oldCap) == 0) {
                            //如果lo鏈表尾結點是空值,代表lo鏈表還沒有元素存在
                            if (loTail == null)
                                //將頭結點指向e元素
                                loHead = e;
                            else
                                //有頭之後就將loTail的下一個後移
                                loTail.next = e;
                            //同時移動loTail指針
                            loTail = e;
                        }
                        else {
                            //hi鏈表也一樣
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    //將鏈表遷移到新數組原下標位置
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    //將鏈表遷移到新數組原下標+oldCap處
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

hash 衝突並不代表hash值是一模一樣的,只能表示hash值的低位是相同的,比如說:

//hashA:1010 1111    hashB:0011 1111   hashC:1001 1111  當數組大小都爲16時
//三者對應數組下標爲hashA:0000 1111(15)
//但是e.hash&oldCap分別爲:0000 0000;0001 0000;0001 0000

在這裏插入圖片描述
以上,就完成了一個HashMap的初始化以及擴容過程。

那麼,我們之前提到,出了初始化和size大於閾值時會觸發resize擴容,還有一個地方會觸發擴容,我們看代碼:

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    //當前數組爲空,或者數組的大小小於最小樹容量
    //也就是說當數組比較小時,不會出現轉化爲紅黑樹,會進行擴容
    //儘可能將元素存放在數組中,數組log(1)的效率還是要高一些
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

那麼,我們再來看看他的get方法:

//其實,弄清楚hashmap的底層結構和put方法,這個就比較簡單了
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) {
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

get方法其實不難理解。就是一個查找加比較的一個過程。

後面一些方法這裏也不再詳解了,有興趣的小夥伴自己可以去源碼看看。話說寫這個JDK的老哥是真的大佬,讓人膜拜了。

總結

1、JDK8採用的是數組+鏈表+紅黑樹的結構

2、JDK8採用尾插法,有效避免了多線程情況下JDK7出現的循環鏈表的情況

3、JDK8簡化hash算法。

4、擴容機制有區別。JDK7會rehash,JDK8不會,只是根據數組大小重新計算位置。

ps:以上內容難免有不正確的地方,希望您能夠及時指出,謝謝!

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