jdk-HashMap-1.8

劉二郎發表於2018-07-11

由於jdk版本的升級導致原始碼的更新,因此hashmap的原始碼需要重新讀一下,不過在本文記錄時jdk的版本早就不是8版本了,只不過是1.7和1.8發生了本質的變化,因此才記錄一下的。至於9,10版本,暫時不管了。

為了重新去讀1.8版本的hashmap原始碼,特此做了些前期準備:

紅黑樹系列

jdk1.7

jdk1.7補充文章

1.總述

關於之前學習的1.7版本,我著重學習了幾個點,建構函式(容量大小,載入因子),put(),get(),擴容機制,擴容時機,hashcode的產生,hash衝突,執行緒安全問題,當然還有最重要的底層結構(陣列+連結串列)。可以說關於hashmap,可學習和可關注的點太多太多。因此,文章不可能所有的都能涉及到,只能儘可能的去學習和理解。

那麼在學習之前呢,我已經瞭解到底層結構的變成了陣列+連結串列+紅黑樹。so,著重關注下紅黑樹部分應該說就能將1.8的原始碼拿下了。

1.原始碼分析

建構函式部分:
    //建構函式1
    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); //1
    }
    //建構函式2
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    //建構函式3
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
    //建構函式4
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }
建構函式3:常用建構函式,一般不指定大小,建構函式3只是指定了loadFactory(載入因子)的值,其他的值都沒賦值。應該是後續由初始化的操作。

建構函式2和建構函式1:內部呼叫建構函式1指定一些常用值的初始值,這和1.7一致,不同點在於threshold的值的確定

threshold(閾值)的確定

在1.7內,通過建構函式的操作就確定了,如下:

例如,建構函式是new HashMap(7),那麼capacity就是8,而threshold就是8*0.75 = 6。

//設定capacity為大於initialCapacity且是2的冪的最小值
while (capacity < initialCapacity)
	capacity <<= 1;
this.loadFactor = loadFactor;
threshold = (int)(capacity * loadFactor);

而在1.8內,確不是如此,而是經歷過兩次操作,但是本質是還是一致的。首先建構函式內得到一個threshold的值,例如構造韓式是new HashMap(7),那麼此處的threshold的值就是8。

this.threshold = tableSizeFor(initialCapacity);

但是在擴容resize()函式內,還存在一部分額外的初始化動作,threshold的值也在其內,最終threshold的值依然是8*0.75=6;

        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;

可以說tableSizeFor()函式的作用其實可以認為是capacity的獲取的作用(得到大於initialCapacity且是2的冪的最小值)。

tableSizeFor()
    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;
    }

此處 n |= n >>>1 等價於 n = n | n>>>1; >>>是無符號右移動。

例如 new HashMap(53);圖解如下:


put()
先拋開紅黑樹邏輯不看,put的邏輯和之前還是有點區別的,不過本質也還是沒變,主要還是採用不同的理念將元素插入到連結串列中。
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            //初始化陣列table,初始化操作延遲到有新資料插入時並且合併到擴容邏輯內
            n = (tab = resize()).length;//返回table桶的大小,預設還是16
        if ((p = tab[i = (n - 1) & hash]) == null)
            //定位key的hash值和桶的大小進行按位與操作,確定在桶內位置
            //如果沒有發生衝突,構造新的Node節點,進行插入
            tab[i] = newNode(hash, key, value, null);
        else {
            //存在衝突
            Node<K,V> e; K k;
            if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                //當前key和桶內的第一個Node的key相等,則指向它
                e = p;
            else if (p instanceof TreeNode)
                //如果桶內元素是紅黑樹,則進行紅黑樹邏輯
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                //此邏輯段均是在當期key和第一個key不相等的時候迴圈的
                //遍歷找到的當前桶內元素,並記錄當前元素個數
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        //如果不存在相同的key,則將元素連線到此連結串列後面
                        p.next = newNode(hash, key, value, null);
                        //如果數量超過閾值,則轉成紅黑樹
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    //如果遍歷的時候找到某個key和當前key相等,則跳出迴圈
                    if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    //指標後移
                    p = 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:如果當前桶內的連結串列長度大於等於8個時,進入轉變流程。
 if (binCount >= TREEIFY_THRESHOLD - 1) 
條件2:當table的長度超過64時,才會將這一部分連結串列結構轉成紅黑樹,不然依然是擴容。 
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
treeifyBin()

轉成紅黑樹的程式碼也是比較重點的一個部分,在文章的開頭,關於紅黑樹的插入,刪除和理論知識已經給出,不熟悉的可以先去練練手。

    final void treeifyBin(HashMap.Node<K,V>[] tab, int hash) {
        int n, index; HashMap.Node<K,V> e;
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            //桶的長度小於64,只擴容,不轉紅黑樹
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            //hd頭結點,tl尾節點
            HashMap.TreeNode<K,V> hd = null, tl = null;
            do {
                //先轉成樹型節點
                HashMap.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); //轉成紅黑樹
        }
    }

上面的put原始碼中已經分析過轉成紅黑樹的兩個條件了,連結串列長度>=8以及桶的大小超過64時才會轉。

個人猜測原因:桶的容量在比較小時,hash衝突會比較高,擴容會非常頻繁,如果此時就轉成紅黑樹,那麼優先擴容的話會減小不必要的樹化過程,另一個減小擴容時的紅黑樹的重新對映的複雜度。

treeify()
    final void treeify(Node<K,V>[] tab) {
        TreeNode<K,V> root = null;
        for (TreeNode<K,V> x = this, next; x != null; x = next) {
            next = (TreeNode<K,V>)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<K,V> p = root;;) {
                    int dir, ph;
                    K pk = p.key;
                    //比較hash值,確定是左節點還是右節點
                    if ((ph = p.hash) > h)
                        dir = -1;
                    else if (ph < h)
                        dir = 1;
                    else if ((kc == null &&
                            (kc = comparableClassFor(k)) == null) ||
                            (dir = compareComparables(kc, k, pk)) == 0)
                        dir = tieBreakOrder(k, pk);  //hash值不能確定的,執行tieBreakOrder再次確認大小

                    TreeNode<K,V> xp = p;
                    if ((p = (dir <= 0) ? p.left : p.right) == null) {
                        x.parent = xp;
                        if (dir <= 0)
                            xp.left = x;
                        else
                            xp.right = x;
                        //修正操作
                        root = balanceInsertion(root, x);
                        break;
                    }
                }
            }
        }
        moveRootToFront(tab, root);
    }

HashMap在設計之初可以發現,鍵物件可以是任意物件,因此可能自定義的鍵物件沒有實現comparable介面,因此如何比較鍵物件的大小就變得複雜的多。

所以在比較鍵物件大小時,1.8的程式碼中採取了3個步驟:

1. 比較hashcode的大小;

2. 檢測鍵物件是否實現了comparable介面,如果實現了則呼叫compareTo比較;

3. 都沒法比較則進行tieBreakOrder(class物件層面和system層面)比較;

balanceInsertion()
修正操作,關於修正操作我們去分析一下,場景的話我們借鑑文章最上面紅黑樹的理論分析來進行。基本和演算法導論裡的虛擬碼是一致的,看起來不費力。
    static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
                                                TreeNode<K,V> x) {
        //待插入節點是紅色的
        x.red = true;

        //xp=x.parent 待插入節點的父節點
        //xpp=xp.parent 待插入節點的祖父節點
        //xppl=xpp.left 待插入節點的祖父節點的左孩子節點
        //xppr=xpp.left 待插入節點的祖父節點的右孩子節點
        for (TreeNode<K,V> xp, xpp, xppl, xppr;;) {
            if ((xp = x.parent) == null) {
                //待插入節點就是根節點,設定為黑色就行
                x.red = false;
                return x;
            }
            else if (!xp.red || (xpp = xp.parent) == null)
                //如果待插入節點的父節點是根節點或者父節點是黑色,結束
                return root;
            if (xp == (xppl = xpp.left)) {
                //如果待插入節點的父節點是紅色且是祖父節點的左孩子
                if ((xppr = xpp.right) != null && xppr.red) {
                    //待插入節點的父節點是紅色(總條件) 且 叔叔節點是紅色
                    xppr.red = false; //設定叔叔節點是黑色
                    xp.red = false; //設定父節點是黑色
                    xpp.red = true; //設定祖父節點為紅色
                    x = xpp;  //設定祖父節點為當前節點,進行下一次修正
                }
                else {
                    if (x == xp.right) {
                        //待插入節點的父節點是紅色(總條件) 且 叔叔節點是黑色 且 待插入節點是父節點的右孩
                        root = rotateLeft(root, x = xp); //設定父節點為當前節點進行左旋
                        xpp = (xp = x.parent) == null ? null : xp.parent; //設定新的祖父節點
                    }
                    if (xp != null) {
                        //設定父節點為黑色
                        xp.red = false;
                        if (xpp != null) {
                            //設定祖父節點為紅色
                            xpp.red = true;
                            //以祖父節點為支點進行右旋
                            root = rotateRight(root, xpp);
                        }
                    }
                }
            }
            else {
                //映象操作,全部相反,left變更為right,right變更為left
                if (xppl != null && xppl.red) {
                    xppl.red = false;
                    xp.red = false;
                    xpp.red = true;
                    x = xpp;
                }
                else {
                    if (x == xp.left) {
                        root = rotateRight(root, x = xp);
                        xpp = (xp = x.parent) == null ? null : xp.parent;
                    }
                    if (xp != null) {
                        xp.red = false;
                        if (xpp != null) {
                            xpp.red = true;
                            root = rotateLeft(root, xpp);
                        }
                    }
                }
            }
        }
    }

插入整體流程圖


樹化簡易流程圖

樹化前


樹化後(樹化之前根據程式碼可知,是先轉成的樹型雙向連結串列,因此prev和next關係就保留下來了),這也是和1.7不同之處,1.7內只有單連結串列的結構。此處保留prev和next的關鍵因素我覺得應該是和後續如果再次轉成連結串列有關。


get()
get的主要流程其實和1.7沒什麼區別,在1.8的程式碼中,桶內第一個元素的重要性被提升了,主要還是因為紅黑樹的存在。
    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))))
                //比較桶內第一個元素的key是否相等,相等則直接返回
                return first;
            if ((e = first.next) != null) {
                if (first instanceof TreeNode)
                    //不相等的時候判斷是否是紅黑樹節點,進入紅黑樹流程
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                do {
                    //否則迴圈找到key相等的Node節點
                    if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }
resize()

最後一個關注點就是擴容,1.7的擴容針對元素就是重新rehash定位在新的桶裡面的位置。而1.8的程式碼發生了一些思路上的改變。

    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;
            }
            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;
        //以上和1.7一致,確定新桶大小和閾值大小等等常規引數的設定

        @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;
                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
                        //如果都不是,那就還是單連結串列結構
                        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;
    }
針對單連結串列的擴容

也就是上述程式碼進行do while迴圈的地方,此處的思路和1.7發生了一些改變。

1.8的思想是針對這一條單連結串列做一下歸類的操作,把位置沒有發生改變的歸成一類,位置發生改變的歸成另一類。具體是怎麼操作的呢?我們列舉一些簡單的例子一看便知:

例如我們現在有下面的一個基礎hashmap結構,大小是16;閾值是12=16*0.75;桶內位置=15 & key;整體過程如下圖:


小結一下:

這樣看來1.8裡,元素之間的相對位置並沒有發生改變,由於是分組的關係,所以最終只要將head節點接到新桶內即可。

但是1.7裡,如果單連結串列中的元素在新桶內具有相同的位置話,元素會倒置。

針對紅黑樹的擴容

    //紅黑樹整體思路和單連結串列思路一致,也是先分組,然後判斷是否需要轉化
    final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
        TreeNode<K,V> b = this;
        // Relink into lo and hi lists, preserving order
        TreeNode<K,V> loHead = null, loTail = null;
        TreeNode<K,V> hiHead = null, hiTail = null;
        int lc = 0, hc = 0;
        for (TreeNode<K,V> e = b, next; e != null; e = next) {
            //之前程式碼可知,在單鏈錶轉成紅黑樹之前保留了next和prev指標,因此可以通過這種方式遍歷
            next = (TreeNode<K,V>)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) {
            if (lc <= UNTREEIFY_THRESHOLD)
                //如果位置不變的元素個數小於6個,則轉成單連結串列
                tab[index] = loHead.untreeify(map);
            else {
                tab[index] = loHead;
                if (hiHead != null) // (else is already treeified)
                    //如果hiHead不為null,表明有元素從紅黑樹中移除,結構發生改變了,需要修正
                    loHead.treeify(tab);
            }
        }
        //下面同理
        if (hiHead != null) {
            if (hc <= UNTREEIFY_THRESHOLD)
                //如果個數小於6個,則轉成單連結串列
                tab[index + bit] = hiHead.untreeify(map);
            else {
                tab[index + bit] = hiHead;
                if (loHead != null)
                    hiHead.treeify(tab);
            }
        }
    }

紅黑樹的擴容部分和單連結串列方式一致,但是在此間還存在了紅黑樹向單連結串列的轉化,判斷個數是6。程式碼如下:就不做圖了。

    final Node<K,V> untreeify(HashMap<K,V> map) {
        Node<K,V> hd = null, tl = null;
        for (Node<K,V> q = this; q != null; q = q.next) {
            //從TreeNode向Node節點轉變,然後連線成單連結串列
            Node<K,V> p = map.replacementNode(q, null);
            if (tl == null)
                hd = p;
            else
                tl.next = p;
            tl = p;
        }
        return hd;
    }

1.7和1.8擴容問題的比較

1.7:

問題:連結串列的死迴圈(由於執行緒A操作了執行緒B擴容之後的正常的table陣列導致死迴圈)。

現象:同一個位置的元素如果擴容後還是相同的位置,會出現倒置的現象,當然這不是問題,只是演算法導致的。

1.8:

問題:不會出現連結串列的死迴圈(不針對紅黑樹的場景,只討論單連結串列),可能造成資料丟失。

現象:元素之間的相對位置不會發生改變。

程式碼的不同

//1.7
    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];  //針對每個元素的next指標連線到新的位置的後續元素之前
                newTable[i] = e; //針對每一個元素都連線到新的位置上
                e = next;
            }
        }
    }

    //1.8
    for (int j = 0; j < oldCap; ++j) {
        Node<K,V> e;
        if ((e = oldTab[j]) != null) {
            //將位置上的元素賦值給e,然後針對每一個Node節點置成null
            oldTab[j] = null;
            //...省略中間無關程式碼
            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; //位置改變的一組
            }
        }
        }
    }

比較程式碼就能發現,1.8內由於棧變數e儲存了此連結串列中的資料然後進行分組的關係,所以不可能出現死迴圈了,唯一的問題就是oldTab[j] = null;這個操作導致了元素被清空,也就是null的問題。所以在多執行緒下容易出現元素丟失。

總結:

1.8HashMap的正篇就到此為止吧,還有很多細節都沒涉及到,就留給以後補充吧,一下子也沒法方方面面的顧全到,一開始以為這一篇幅應該花不了多長時間,結果花了3天時間才整理了這麼點東西。主要沒有想到的是作者的處理思路發生了質的變化了。