美文网首页Java从入门到实践
Java集合框架—HashMap—源码研读-2

Java集合框架—HashMap—源码研读-2

作者: Sunflow007 | 来源:发表于2020-03-08 10:53 被阅读0次
    5.jpg

    前言:

    本篇是HashMap系列的第二篇,上一篇:Java集合框架—HashMap—源码深入分析1 我们主要讲解了HashMap源码中的put方法,本篇我们主要讲解HashMap扩容——resize()方法


    hashMap中的resize()方法:

    进入源码前,首先看下链表数组的结构图,有个概念:

    图片引用自:https://zhuanlan.zhihu.com/p/21673805

    Node<K,V>[] table

    table称为链表数组,是HashMap的底层结构,table中的对象table[i],也称为【桶】,故table也称为hash桶数组。

    桶可能的取值有三种类型:

    1.null 2.链表(链表中的节点数量为8以内) 3.红黑树(链表长度>8时自动转换为红黑树)

    初始化hashMap时若未指定容量,则默认为16,即table的容量为16,扩容resize指的是扩大table的容量,扩容后的容量 = 原容量 * 2

    扩容resize()的源码如下:

    image

    resize()方法如此长,囧~先看注释吧:

    /** ** Initializes or doubles table size. If null, allocates in* ** accord with initial capacity target held in field threshold.* ** Otherwise, because we are using power-of-two expansion, the* ** elements from each bin must either stay at same index, or move* ** with a power of two offset in the new table.* *** ** **@return *the table */

    有道翻译下:

    初始化或加倍表大小。如果是null,分配符合现场门限内的初始容量目标。否则,因为我们使用的是“2的幂”展开式每个bin中的元素必须保持相同的索引,或者移动在新表中具有两倍偏移量的能力。

    看完还是有点迷糊,没关系,我们大致猜测一下,resize是个给node[]重分配空间的方法,如果初始化为null则赋初始值,否则按2的倍数扩容。

    接下来我们还是一步步地分析下源码:

    Node<K,V>[] oldTab = table;
            int oldCap = (oldTab == null) ? 0 : oldTab.length;
            int oldThr = threshold;
            int newCap, newThr = 0;
    

    此处Cap为capacity的缩写,表示【容量】,oldCap即原容量,newCap为扩容后的容量;

    Thr为threshold的缩写,表示【阈值】或【临界容量】,oldThr即原临界容量,newThr为新临界容量。看到这里,让我们来梳理一下几个知识点:

    容量capacity、装填因子loadFactor、阈值threshold和扩容

    1.首先:HashMap的底层是链表数组,也就是此处的Node<K,V>[] table。hashmap通过put方法装对象时,对象会根据Object的hashCode方法(native方法)产生一个hashCode,根据hashCode以及进一步地计算来得到这个对象存放在table中的索引i。然后此对象就被装入了table[i]中。(具体见下篇文章)

    容量capacity即==此处table数组的长度,若未定义长度则初始默认设为16。

    2.理论上来说如果HashMap的容量越大,put进去的对象产生冲突的概率也就越小,反过来说,如果我HashMap容量相对较小,put进去的对象就有很大概率产生碰撞。为了降低碰撞发生的概率,hashMap设定了装填因子loadFactor,阈值threshold。当table中装的对象容量达到阈值时,触发自动扩容机制。

    (举个栗子-1:此处假定每个对象存在数组任意位置i的概率都一样,且table不能自动扩容。若table容量为10,要放1000个对象进去,那么发生碰撞的概率接近100%,若table容量为100000,则碰撞的概率就接近0了~)

    阈值threshold = 容量capacity * 装填因子loadFactor

    (举个栗子-2:初始默认容量capacity为16,loadFactor为0.75,threshold = 16 * 0.75 = 12

    即表示如果table装载了12个元素,达到了阈值,hashMap触发扩容,容量会自动增大2倍。)

    装填因子过小,譬如容量为100的hashMap装填因子设为0.1,则每装10个对象,hashMap就会进行一次扩容,这样做的好处在于:发生碰撞的概率会很低,但频繁扩容会耗时耗资源,势必影响效率;

    装填因子过大,则扩容的频率会变低,但发生碰撞的概率会相应增高。

    装填因子是可以设定的,默认为0.75,这个数值也是兼顾了碰撞的概率和扩容的效率综合考虑的一个比较平衡的设定。

    3.虽然引入了扩容机制,有效降低了碰撞的概率,但是碰撞还是可能存在,碰撞就是table数组某些位置已经存放了对象,后续put进来的对象却依然放置到了这些位置上。如果hashMap中越来越多的元素都会被存在链表上,那么hashMap就失去了效率,毕竟hashMap存在的意义就是即有数组高效随机存储的特性,也有链表高效增删元素的优点。在JDK8中,设计者为了防止hashMap元素过多碰撞形成过长的链表,特意引入了红黑树,即某个链表包含超过8个对象时,就会自动从链表转变为红黑树,用于平衡链表过长时访问效率低的劣势。


    回到扩容resize()的源码:

    Node<K,V>[] oldTab = table;
            int oldCap = (oldTab == null) ? 0 : oldTab.length;
            int oldThr = threshold;
            int newCap, newThr = 0;
    

    1.将table赋给oldTab

    2.(oldTab == null)成立,则原table为空,oldCap=0 否则oldCap=table.length;

    3.oldThr = threshold,threshold为阈值,定义为:int threshold;初始为0

    4.newCap, newThr = 0;

    然后进入判断:

    //若扩容前原table的容量>0,进入此分支
    if (oldCap > 0) {
        //若oldCap>最大容量(MAXIMUM_CAPACITY = 1 << 30),则将阈值提高至最大值,
        if (oldCap >= MAXIMUM_CAPACITY) {
            //最大值为Integer类型的最大值:2^31-1(MAX_VALUE = 0x7fffffff)
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //否则,新容量 = 旧容量 * 2
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
            oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    //若原table容量==0且原阈值>0,则新table的容量 = 原阈值
    //若初始化HashMap时赋了初始容量就会执行到此分支,为什么?见下面【疑问】
    else if (oldThr > 0)
        newCap = oldThr;
    //若原table容量==0且阈值==0,则进入此分支,初始化table容量和阈值
    else { // zero initial threshold signifies using defaults
        //oldCap==0表示table==null,即尚未初始化,故赋值初始容量为16,初始阈值为12
        // (DEFAULT_INITIAL_CAPACITY==16,DEFAULT_LOAD_FACTOR==0.75)
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    

    简单总结下,扩容前,如果原table的容量oldCap>0,则会进入判断:

    若oldCap>最大容量,则将阈值提高到32位整型能达到的最大值2^31-1

    若oldCap<最大容量,则扩容2倍,同时阈值也扩大2倍

    若原table的容量oldCap==0,表示table尚未初始化:

    若原阈值>0,则新容量 = 原阈值【疑问】

    否则,新容量 = 16,新阈值 = 16 * 0.75 = 12

    现在来看看【疑问】,若oldThr>0,则执行newCap = oldThr,根据原有阈值是否>0来判断,若>0,则新容量 = 原阈值,乍一看觉得好奇怪啊,为什么会用阈值来判断?为什么不直接用容量判断?判断完了将阈值赋给容量....

    现在我们来捋一捋思路:

    初始table容量为0,表示整个table尚未初始化,那未初始化的table其阈值oldThr会存在么?

    这时,想一下一种情况:初始化map时给定初始容量:Map map = new HashMap(10);

    哎,也不太对,那初始赋值10不应该是将其初始容量设定为10了么?和阈值有神马关系呢?

    先带着疑问,看看源码再说:

    image

    如图所示,源码中初始化空HashMap有三种方法:

    1.空Hashmap构造器

    2.初始化时给定初始容量

    3.初始化时给定初始容量和装填因子

    方法1:就比较简单了,直接将装填因子loadFactor设为初始值0.75,然后后面的容量和阈值暂且尚未赋值,留到了后面

    方法2:给定了初始容量initialCapacity后,调用了方法三

    方法3:给定了初始容量initialCapacity和装填因子loadFactor

    此时,让我们用Map map = new HashMap(10);来看看会发生什么事?

    initialCapacity = 10,调用方法2,方法2执行方法3,此时initialCapacity = 10,loadFactor = 0.75.方法3中的3处if判断都没有执行到,直接来到:

    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
    

    这里直接将loadFactor设为0.75,将阈值设为tableSizeFor(10)。我们接着看看tableSizeFor干了什么?

    image

    这个函数是不是很让人摸不着头脑?我们还是要硬着头皮来计算下试试,看看有何乾坤?

    //首先|=的意思是按位或等于,a |= b 等价于 a = a | b,即将a与b按位或运算的值赋给a。
    //>>>是无符号右移,高位补0
    
    9的二进制表示:
        0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1  (9)
    
    9 >>> 1后:
        0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0  (4)
    
    再进行按位或:
        0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1  (9)
            或
        0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0  (4)
    n = 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 1  (13)
    
    13 >>> 2后:
        0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1  (3)
    再进行按位或:
        0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 1  (13)
            或
        0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1   (3)
    n = 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1  (15)
    
    15 >>> 4后:
        0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0  (0)
    再进行按位或:
        0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1  (15)
            或
        0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0   (0)
    n = 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1  (15)
    
    15 >>> 8后:
        0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0  (0)
    再进行按位或:
        0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1  (15)
            或
        0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0   (0)
    n = 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1  (15)
    
    15 >>> 16后:
        0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0  (0)
    再进行按位或:
        0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1  (15)
            或
        0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0   (0)
    n = 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1  (15)
    

    tableSizeFor(10)计算出的n = 15

    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    

    15 < MAXIMUM_CAPACITY,故return n + 1 即16.

    多试几个数就会发现,无论初始initialCapacity设为多少,最后return的数总是>initialCapacity,且最接近2的幂次方的一个数,譬如initialCapacity= 9,10,11,12,13,14,15最后都会变为16,initialCapacity = 17,18,19...31,最终都会变为32,看到这,不得不感叹,tableSizeFor真是一个精妙的算法!!

    详见:HashMap源码注解 之 静态工具方法hash()、tableSizeFor()(四)

    同时,让我们回到还没有分析完的源码中的【疑问】处:

    //若扩容前原table的容量>0,进入此分支
    if (oldCap > 0) {
       ...
    }
    //若原table容量==0且原阈值>0,则新table的容量 = 原阈值
    //若初始化HashMap时赋了初始容量就会执行到此分支,为什么?见下面【疑问】
    else if (oldThr > 0)
        newCap = oldThr;
    //最后,若原table容量==0且阈值==0,则进入此分支
    else { // zero initial threshold signifies using defaults
        //oldCap==0表示table==null,即尚未初始化,故赋值初始容量为16,初始阈值为12
        // (DEFAULT_INITIAL_CAPACITY==16,DEFAULT_LOAD_FACTOR==0.75)
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    
    

    现在,我们终于知道了为什么会有尚未初始化table,阈值就存在的情况?

    因为,初始化hashMap的时候设定了initialCapacity,经过tableSizeFor处理后将this.threshold设置为一定的值,所以阈值oldThr就存在了。此时会将新的table容量定为oldThr。

    若oldCap ==0 且oldThr==0,则表示一切都尚未初始化,故容量newCap设为默认容量16,

    阈值newThr设为16 * 0.75 = 12

    进入到resize()方法的后半部分:

    后半部分是resize()扩容的核心,主要过程就是将原table中的对象复制到扩容后的新table。

    //阈值threshold = 经过计算的新阈值newThr
    threshold = newThr;
    //新的数组链表命名为newTab,newTab的容量为经过计算的新容量newCap
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    //将newTab赋给table,到此完成了扩容的第一步。
    table = newTab;
    //若原table不为空,则将原table中的所有对象转移到newTab,完成扩容的第二步。
    if (oldTab != null) {
        //遍历原table中所有的【桶】
        for (int j = 0; j < oldCap; ++j) {
            HashMap.Node<K,V> e;
            //若【桶】非空,则将处理后的【桶】移到新的table中
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                if (e.next == null)
                    //桶中只有一个节点对象,则直接将桶放入newTab中,索引为e.hash & (newCap - 1)
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    //如果桶是个红黑树,则进入split方法...
                    ((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    //如果桶是个链表,则对链表中的元素进行遍历,并放入相应的桶loHead和hiHead中
                    //此处loHead是指索引不变的桶,hiHead是索引改变的桶,遍历完链表后会将这两个桶分别插入newTab不同的索引中
                    HashMap.Node<K,V> loHead = null, loTail = null;
                    HashMap.Node<K,V> hiHead = null, hiTail = null;
                    HashMap.Node<K,V> next;
                    //do...while从链表头→尾遍历链表元素
                    do {
                        next = e.next;
                        //如果(e.hash & oldCap) == 0)成立,则该元素在newTab中的索引和oldTab中一致,
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e; 
                        }
                        //否则,该元素在newTab中的索引 = 原索引 + 原table长度
                        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;
                    }
                }
            }
        }            
    }
    //返回扩容完成后的新table[]
    return newTab;
    

    我们来整理下思路,其实扩容主要就是hashMap扩大2倍,这个将table的capcity * 2即可,比较好理解,关键在于原table中的对象要复制到扩容后的新table中。此处原table即:oldTab,新table即newTab,原table容量为oldCap,newTab容量为newCap。

    我们用for循环遍历oldCap中的每个桶(table[i]对象),这里oldCap[i]可能的形态有4种:

    1.null

    2.oldTab[i]中有一个键值对元素

    3.oldTab[i]是一个链表

    4.oldTab[i]是一个红黑树

    对于1.则直接跳过不用处理

    对于2.则将这个元素直接放到newTab中去,索引i = e.hash & (newCap - 1)

    对于3.则情况稍微复杂些。

    因为对于oldTab[i]中的每个链表节点,原先的索引是根据原先的hash值&原length-1取得的,扩容后由于length扩大了2倍,所以索引有可能发生改变。按常理应该重新遍历这些节点,计算出新的索引值,再依次放入新的newTab中,但是源码中似乎没有这么做。

    源码采用了一个巧妙的判断:

    if ((e.hash & oldCap) == 0) 如果成立,则该节点在newTab中的索引不变,和原索引一致

    否则,该节点在newTab中的索引 = 原索引 + 原table容量(oldCap)

    为什么?让我们来看一张图:

    image

    图(a)表示扩容前的key1和key2两种key确定索引位置的示例

    图(b)表示扩容后的key1和key2两种key确定索引位置的示例

    1.首先,我们知道hashMap的容量必然为2的幂次方如16 = 2^4 32 = 2^5。这样n-1 表示为32位的bit位时,总会称为00000.....1111这种形式,即高位全为0,低位全为1,如图a中n = 16 ,n-1 = 15即表示为0000....1111.

    2.扩容后,新的索引 = hash & newCap-1 = hash & 【(oldCap - 1 )* 2 + 1】

    即相当于bit位左移一位,空出的一位+1,看上去的效果就是,低位不变,高位+1,如图中扩容前后的n-1所示。

    3.此时就比较清楚了,扩容前后的索引 = hash & 长度-1,其实只取决于高一位的hash值是否为0,若为0,则不论n-1的高位是否为1,都不会影响到最后结果,即索引i不变;

    若hash的高位为1,则扩容后n-1高位也为1,则&操作后索引值增加了原容量的一倍。

    思路理清楚后,我们继续回到源码中的do....while部分:

    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 ((e.hash & oldCap) == 0)来判断节点的索引是否发生改变。

    如果成立,表明新索引不变,将此节点链接到loHead链表的尾部(loTail)

    若!=0,则此节点的索引 = 原索引 + oldCap,将此节点链接至hiHead的尾部(hiTail)

    循环结束后,得到两条链表,若链表不空则将loHead和hiHead依次链接至newTab[j]处和newTab[j + oldCap]处,成为两个新的table桶。

    if (loTail != null) {
        loTail.next = null;
        newTab[j] = loHead;
    }
    if (hiTail != null) {
        hiTail.next = null;
        newTab[j + oldCap] = hiHead;
    }
    

    至此,处理红黑树的底层实现并未涉及,HahsMap源码的研读基本已经完成了。还是学习和收获了不少,为了看Object.hashCode()方法,还特意下载了OpenJDK的源码,查看了其在c++中的底层实现,扩容中巧妙地通过hash(key) & capacity - 1计算元素在table[]中的索引值也是让人印象深刻,更别说还有tableSizeFor()方法中优雅地位移运算!真是收获满满~

    相关文章

      网友评论

        本文标题:Java集合框架—HashMap—源码研读-2

        本文链接:https://www.haomeiwen.com/subject/czrqdhtx.html