Java基礎之HashMap原理分析(put、get、resize)


准備知識:hash知識

在分析HashMap之前,先看下圖,理解一下HashMap的結構

圖片

我手畫了一個圖,簡單描述一下HashMap的結構,數組+鏈表構成一個HashMap,當我們調用put方法的時候增加一個新的 key-value 的時候,HashMap會通過key的hash值和當前node數據的長度計算出來一個index值,然后在把 hash,key,value 創建一個Node對象,根據index存入Node[]數組中,當計算出來的index上已經存在了Node對象的話。就把新值存在 Node[index].next 上,就像圖中的 a->aa->a1 一樣,這樣的情況我們稱之為hash沖突

HashMap基本用法

Map<String, Object> map = new HashMap<>();
map.put("student", "333");//正常入數組,i=5
map.put("goods", "222");//正常入數據,i=9
map.put("product", "222");//正常入數據,i=2
map.put("hello", "222");//正常入數據,i=11
map.put("what", "222");//正常入數據,i=3
map.put("fuck", "222");//正常入數據,i=7
map.put("a", "222");//正常入數據,i=1
map.put("b", "222");//哈希沖突,i=2,product.next
map.put("c", "222");//哈希沖突,i=3,what.next
map.put("d", "222");//正常入數據,i=4
map.put("e", "222");//哈希沖突,i=5,student.next
map.put("f", "222");//正常入數據,i=6
map.put("g", "222");//哈希沖突,i=7,fuck.next

首先我們都是創建一個Map對象,然后用HashMap來實現,通過調用 put get 方法就可以實現數據存儲,我們就先從構造方法開始分析

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

初始化負載因子為0.75,負載因子的作用是計算一個擴容閥值,當容器內數量達到閥值時,HashMap會進行一次resize,把容器大小擴大一倍,同時也會重新計算擴容閥值。擴容閥值=容器數量 * 負載因子,具體為啥是0.75別問我,自己查資料吧(其實我是不知道,我覺得這個不重要吧~)

繼續看 put 方法

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

額,也沒啥可看的,繼續往下看putVal方法吧

transient Node<K,V>[] table;

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //先判斷當前容器內的哈希表是否是空的,如果table都是空的就會觸發resize()擴容
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //通過 (n - 1) & hash 計算索引,稍后單獨展開計算過程
    if ((p = tab[i = (n - 1) & hash]) == null)
        //如果算出來的索引上是空的數據,直接創建Node對象存儲在tab下
        tab[i] = newNode(hash, key, value, null);
    else {
        //如果tab[i]不為空,說明之前已經存有值了
        Node<K,V> e; K k;
        //如果key相同,則需要先把舊的 Node 對象取出來存儲在e上,下邊會對e做替換value的操作
        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 {
            //在這里解決hash沖突,判斷當前 node[index].next 是否是空的,如果為空,就直接
            //創建新Node在next上,比如我貼的圖上,a -> aa -> a1
            //大概邏輯就是a占了0索引,然后aa通過 (n - 1) & hash 得到的還是0索引
            //就會判斷a的next節點,如果a的next節點不為空,就繼續循環next節點。直到為空為止
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    //如果當前這個鏈表上數量超過8個,會直接轉化為紅黑樹,因為紅黑樹查找效率
                    //要比普通的單向鏈表速度快,性能好
                    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;
            }
        }
        //只有替換value的時候,e才不會空
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    //在增加計數器
    ++modCount;
    //判斷是否超過了負載,如果超過了會進行一次擴容操作
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

雖然寫我加了注釋,但是我還是簡單說一下這個的邏輯吧
1.首先判斷哈希表,是否存在,不存在的時候,通過resize進行創建
2.然后在通過索引算法計算哈希表上是否存在該數據,不存在就新增node節點存儲,然后方法結束
3.如果目標索引上存在數據,則需要用equals方法判斷key的內容,要是判斷命中,就是替換value,方法結束
4.要是key也不一樣,索引一樣,那么就是哈希沖突,HashMap解決哈希沖突的策略就是遍歷鏈表,找到最后一個空節點,存儲值,就像我的圖一樣。靈魂畫手有木有,很生動的表式了HashMap的數據結構
5.最后一步就是判斷是否到擴容閥值,容量達到閥值后,進行一次擴容,按照2倍的規則進行擴容,因為要遵循哈希表的長度必須是2次冪的概念

好,put 告一斷落,我們繼續 get

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

get方法,恩,好,很簡單。hash一下key,然后通過getNode來獲取節點,然后返回value,恩。get就講完了,哈哈。開個玩笑。我們繼續看getNode

    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        //哈希表存在的情況下,根據hash獲取鏈表的頭,也就是first對象
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            //檢測第一個first是的hash和key的內容是否匹配,匹配就直接返回
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            //鏈表的頭部如果不是那就開始遍歷整個鏈表,如果是紅黑樹節點,就用紅黑樹的方式遍歷
            //整個鏈表的遍歷就是通過比對hash和equals來實現
            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方法比put要簡單很多,核心邏輯就是取出來索引上的節點,然后挨個匹配hash和equals,直到找出節點。
那么get方法就搞定了

再來看一下resize吧。就是HashMap的擴容機制

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) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 對閥值進行擴容更新,左移1位代表一次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 threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //如果哈希表是空的,這里會進行初始化擴容閥值,
    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;
    //處理舊數據,把舊數據挪到newTab內,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)
                    newTab[e.hash & (newCap - 1)] = e;
                //紅黑樹處理
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    //對鏈表的索引重新計算,如果還是0,那說明索引沒變化
                    //如果hash的第5位等於1的情況下,那說明 hash & n - 1 得出來的索引已經發生變化了,變化規則就是 j + oldCap,就是索引內向后偏移16個位置
                    Node<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;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

resize方法的作用就是初始化容器,以及對容器做擴容操作,擴容規則就是double
擴容完了之后還有一個重要的操作就是會對鏈表上的元素重新排列

(e.hash & oldCap) == 0

在講這個公式之前,我先做個鋪墊

16的二進制是 0001 0000
32的二進制是 0010 0000
64的二進制是 0100 0000

我們知道HashMap每次擴容都是左移1位,其實就是2的m+1次冪,也就是說哈希表每次擴容都是 16、32、64........n
然后我們知道HashMap內的索引是 hash & n - 1,n代表哈希表的長度,當n=16的時候,就是hash & 0000 1111,其實就是hash的后四位,當擴容n變成32的時候,就是 hash & 0001 1111,就是后五位

我為啥要說這個,因為跟上邊的 (e.hash & oldCap) == 0 有關,這里其實我們也可以用

假設我們的HashMap從16擴容都了32。
其實可以用 e.hash & newCap -1 的方式來重新計算索引,然后在重排鏈表,但是源碼作者采用的是另外一種方式(其實我覺得性能上應該一樣)作者采用的是直接比對 e.hash 的第五位(16長度是后四位,32長度是后五位)進行 0 1校驗,如果為0那么就可以說明 (hash & n - 1)算出來的索引沒有變化,還是當前位置。要是第五位校驗為1,那么這里(hash & n - 1)的公式得出來的索引就是向數據后偏移了16(oldCap)位。

所以作者在這里定義了兩個鏈表,
loHead低位表頭,loTail低位表尾(靠近索引0)
hiHead高位表頭,hiTail高位表尾(遠離索引0)

然后對鏈表進行拆分,如果計算出來索引沒有變化,那么還讓他停留在這個鏈表上(拼接在loTail.next上)
如果計算索引發生了變化。那么數據就要放置在高位鏈表上(拼接在hiTail.next)上

最后來個靈魂配圖,鏈表重排
圖片
重拍完成后的HashMap
圖片

好了。HashMap就講完了,可能還需要自己消化消化,反正我是消化完了。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM