【Java集合】HashMap的resize()源码详解以及JDK1.7与JDK1.8的区别

目录

一、resize()方法执行流程

二、resize()方法源码

2.1 计算新索引的位置(e.hash & oldCap)

三、对比JDK1.7的resize()扩容方法源码

3.1 JDK1.7的resize()方法执行流程

3.2 JDK1.7的resize()方法源码

3.3 initHashSeedAsNeeded()方法源码

3.4  jdk1.7和jdk1.8的区别


下面看一下 resize 方法,面试时最经常问的hashmap扩容机制就在这个地方,注意newCap = oldCap << 1这句,扩容就在这,扩大两倍。

进行扩容,会伴随着一次重新 hash 分配,并且会遍历 hash 表中所有的元素,是非常耗时的。在编写程序中,要尽量避免 resize。

一、resize()方法执行流程

二、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.
 * 初始化或把table容量翻倍。如果table是空,则根据threshold属性的值去初始化HashMap的容                
 * 量。如果不为空,则进行扩容,因为我们使用2的次幂来给HashMap进行扩容,所以每个桶里的元素 
 * 必须保持在原来的位置或在新的table中以2的次幂作为偏移量进行移动
 * @return 返回Node<K, V>数组
 */
final Node<K,V>[] resize() {
    // 创建一个临时变量,用来存储当前的table
    Node<K,V>[] oldTab = table;
    // 获取原来的table的长度(大小),判断当前的table是否为空,如果为空,则把0赋值给新定义的oldCap,否则以table的长度作为oldCap的大小
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 创建临时变量用来存储旧的阈值,把旧table的阈值赋值给oldThr变量
    int oldThr = threshold;
    // 定义变量newCap和newThr来存放新的table的容量和阈值,默认都是0
    int newCap, newThr = 0;    
    // 判断旧容量是否大于0            
    if (oldCap > 0) {                            
        // 判断旧容量是否大于等于 允许的最大值,2^30
        if (oldCap >= MAXIMUM_CAPACITY) {    
            // 以int的最大值作为原来HashMap的阈值,这样永远达不到阈值就不会扩容了
            threshold = Integer.MAX_VALUE; 
            // 因为旧容量已经达到了最大的HashMap容量,不可以再扩容了,将阈值变成最大值之后,将原table返回       
            return oldTab;
        }
        // 如果原table容量不超过HashMap的最大容量,将原容量*2 赋值给变量newCap,如果newCap不大于HashMap的最大容量,并且原容量大于HashMap的默认容量
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                    oldCap >= DEFAULT_INITIAL_CAPACITY)
            // 将newThr的值设置为原HashMap的阈值*2
            newThr = oldThr << 1; // double threshold
    }
    // 如果原容量不大于0,即原table为null,则判断旧阈值是否大于0 
    else if (oldThr > 0) // 如果原table为Null且原阈值大于0,说明当前是使用了构造方法指定了容量大小,只是声明了HashMap但是还没有真正的初始化HashMap(创建table数组),只有在向里面插入数据才会触发扩容操作进而进行初始化
        // 将原阈值作为容量赋值给newCap当做newCap的值。由之前的源码分析可知,此时原阈值存储的大小就是调用构造函数时指定的容量大小,所以直接将原阈值赋值给新容量
        newCap = oldThr;
    // 如果原容量不大于0,并且原阈值也不大于0。这种情况说明调用的是无参构造方法,还没有真正初始化HashMap,只有put()数据的时候才会触发扩容操作进而进行初始化
    else {               // zero initial threshold signifies using defaults
        // 则以默认容量作为newCap的值
        newCap = DEFAULT_INITIAL_CAPACITY;
        // 以初始容量*默认负载因子的结果作为newThr值
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 经过上面的处理过程,如果newThr值为0,说明上面是进入到了原容量不大于0,旧阈值大于0的判断分支。需要单独给newThr进行赋值
    if (newThr == 0) {
        // 临时阈值 = 新容量 * 负载因子
        float ft = (float)newCap * loadFactor;
        // 设置新的阈值 保证新容量小于最大总量   阈值要小于最大容量,否则阈值就设置为int最大值
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                    (int)ft : Integer.MAX_VALUE);
    }
    // 将新的阈值newThr赋值给threshold,为新初始化的HashMap来使用
    threshold = newThr;
    // 初始化一个新的容量大小为newCap的Node数组
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    // 将新创建的数组赋值给table,完成扩容后的新数组创建
    table = newTab;
    // 如果旧table不为null,说明旧HashMap中有值
    if (oldTab != null) {
        // 如果原来的HashMap中有值,则遍历oldTab,取出每一个键值对,存入到新table        
        for (int j = 0; j < oldCap; ++j) {
            // 创建一个临时变量e用来指向oldTab中的第j个键值对,
            Node<K,V> e;
            // 将oldTab[j]赋值给e并且判断原来table数组中第j个位置是否不为空
            if ((e = oldTab[j]) != null) {    
                // 如果不为空,则将oldTab[j]置为null,释放内存,方便gc
                oldTab[j] = null;   
                // 如果e.next = null,说明该位置的数组桶上没有连着额外的数组          
                if (e.next == null)
                    // 此时以e.hash&(newCap-1)的结果作为e在newTab中的位置,将e直接放置在新数组的新位置即可          
                    newTab[e.hash & (newCap - 1)] = e; 
                // 否则说明e的后面连接着链表或者红黑树,判断e的类型是TreeNode还是Node,即链表和红黑树判断
                else if (e instanceof TreeNode)  
                    // 如果是红黑树,则进行红黑树的处理。将Node类型的e强制转为TreeNode,之所以能转换是因为TreeNode 是Node的子类
                    // 拆分树,具体源码解析会在后面的TreeNode章节中讲解
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); 
                // 当前节不是红黑树,不是null,并且还有下一个元素。那么此时为链表   
                else { // preserve order 
                    /*
                        这里定义了五个Node变量,其中lo和hi是,lower和higher的缩写,也就是高位和低位,
                        因为我们知道HashMap扩容时,容量会扩到原容量的2倍,
                        也就是放在链表中的Node的位置可能保持不变或位置变成 原位置+oldCap,在原位置基础上又加了一个数,位置变高了,
                        这里的高低位就是这个意思,低位指向的是保持原位置不变的节点,高位指向的是需要更新位置的节点
                    */
                    // Head指向的是链表的头节点,Tail指向的是链表的尾节点
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    // 指向当前遍历到的节点的下一个节点
                    Node<K,V> next;
                    // 循环遍历链表中的Node
                    do {                   
                        next = e.next;
                        /*
                            如果e.hash & oldCap == 0,注意这里是oldCap,而不是oldCap-1。
                            我们知道oldCap是2的次幂,也就是1、2、4、8、16...转化为二进制之后,
                            都是最高位为1,其它位为0。所以oldCap & e.hash 也是只有e.hash值在oldCap二进制不为0的位对应的位也不为0时,
                            才会得到一个不为0的结果。举个例子,我们知道10010 和00010 与1111的&运算结果都是 0010  ,
                            但是110010和010010与10000的运算结果是不一样的,所以HashMap就是利用这一点,
                            来判断当前在链表中的数据,在扩容时位置是保持不变还是位置移动oldCap。
                        */
                        // 如果结果为0,即位置保持不变  
                        if ((e.hash & oldCap) == 0) {  
                            // 如果是第一次遍历    
                            if (loTail == null)      
                                // 让loHead = e,设置头节点     
                                loHead = e;               
                            else
                                // 否则,让loTail的next = e
                                loTail.next = e;   
                            // 最后让loTail = e       
                            loTail = e;                   
                        }
                        /*
                            其实if 和else 中做的事情是一样的,本质上就是将不需要更新位置的节点加入到loHead为头节点的低位链表中,将需要更新位置的节点加入到hiHead为头结点的高位链表中。
                            我们看到有loHead和loTail两个Node,loHead为头节点,然后loTail是尾节点,在遍历的时候用来维护loHead,即每次循环,
                            更新loHead的next。我们来举个例子,比如原来的链表是A->B->C->D->E。
                            我们这里把->假设成next关系,这五个Node中,只有C的hash & oldCap != 0 ,
                            然后这个代码执行过程就是:
                            第一次循环: 先拿到A,把A赋给loHead,然后loTail也是A
                            第二次循环: 此时e的为B,而且loTail != null,也就是进入上面的else分支,把loTail.next =                     
                                        B,此时loTail中即A->B,同样反应在loHead中也是A->B,然后把loTail = B
                            第三次循环: 此时e = C,由于C不满足 (e.hash & oldCap) == 0,进入到了我们下面的else分支,其 
                                        实做的事情和当前分支的意思一样,只不过维护的是hiHead和hiTail。
                            第四次循环: 此时e的为D,loTail != null,进入上面的else分支,把loTail.next =                     
                                        D,此时loTail中即B->D,同样反应在loHead中也是A->B->D,然后把loTail = D
                        */
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 遍历结束,即把table[j]中所有的Node处理完
                    // 如果loTail不为空,也保证了loHead不为空
                    if (loTail != null) {    
                        // 此时把loTail的next置空,将低位链表构造完成    
                        loTail.next = null;    
                        // 把loHead放在newTab数组的第j个位置上,也就是这些节点保持在数组中的原位置不变
                        newTab[j] = loHead;      
                    }
                    // 同理,只不过hiHead中节点放的位置是j+oldCap
                    if (hiTail != null) {       
                        hiTail.next = null;
                        // hiHead链表中的节点都是需要更新位置的节点
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    // 最后返回newTab
    return newTab;            
}

 resize()方法中有几个点需要注意:

  • 一个是计算新索引的位置(e.hash & oldCap),
  • 另一个是红黑树的处理(split)。

下面就就来说一下第一个问题,红黑树处理留到TreeNode的文章讲解。

2.1 计算新索引的位置(e.hash & oldCap

关于e.hash & oldCap == 0 的讲解在之前的HashMap底层原理概要笔记和上面的代码注释中已经说过了,这里再结合图详细说一下。

为什么是e.hash & oldCap 来确定在新数组中的索引位置呢,因为在put 的时候 (n - 1) & hash 得到索引位置

举个例子,扩容前 table 的容量n为16,a 节点和 b 节点在扩容前处于同一索引位置。

 扩容后,table 长度n为32,新表的 n - 1 只比老表的 n - 1 在高位多了一个1(图中标红的1)。

 因为 2 个节点在老表是同一个索引位置,因此计算新表的索引位置时,只取决于新表在高位多出来的这一位(图中标红1),而这一位的值刚好等于 oldCap。

因此会存在两种情况:

  1. (e.hash & oldCap) == 0 ,则新表索引位置为“原索引位置” ;
  2. (e.hash & oldCap) != 0,则新表索引位置为”原索引 + oldCap 位置”。

三、对比JDK1.7resize()扩容方法源码

JDK1.7中一般是在进行put()数据的时候调用resize()方法进行扩容,由addEntry()方法进行调用。

3.1 JDK1.7resize()方法执行流程

流程图:

扩容过程中的转移数据示意图如下:

在扩容resize()过程中,在将旧数组上的数据转移到新数组上时,转移操作 = 按旧链表的正序遍历链表,在新链表的使用头插法依次插入,即在转移数据、扩容后,容易出现链表逆序的情况。

此时若(多线程)并发执行 put()操作,一旦出现扩容情况, 容易出现环形链表,从而在获取数据、遍历链表时形成死循环(Infinite Loop),即死锁的状态,所以HashMap线程不安全。

3.2 JDK1.7resize()方法源码

/**
* 分析:resize(2 * table.length)
* 作用:当容量不足时(容量 > 阈值),则扩容(扩到2倍)
*/   
void resize(int newCapacity) {  
    // 1. 保存旧数组(old table)
    Entry[] oldTable = table; 
    // 2. 保存旧容量(old capacity ),即数组长度
    int oldCapacity = oldTable.length;
    // 3. 若旧容量已经是系统默认最大容量了,那么将阈值设置成整型的最大值,退出    
    if (oldCapacity == MAXIMUM_CAPACITY) {
        // 修改扩容阀值  
        threshold = Integer.MAX_VALUE;
        return;  
    }  
    // 4. 根据新容量(2倍容量)新建1个数组,即新table  
    Entry[] newTable = new Entry[newCapacity];  
    // 5. 将旧数组上的数据(键值对)转移到新table中,从而完成扩容。initHashSeedAsNeeded(newCapacity)这个方法用来根据新的数组长度来重新初始化Hash种子
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    // 6. 新数组table引用到HashMap的table属性上
    table = newTable;  
    // 7. 重新设置阈值,如果阈值超过了HashMap最大容量大小,则直接将阈值设置为 MAXIMUM_CAPACITY + 1
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}  

 如果数组进行扩容,数组长度发生变化,而存储位置 index = h&(length-1),index也可能会发生变化,需要重新计算index,我们先来看看transfer这个方法:

/**
* 分析:transfer(newTable); 
* 作用:将旧数组上的数据(键值对)转移到新table中,从而完成扩容
* 过程:按旧链表的正序遍历链表、在新链表的头部依次插入
* @param rehash 如果这里传入的是true,说明Hash种子已经更新,需要对所有的元素进行rehash重新计算Hash值。该操作比较消耗资源,这也是JDK1.7相对JDK1.8执行效率较低的原因
*/ 
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;  
            // 如果是重新Hash,则需要重新计算hash值  
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);  
            }  
            // 重新计算每个元素的存储位置,这里再次按照之前计算元素所在位置的方法重新进行一遍 Hash值 &(新数组长度 - 1)的计算,也是相当消耗资源的操作。1.8就采用扩容之后运用运算规律来对元素重新定位,这样相对要高效很多。
            int i = indexFor(e.hash, newCapacity);
            // 将元素放在数组上:采用单链表的头插入方式 = 在链表头上存放数据 = 将新插入数据的next指向原数组位置的链表头节点,然后将需放入的数据放到数组位置中,这样就实现了头插法将数据插入链表
            // 即 扩容后,可能出现逆序:按旧链表的正序遍历链表、在新链表的头部依次插入
            e.next = newTable[i];
            // newTable[i]的值总是最新插入的值
            newTable[i] = e;
            // 访问下一个Entry链上的元素,如此不断循环,直到遍历完该链表上的所有节点
            e = next;
        }  
    }  
}  

3.3 initHashSeedAsNeeded()方法源码

这个方法在JDK1.7中inflateTable()初始化哈希表方法和resize()扩容方法中都有出现。这个方法作用是初始化Hash种子。在JDK1.7中计算hash值的方法需要使用Hash种子来参与运算,进而提高计算出来的hash值的散列性,最大限度减少哈希冲突。下面就来简单讲一下这个方法。

/**
 * 这个方法用来根据新的数组长度来重新初始化Hash种子,好的Hash种子能提高计算Hash时结果的散列性,最大限度减少哈希冲突。
 * @param capacity 根据传入的容量大小来进行重新初始化Hash种子
 * @return 返回true说明已经根据传入的容量大小重新初始化了Hash种子,此时以前根据旧的Hash种子计算出来的Hash值就需要进行rehash了。
 *         返回false说明并没有根据传入的容量大小进行重新初始化Hash种子
 */
final boolean initHashSeedAsNeeded(int capacity) {
    // 首先会判断hashSeed是否不等于0,因为hashSeed一开始是0,所以此处是false
    boolean currentAltHashing = hashSeed != 0;
    // 这行代码是判断vm是否启动 且 容量到达一个值ALTERNATIVE_HASHING_THRESHOLD,这个值是可以自己去设定,不设定的话是默认的Integer.MaxValue 。 假设我们初始化容量capacity = 16,设置ALTERNATIVE_HASHING_THRESHOLD值为 3,那么这行代码会为true
    boolean useAltHashing = sun.misc.VM.isBooted() &&
        (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
    //亦或 ^ 的意思是 不相同则返回true,此时switching=true,那么hashSeed就会重新去计算hash种子,以便计算hash时增加散列性,
    boolean switching = currentAltHashing ^ useAltHashing;
    if (switching) {
        // 重新设置了Hash种子
        hashSeed = useAltHashing
            ? sun.misc.Hashing.randomHashSeed(this)
            : 0;
    }
    return switching;
}

final int hash(Object k) {
    // 设置了哈希种子
    int h = hashSeed;
    if (0 != h && k instanceof String) {
        return sun.misc.Hashing.stringHash32((String) k);
    }
    // Hash种子参与到了key的Hash值计算当中
    h ^= k.hashCode();
    // This function ensures that hashCodes that differ only by
    // constant multiples at each bit position have a bounded
    // number of collisions (approximately 8 at default load factor).
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

3.4  jdk1.7jdk1.8的区别

resize()扩容方法会触发一系列的TreeNode类的方法,依次为:split()、untreeify()、treeify()、tieBreakOrder()、balanceInsertion()、moveRootToFront()、checkInvariants() 。这些方法源码的详细讲解可以看这篇文章:【Java集合】HashMap的扩容操作源码详解https://blog.csdn.net/cy973071263/article/details/124766292?spm=1001.2014.3001.5501


相关文章: 【Java集合】HashMap系列(一)——底层数据结构分析
                   【Java集合】HashMap系列(二)——底层源码分析
                   【Java集合】HashMap的tableSizeFor()源码详解​​​​​​
                   【Java集合】HashMap的putMapEntries()源码详解 
                   【Java集合】HashMap的put()源码详解以及JDK1.7与JDK1.8的区别 
                   【Java集合】HashMap的get()源码详解以及JDK1.7与JDK1.8的区别 
                   【Java集合】HashMap的remove()源码详解以及JDK1.7与JDK1.8的区别

  • 15
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值