Java集合——HashMap(jdk1.8)

午夜12點發表於2018-04-28

在上篇文章中我們大致介紹了HashMap原理,本文主要圍繞Java8HashMap做了哪些優化.

簡述

在上文提到jdk1.7中HashMap採用陣列+連結串列實現,雖然使用連結串列處理衝突,同一hash值的元素都儲存在一個連結串列中,但當同一連結串列上的元素較多又想要查詢最先插入的元素時,通過key依次尋找顯然效率較低.所以java8HashMap採用陣列+連結串列+紅黑樹方式實現,當連結串列長度超過閾值8時,會將連結串列轉換為紅黑樹.

資料結構

Java集合——HashMap(jdk1.8)

紅黑樹相關的重要欄位


    /**
     * 連結串列最大長度,若超過8,桶中連結串列轉成紅黑樹
     */ 
    static final int TREEIFY_THRESHOLD = 8;
    
    /**
     * 桶中結點小於該長度,紅黑樹轉成連結串列.中間有2個緩衝值的原因是避免頻繁的切換浪費計算機資源
     */
    static final int UNTREEIFY_THRESHOLD = 6;
    
    /**
     * 雜湊表的最小樹形化容量,只有鍵值對數量大於64才會發生轉換,將鏈式結構轉化成樹型結構,
     * 否則採用擴容來避免衝突,至少4*TREEIFY_THRESHOLD來避免擴容和樹形結構之間的衝突
     */
    static final int MIN_TREEIFY_CAPACITY = 64;
    
    /**
     * 相對於java7,8用node替換了Entry類,它們的結構大體相同.一個顯著的區別
     * node有派生類TreeNode,通過這種繼承關係,連結串列很容易轉換成樹
     */
    static class Node implements Map.Entry {
        final int hash;
        final K key;
        V value;
        Node next;

        Node(int hash, K key, V value, Node next) {...}
        public final K getKey()        {...}
        public final V getValue()      {...}
        public final String toString() {...}
        public final int hashCode() {...}
        public final V setValue(V newValue) {...}
        public final boolean equals(Object o) {...}
    }    
    
    static final class TreeNode extends LinkedHashMap.Entry {
        TreeNode parent;  //父結點
        TreeNode left;    //結點的左孩子
        TreeNode right;   //結點的右孩子
        TreeNode prev;    //前一個元素結點
        boolean red;           //true表示紅結點,false表示黑結點
        TreeNode(int hash, K key, V val, Node next) {...}
        final TreeNode root() {...}
        static  void moveRootToFront(Node[] tab, TreeNode root) {...}
        final TreeNode find(int h, Object k, Class kc) {...}
        final TreeNode getTreeNode(int h, Object k) {...}
        static int tieBreakOrder(Object a, Object b) {...}
        final void treeify(Node[] tab) {...}
        final Node untreeify(HashMap map) {...}
        final TreeNode putTreeVal(HashMap map, Node[] tab,
                                       int h, K k, V v) {...}
        final void removeTreeNode(HashMap map, Node[] tab,
                                  boolean movable) {...}
        final void split(HashMap map, Node[] tab, int index, int bit) {...}
        /* ------------------------------------------------------------ */
        // Red-black tree methods, all adapted from CLR
        static  TreeNode rotateLeft(TreeNode root,
                                              TreeNode p) {...}
        static  TreeNode rotateRight(TreeNode root,
                                               TreeNode p) {...}
        static  TreeNode balanceInsertion(TreeNode root,
                                                    TreeNode x) {...}
        static  TreeNode balanceDeletion(TreeNode root,
                                                   TreeNode x) {...}
        static  boolean checkInvariants(TreeNode t) {...}
    }
複製程式碼

TreeNode繼承關係圖:

Java集合——HashMap(jdk1.8)

1.8 HashMap方法

put方法

put操作進行如下步驟:
①.通過hash演算法計算key的hash值 ②.判斷雜湊表是否為空或為null,為空或為null時呼叫resize方法進行擴容
③.根據key計算hash值得到桶索引,若沒有碰撞即桶中無結點直接新增
④.若發生碰撞,判斷該桶中首個元素是否與key一樣,若相同記錄該結點
⑤.若不同,判斷該桶結構是否是紅黑樹,若是在樹中插入鍵值對
⑥.若不是紅黑樹,即為連結串列.遍歷連結串列,判斷連結串列長度是否大於8,若大於8將連結串列轉成紅黑樹,在紅黑樹中插入鍵值對;
⑦.若未超過8且找到key相同的結點記錄此結點,若沒有找到則尾插結點
⑧.若記錄的結點不為null且onlyIfAbsent為false或舊值為null進行替換返回舊值,否則不能替換
⑨.插入成功後size+1,校驗是否超過閾值threshold,若過載則擴容


    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
    
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    
    /**
     * @param hash          key經過hash計算後的hash值
     * @param key           鍵
     * @param value         值
     * @param onlyIfAbsent  若為true,不會替換value
     * @param evict         若為false,雜湊表在建立模式中
     * @return              返回被替換值.返回null可能被替換的就是null,或不存在key鍵物件
     *                      沒有進行替換操作
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node[] tab; Node p; int n, i;
        // 若雜湊表為空或為null,呼叫resize方法建立一個
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // 若沒有發生衝突,直接建立結點
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node e; K k;
            // 若桶中第一個結點的hash值相同並且equals方法返回true時進行替換
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            // 判斷是否為紅黑樹    
            else if (p instanceof TreeNode)
                e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
            // 為連結串列時    
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        //若連結串列中無相應key進行尾插
                        p.next = newNode(hash, key, value, null);
                        // 連結串列長度大於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;
                }
            }
            // 若結點不為null,將值進行替換,返回舊值
            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;
    } 
複製程式碼

java7HashMap的put操作與java8有如下區別:
①.java7HashMap採用連結串列處理衝突hash演算法對key的hash值做了4擾動,而java8引入了紅黑樹來處理較多的雜湊衝突,遍歷的時間複雜度由O(n)→O(logn)所以其簡化了他hash演算法將hash值的高位與低位進行混合
②.java7桶中僅可能是鏈式結構,而java8還可能是紅黑樹,所以java8只能先檢視桶中首個元素後需要判斷桶的資料結構根據其結構採用不同的方式處理
③.java7新增結點先判斷是否超過閾值threshold再新增,java8先新增後判斷
④.java7採用頭插,而java8若桶是鏈式結構採用尾插

樹形化

當桶中連結串列長度超過8時,會呼叫此方法連結串列結點轉紅黑樹結點進行如下操作:
①.判斷其是否符合樹形化條件,若不符合進行擴容解決更多衝突
②.若符合遍歷桶中連結串列所有結點,將頭結點設為紅黑樹的根結點,建立與連結串列結點內容一致的樹形結點


    final void treeifyBin(Node[] tab, int hash) {
        int n, index; Node e;
        //若當前雜湊表為空或長度小於最小化樹形容量進行擴容來解決更多衝突
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        //當前位置桶不為null
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            //紅黑樹頭結點,尾結點
            TreeNode hd = null, tl = null;
            do {
                //宣告樹形節點,內容和當前連結串列節點e一致
                TreeNode 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);
        }
    }
    
    TreeNode replacementTreeNode(Node p, Node next) {
        return new TreeNode<>(p.hash, p.key, p.value, next);
    }
複製程式碼

紅黑樹轉換步驟:
①.桶中第一個結點作為紅黑樹根節點(黑色)
②.遍歷其他結點,從根結點開始通過比較雜湊值尋找其位置
③.若x結點hash值小於p結點hash值,往p結點左邊尋找,否則往p結點右邊尋找
④.一直按照步驟③尋找,直至尋找的位置為null即此位置為x的目標位置
⑤.因為紅黑樹性質,其插入刪除都需要平衡調整
⑥.最後確保紅黑樹根結點為桶中第一個節點


        final void treeify(Node[] tab) {
            TreeNode root = null;
            for (TreeNode x = this, next; x != null; x = next) {
                next = (TreeNode)x.next;
                x.left = x.right = null;
                //第一個結點為根結點,必須黑色
                if (root == null) {
                    x.parent = null;
                    x.red = false;
                    root = x;
                }
                else {
                    K k = x.key;
                    int h = x.hash;
                    Class kc = null;
                    // 若不是根結點從根節點遍歷所有結點與結點x比較雜湊值找到其位置
                    for (TreeNode p = root;;) {
                        int dir, ph;
                        K pk = p.key;
                        // p雜湊值大於x雜湊值,dir為-1,從p的左邊找
                        if ((ph = p.hash) > h)
                            dir = -1;
                        // p雜湊值小於x雜湊值,dir為1,從p的右邊找
                        else if (ph < h)
                            dir = 1;
                        //雜湊值相等時   
                        else if ((kc == null &&
                        //comparableClassFor方法若x的Key實現了Comparable介面
                        //返回Key的執行時型別,否則返回null
                                  (kc = comparableClassFor(k)) == null) ||
                        //compareComparables方法,若pk與x的key型別相同
                        //返回k.compareTo(pk),否則返回0
                                 (dir = compareComparables(kc, k, pk)) == 0)
                        //若pk與k雜湊值相同無法比較,直接比較它們的引用地址   
                        //紅黑樹不像avl樹一樣需要高度平衡,其允許區域性很少的不完全平衡
                        //這樣對於效率影響不大省去了很多沒有必要的調平衡操作
                            dir = tieBreakOrder(k, pk);
                        //將p作為x的父節點,用於給下面的x父節點賦值
                        TreeNode xp = p;
                        //若dir不大於0則向p左邊查詢,否則向p右邊查詢
                        //如果為null則表示該位置為x的目標位置
                        if ((p = (dir <= 0) ? p.left : p.right) == null) {
                            x.parent = xp;
                            //dir不大於0,x為p的左節點
                            if (dir <= 0)
                                xp.left = x;
                            //dir大於0,x為p的右節點    
                            else
                                xp.right = x;
                            //保證插入後平衡
                            root = balanceInsertion(root, x);
                            break;
                        }
                    }
                }
            }
            //確保根節點是桶的第一個節點
            moveRootToFront(tab, root);
        } 
複製程式碼

moveRootToFront方法:


        static  void moveRootToFront(Node[] tab, TreeNode root) {
            int n;
            //雜湊表不為空且桶中有節點
            if (root != null && tab != null && (n = tab.length) > 0) {
                int index = (n - 1) & root.hash;
                //記錄桶中第一個元素
                TreeNode first = (TreeNode)tab[index];
                //若根節點不是桶中第一個節點
                if (root != first) {
                    Node rn;
                    //將根節點設為桶中第一個節點
                    tab[index] = root;
                    //獲取根節點的前驅節點
                    TreeNode rp = root.prev;
                    //若根節點後繼不為空則將其前驅指向根節點前驅節點
                    if ((rn = root.next) != null)
                        ((TreeNode)rn).prev = rp;
                    //若根節點前驅節點不為null則將其後繼指向根節點的後驅節點    
                    if (rp != null)
                        rp.next = rn;
                    //若原桶中第一個結點不為null則將其前驅指向根節點
                    if (first != null)
                        first.prev = root;
                    //根節點後繼指向原桶中第一個結點    
                    root.next = first;
                    //根節點前驅設為null
                    root.prev = null;
                }
                //斷言檢測其是否符合紅黑樹性質
                assert checkInvariants(root);
            }
        }  
複製程式碼

擴容機制

①.分支1:若原雜湊表不為空,判斷其容量是否超過最大容量,若超過則將其閾值設為int最大值返回原雜湊表無法擴容,若沒超過再判斷原雜湊表容量的2倍是否最大容量且不小於16,符合條件將閾值設為原閾值兩倍
②.分支2:若原雜湊表容量為0,閾值大於0(初始化容量0的HashMap),將新表容量設為原表的閾值
③.分支3:若原雜湊表容量為0,閾值也為0(無參構造),將容量和閾值設為預設值
④.當分支2成立,計算新的resize上限(正常情況新閾值為新容量*負載因子)
⑤.將計算好的新閾值設為當前閾值,以計算好的新容量定義新表
⑥.若原雜湊表不為空則將其結點轉移到新table中


    /**
     * 對雜湊表初始化或擴容
     * 若雜湊表為null則對其進行初始化
     * 擴容後結點要麼原位置,要麼在原位置偏移舊容量的位置
     */
    final Node[] resize() {
        // 記錄當前雜湊表
        Node[] 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;
            }
            //若當前容量的兩倍小於最大容量且當前容量不小於預設初始容量(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且當前閾值為0
        else {               // zero initial threshold signifies using defaults
            // 新容量設為預設初始化容量
            newCap = DEFAULT_INITIAL_CAPACITY;
            // 設定新閾值
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        //計算新的resize上限
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            //若新容量 < 最大容量且ft < 最大容量,新閾值為ft,否則為int最大值
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        //將閾值設為newThr
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
            //建立新陣列
            Node[] newTab = (Node[])new Node[newCap];
        //將當前雜湊表設為擴容後的newTab    
        table = newTab;
        //若原雜湊表不為空則將其結點轉移到新table中
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node e;
                //若原桶中有結點
                if ((e = oldTab[j]) != null) {
                    //將舊桶置空便於gc
                    oldTab[j] = null;
                    //若桶中只有一個結點,重新計算位置新增
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    //若桶結構為紅黑樹    
                    else if (e instanceof TreeNode)
                        //分割樹中結點
                        ((TreeNode)e).split(this, newTab, j, oldCap);
                    //鏈式優化重hash的程式碼塊    
                    else { // preserve order
                        Node loHead = null, loTail = null;
                        Node hiHead = null, hiTail = null;
                        Node next;
                        do {
                            next = e.next;
                            // 原索引
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            // 原索引+oldCap
                            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;
                        }
                        // 原索引+oldCap放到桶中
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    } 
複製程式碼

java8的resize方法具有了擴容和初始化功能相對於7,其沒有重新計算hash值,只需要看新增的1bit是0還是1可以認為是隨機的,因此resize過程,均勻地把之前的衝突結點分散到新的bucket,這塊便是java8新增的優化點,並且java7rehash時新表的陣列索引位置相同,連結串列元素會倒置也因為倒置多執行緒可能會出現死迴圈,而java8順序一致不會出現此場景.

split方法


        final void split(HashMap map, Node[] tab, int index, int bit) {
            //獲取呼叫此方法結點
            TreeNode b = this;
            //儲存與原索引位置相同的結點
            TreeNode loHead = null, loTail = null;
            //儲存原索引+oldCap的結點
            TreeNode hiHead = null, hiTail = null;
            int lc = 0, hc = 0;
            for (TreeNode e = b, next; e != null; e = next) {
                next = (TreeNode)e.next;
                e.next = null;
                //擴容後與原位置相同,尾插
                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) {
                //桶中結點數少於6個將紅黑樹轉為連結串列
                if (lc <= UNTREEIFY_THRESHOLD)
                    tab[index] = loHead.untreeify(map);
                else {
                    tab[index] = loHead;
                    //重新構建紅黑樹
                    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);
                }
            }
        } 
複製程式碼

untreeify方法:


        /**
         * 紅黑樹轉為連結串列
         */
        final Node untreeify(HashMap map) {
            Node hd = null, tl = null;
            //從呼叫此方法結點開始遍歷,將所有結點轉為連結串列結點
            for (Node q = this; q != null; q = q.next) {
                Node p = map.replacementNode(q, null);
                //第一個結點為頭結點,其餘逐個尾插
                if (tl == null)
                    hd = p;
                else
                    tl.next = p;
                tl = p;
            }
            return hd;
        } 
複製程式碼

小結

最主要講了put方法、擴容機制以及連結串列與樹間轉換,java8中HashMap引入了紅黑樹,因為紅黑樹查詢時間複雜度為O(logn)能解決較多雜湊衝突問題,所以簡化了其hash演算法,採用尾插方式新增結點.其次擴容也不需要重新計算hash值,擴容後的結點位置(原位置/偏移舊容量)均勻地把之前的衝突結點分散到新的桶中,且不會出現死迴圈,但是其依舊執行緒不安全.
在本節中並沒有提及get方法,因為當理解了put原理,get操作與之雷同.也未涉及太多紅黑樹相關東西,我想等介紹treemap再詳細討論.

問題

1.為什麼轉紅黑樹的閾值是8?
我們可以從HashMap原始碼中有一段註釋說明,理想情況下使用隨機的雜湊碼,容器中結點分佈在hash桶中的頻率遵循泊松分佈(詳情),按照泊松分佈的計算公式計算出了桶中元素個數和概率的對照表可以看到連結串列中元素個數為8時的概率已經非常小,再多的就更少了,所以選擇了8.
桶中元素個數和概率的關係如下:

數量 概率
0 0.60653066
1 0.30326533
2 0.07581633
3 0.01263606
4 0.00157952
5 0.00015795
6 0.00001316
7 0.00000094
8 0.00000006

參考

https://blog.csdn.net/v123411739/article/details/78996181
https://tech.meituan.com/java-hashmap.html
https://www.toutiao.com/a6542437571140518414/

相關文章