HashMap稍微詳細的理解

小雞小雞快點跑發表於2020-12-07

此文章用來記錄hashmap的一些特點(在學習中的所瞭解的,如有不足,請指正)

什麼是hash表

概念

先來一段百度百科的的解釋

雜湊表(Hash table,也叫雜湊表),是根據關鍵碼值(Key value)而直接進行訪問的資料結構。也就是說,它通過把關鍵碼值對映到表中一個位置來訪問記錄,以加快查詢的速度。這個對映函式叫做雜湊函式,存放記錄的陣列叫做雜湊表

給定表M,存在函式f(key),對任意給定的關鍵字值key,代入函式後若能得到包含該關鍵字的記錄在表中的地址,則稱表M為雜湊(Hash)表,函式f(key)為雜湊(Hash) 函式。

所謂的hash表在我看來嘛就是對映嘛,以前嘛要查詢一個數或者一個值嘛是通過遍歷的形式,這樣的話就會有一個問題,那就是太浪費時間了,時間效率非常低,也不能非常低嘛,時間複雜度是O(n)。於是呢,人們為了更快的找到需要查詢的值呢就想到了一種辦法,將儲存的位置與儲存的值對應起來,這樣查詢的效率不就高了很多。但是怎麼轉換呢,聰明的人類想到了一種辦法,利用一種函式對映的形式來解決,這個對映用的函式就叫做hash函式,這個表呢就叫hash雜湊表,但是呢這是有問題的。那就是

hash表衝突

很好理解嘛,不同的值可能經過hash函式生成同樣的索引,這樣的話就有衝突了,怎麼解決?請看

hash表衝突的解決

我所瞭解的常用的

  • 直接定址,也叫開放地址法,就是這個不能放我不放了,我放到下一個去,要是下一個還有就繼續往後直到找到可以插入的位置,要是都沒有,那就考慮一下擴容唄
  • hash再雜湊,就是用別的hash演算法再算一遍
  • 拉鍊法,這個方法就是hashmap中用到的方法。不是有衝突嘛,統統拿來,統統放這,一個別想跑。其實就是利用連結串列,衝突了就追加節點(不是同一個的話才追加)
  • 建立公共溢位區,就是衝突了嘛,沒坑了,那就走吧,不要呆在這裡了

以上就是我所瞭解的,估計也是常用的吧,不然我也不會了解

HashMap

map的意思嘛,就是對映,才不是地圖。Java中的HashMap就是利用hash表加連結串列實現的K,V形式的資料結構,和python中的字典是一樣的。hashmap中的hash衝突的解決利用的是拉鍊法。1.7之前的拉鍊是隻有連結串列,而在1.8增加了一個紅黑樹結構,這是因為,當連結串列長度太長的時候查詢效率比較低。所以在hash桶資料的容量大於等於64以及hash桶內的元素數量大於等於8時就會轉換為紅黑樹。今天我們進入原始碼一探究竟,先來看個靜態常量

static final int MIN_TREEIFY_CAPACITY = 64;

樹化:treeifyBin

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
//    如果說table 為null或者說容量小於64就擴容,不執行樹化
    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);
    }
}

上面的就是進行樹化的條件了,具體流程就算了吧,不看了

擴容:resize()方法

先準備一個重要的方法,resize()方法

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;//判斷老表是否為null為null的話長度就是0
    int oldThr = threshold; // 儲存原來的老閾值
    int newCap, newThr = 0; //先將新表的長度 閾值設定為0
    if (oldCap > 0) {
    //如果說老表的容量大於0且容量大於等於最大容量(MAXIMUM_CAPACITY = 1 << 30)
    //就將閾值設為Integer.MAX_VALUE,然後直接返回也就是不再擴容了,僅僅將閾值增大就行了
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
         //如果說老容量乘以2小魚最大容量以及大於等於預設的容量( DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16)
        // 就將原來的閾值也擴充為兩倍 就是說這裡沒啥意外容量就定下來了,也是一般的擴容情況
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    //如果說老表的容量小於等於0,但是老閾值大於0,就將新的容量設定為老閾值
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    //如果老表的容量以及老閾值都不大於0,就執行初始操作,將新表的容量設定為16,計算新表的閾值
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //如果說得出的新表閾值等於0的話就用新表的容量乘以負載因子,然後如果說新表的容量小於最大值以及新的閾值小於最大值,就將新閾值設為所求,否則就是Integer.MAX_VALUE
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
           MAX_VALUE       (int)ft : Integer.MAX_VALUE);
    }
    //到此用新閾值覆蓋老閾值閾值的更新操作完成
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    //利用新的容量建立一個表(這有個問題,就是如果是兩個執行緒的話會建立兩個表)
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    //新的容量覆蓋老的,容量至此也已確定
    table = newTab;
    //若原來的表不等於空,就進行移動,等於空的話就直接返回,因為原來的沒有東西嘛,也就不用轉移值了
    if (oldTab != null) {
        //這裡對老表進行遍歷,採用for迴圈
        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;
                //如果說e是treeNode節點,也就是說,這個hash桶裡邊的節點已經樹化過了
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                //下面這個else的意思是如果有下一個節點而且沒有樹化,也是說是連結串列形式的至少有兩個節點
                else { // preserve order
                    //這裡就是將連結串列分成兩種一種是高位連結串列一種是低位連結串列,至於什麼是高低位連結串列,我們們往下看
                    //loHead低位頭節點
                    //loTail低位尾節點
                    Node<K,V> loHead = null, loTail = null;
                    //hiHead高位頭節點
                    //hiTail低位尾節點
                    Node<K,V> hiHead = null, hiTail = null;
                    //next節點
                    Node<K,V> next;
                    //開始do..While迴圈,因為肯定有next節點嘛,不然也到不了這裡
                    do {
                        //儲存一下next節點
                        next = e.next;
                        //注意這個與的 是與原來的容量進行比較的沒有進行減一哈,減一是求索引用的。
                        //這裡的意思舉個例子來說就是比如原來的容量就是16吧,因為這裡是位運算嘛,轉換成二進位制就是10000
                        //因為這裡是等於0 的情況嘛,所以就假設e.hash二進位制為1011001111吧,索引算出來就是1111
                        //運算開始 因不足用0補齊嘛
                        //1011001111
                        //     10000
                        //----------
                        //0000000000
                        //嗯 就是這種情況,因為原來容量二進位制是5位也就是說如果hash值第五位是0,那麼就擴容以後不會有任何變化
                        //因為擴容是變為原來的2倍,也就是左移一位變為100000。
                        //那麼減1以後就是11111,刨去後邊的4個1,兩個最高位都是1也就是相同的,可以直接運算
                        //如果說此時元素的hash值在這個最高位是0的話,那麼算出的索引與原來是一樣的,這也就是低位索引
                        //這裡只是將低位放在一起
                        if ((e.hash & oldCap) == 0) {
                            //如果尾節點為空就初始化(說明頭節點也沒值)
                            if (loTail == null)
                                //這裡的頭節點指示頭所在的位置,以後追加就是用為節點了,高位連結串列一樣如此
                                loHead = e;
                            else
                                //讓尾節點的next指向e,
                                loTail.next = e;
                            //然後尾節點向後移一位
                            //這裡寫成loTail=loTail.next我感覺比較好理解一些
                            loTail = e;
                        }
                        //如果說不是0的話,說明hash值的高位是1,經過運算後就是11111就是原來的索引加上2^4
                        //就是原來的表的長度,所以高位連結串列只需要原來的索引加上原來的表的長度就是新的索引
                        //這裡只是將高位放到一起
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    //迴圈結束後,如果說低位連結串列不為空的話就說明執行了分高低位的工作,而且有低位的存在
                    //然後只需要將hash桶的節點指向低位連結串列的頭節點,而且因為是低位連結串列嘛,索引跟原來的一樣
                    if (loTail != null) {
                        //這裡將為節點的next設定為null,因為這再遍歷的時候尾節點的next與尾節點指向同一個位置
                        //因為已經遍歷完了嘛,next也就沒有值了,所以就清空。高位連結串列類似
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    //這裡判斷高位連結串列是否為空,空的就說明沒有高位連結串列嘛
                    //不空的話就將原來的索引加上老表的容量,至於為什麼,上面已經解釋過
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    //至此返回新的連結串列
    return newTab;
}

你以為到這裡就結束了?no,no,no,還有一個重要的方法,那就是如果是紅黑樹的話,怎麼進行操作呢,就是這個紅黑樹節點裡邊的split(this, newTab, j, oldCap)方法了

final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, 
                 int bit//這個bit什麼意思呢,我猜是老表的容量,上面傳過來的oldCap
                ) {
    TreeNode<K,V> b = this;
    // Relink into lo and hi lists, preserving order
    //開頭雷擊,夢迴連結串列
    //這怎麼和連結串列的操作差不多呢,也是分成高低位
    //其實註釋上面也寫了嘛
    //將樹箱中的節點拆分為較高和較低的樹箱,如果現在太小,則取消樹化。 
    //為什麼紅黑樹的節點也可以這樣呢,因為
    /**
     static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    
        // needed to unlink next upon deletion
        TreeNode(int hash, K key, V val, Node<K,V> next) {
            super(hash, key, val, next);
        }
    */
    //所以有個成員變數是next,那不就跟連結串列一樣了
    TreeNode<K,V> loHead = null, loTail = null;
    TreeNode<K,V> hiHead = null, hiTail = null;
    //這個lc吧 low count:低位的數量
    //hc呢 high count:高位的數量
    int lc = 0, hc = 0;
    //顯而易見,這裡遍歷treeNode
    //這裡為什麼不用while迴圈呢,是不用在外面宣告變數嘛
    for (TreeNode<K,V> e = b, next; e != null; e = next) {
        next = (TreeNode<K,V>)e.next;
        e.next = null;
        //這裡高低位一分,對應的count++就不展開細說了
        if ((e.hash & bit) == 0) {
            if ((e.prev = loTail) == null)
                loHead = e;
            else
                loTail.next = e;
            loTail = e;
            ++lc;
        }
        else {
            if ((e.prev = hiTail) == null)
                hiHead = e;
            else
                hiTail.next = e;
            hiTail = e;
            ++hc;
        }
    }

    if (loHead != null) {
        if (lc <= UNTREEIFY_THRESHOLD)
            //如果說數量小於等於UNTREEIFY_THRESHOLD=6,就弄成連結串列
            tab[index] = loHead.untreeify(map);
        else {
            tab[index] = loHead;
            //如果不小於6且高位連結串列不為null就樹化
            //為啥需要高位連結串列不為空呢,
            /**
            這裡個人理解,高位連結串列如果為空,說明舊陣列下的紅黑樹中的元素在新陣列中仍然全部在同一個位置,
            且先後順序沒有改變,也就是註釋中的已經樹化了,沒有必要再次樹化;而當高位節點不為空,
            說明原連結串列元素被拆分了,且位紅黑樹節點個數大於6,不滿足轉連結串列條件,需要重新樹化。
            此處來自https://blog.csdn.net/hengwu1817/article/details/107095871/ 
            */
            //下面的高位連結串列也是如此
            if (hiHead != null) // (else is already treeified)
                loHead.treeify(tab);
        }
    }
    if (hiHead != null) {
        if (hc <= UNTREEIFY_THRESHOLD)
            tab[index + bit] = hiHead.untreeify(map);
        else {
            tab[index + bit] = hiHead;
            if (loHead != null)
                hiHead.treeify(tab);
        }
    }
}

插入資料 put

呼叫put(k,v)方法實際上呼叫的是putVal方法

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

所以只需要分析putVal方法即可

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,//是否不存在才插入
               boolean evict//文件給的是建立表的模式,我的理解是可讀可寫
              ) {
    Node<K,V>[] tab; 
    Node<K,V> p; //p是table[i]所在的頭
    int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;//如果說原來的表不存在或者為空就執行resize()方法,上面已經進入看了一下
    //如果說原來的表的位置等於空的話就直接放進去 不存在衝突
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        //這裡才是重點 到這裡說明發生衝突
        Node<K,V> e;//e表示要要插入的節點
        K k;
        //這個判斷是看原來的老的hash值跟我傳進來的hash值是否相同並且key也相同 或者說key不為空並且相同
        //也就是判斷一下是不是相同的key 是的話就將p賦值給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);
        else {
            //不是的話就執行 也就是尋找要插入的位置的前一個節點
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    //如果說大於等於8-1也就是7的話就樹化 因為要插入元素了嘛,所以插入以後就等於8了
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                //這裡key相同的話就中斷
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        //找到了要插入的節點後 如果e不為null 說明key是一樣的 只需要替換一下值就好了
        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.7的hashmap的死迴圈問題以及本篇文章的1.8的死迴圈資料覆蓋問題,以後再總結

相關文章