jdk-HashMap-1.8

由于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天时间才整理了这么点东西。主要没有想到的是作者的处理思路发生了质的变化了。

猜你喜欢

转载自blog.csdn.net/qq_32924343/article/details/80985037