HashMap之扩容&rehash详解

前言

HashMap做为日常使用较多的类,也是面试中的重点。本文重点在解读源码,希望大家看完本文,可以学习到HashMap的设计思想、扩容、rehash过程,面试不再迷茫。

构造函数

HashMap有几种构造函数,但最终都调用下方这个构造函数。
int initialCapacity 初始容量,用于决策数组size
float loadFactor 负载因子,用于决策是否需要扩容(使用容量 >= 数组size * 负载因子)

public HashMap(int initialCapacity, float loadFactor) {
  	//省略校验部分
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}
//根据容量决策数组大小
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;
}

这里可以看到,threshold等于tableSizeFor方法的返回值,并不会直接消费构造传入的initialCapacity。tableSizeFor方法很复杂,或操作、位运算,我们可以不用理解具体细节,只需要知道这个方法会返回大于cap的最小2的N次方。

cap   tableSize
3      4
4      4
5      8
10     16
20     32

为何数组大小必须是2的N次方

1、可以通过位运算快速计算key的hash值对应的数组下标
2、扩容rehash无需重新计算hash值对应新数组的下标

计算数组下标

如果我们自己实现此功能,简单做法是根据hash %(取模) 数组大小,但这么做效率较低,我们看HashMap如何做的。

final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        //(n - 1) & hash 计算数组下标
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            //省略部分代码
        }
        return null;
    }

如上诉源码,这是HashMap的get()调用的方法,通过(n - 1) & hash来计算下标,n为数组大小,既数组大小-1和hash值与操作。
为什么这么做就可以计算出下标呢?

我们回顾下与操作,两个二进制位都为1,结果位=1,否则=0。
假设数组size=16
16对应二进制 0001 0000
16-1对应二进制 0000 1111
在这里插入图片描述
如上图几个例子,最终结果只与hash最后4位有关,最后4位哪些位置为1,对应结果位就为1,否则都为0,故结果范围[0000,1111],即0-15范围,完美使用与运算解决数组下标问题。

扩容rehash无需重新计算数组下标

具体扩容和rehash见下面源码部分,此章节只描述rehash使用位运算计算新数组下标的思想。
当HashMap使用比例超过阀值,会触发扩容操作,正常情况下新数组size=老数组size*2(满足数组大小为2的N次方)。
HashMap的底层结构为数组+链表/红黑树,当hash冲突的情况下,数组会指向链表或红黑树,当扩容时,链表或红黑树数据也需要拆分,挂靠在新数组下。
在这里插入图片描述
如上图,数组16扩容到32,32大小的下标计算跟16大小对比,差异点在于倒数第五位是否为1,如果等于1,结果倒数第五位值为1,否则为0,而其他位置跟16大小时完全相等。
基于此,我们可以计算倒数第五位是0还是1,将链表或红黑树拆分成2部分。
等于0,新数组的下标等于旧数组下标
等于1,新数组的下标等于旧数组下标+旧数组size(0000 0001 -> 0001 0001 中间增加10000=旧数组size)

源码

以put方法为例,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;
        //put操作,不是本文重点,忽略
        ++modCount;
        //使用size+1,大于扩容阀值(容量 * loadFactor),进行扩容操作
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
    
//重新计算数组size,初始化&put使用率超过给定比例触发
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;
            }
            //未达到最大值,扩容为原先2倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        //省略部分代码
        threshold = newThr;
		//初始化数组,newCap = oldCap * 2
        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;
                    //只存在一个数据,直接使用新数组大小计算hash值后放入数据
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    //当前位存放红黑树,需拆分红黑树成2部分
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { 
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        //此处只会为链表,循环链表,将链表拆分成2部分
                        do {
                            next = e.next;
                            //通过hash值和oldCap与操作,结果=0则数据存放在loHead
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            //此处结果=1则数据存放在hiHead
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        //拆分的2个链表,放入对应的位置
                        //与结果=0,放入原先位置
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        //与结果=1,放入原先位置+原先数组size位置
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

上面源码看着很复杂,其实思想上文已描述清楚,如果你已经理解rehash思想,看此部分代码会容易理解。
这里解释下 (e.hash & oldCap) == 0
上文新旧数组下标计算图可知,存放在当前下标还是当前下标+老数组size位,取决于倒数第五位,即老数组size的二进制大小。
在这里插入图片描述
如图所示,由于oldCap为2的N次方,只有1个1,其他位都为0,如果hash值此位为0,则与运算结果为0。
(e.hash & oldCap) == 0 表示当前hash的当前位为0,放入loHead链表
(e.hash & oldCap) != 0 表示当前hash的当前位为1,放入hiHead链表

树节点split

上面已描述链表节点拆分,继续上树节点拆分。思想跟链表节点类型,通过 e.hash & bit == 0来拆分成两部分。

final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
            TreeNode<K,V> b = this;
            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 = (TreeNode<K,V>)e.next;
                e.next = null;
                //通过hash值和bit与操作,结果=0则数据存放在loHead
                if ((e.hash & bit) == 0) {
                    if ((e.prev = loTail) == null)
                        loHead = e;
                    else
                        loTail.next = e;
                    loTail = e;
                    ++lc;
                }
                //此处结果=1则数据存放在hiHead
                else {
                    if ((e.prev = hiTail) == null)
                        hiHead = e;
                    else
                        hiTail.next = e;
                    hiTail = e;
                    ++hc;
                }
            }
			//树节点拆分的两部分数据,放入对应位置
			//此处有点特殊,如果节点数量小于非树化阀值(6),需要转换成链表
			//否则继续放入树结构
            if (loHead != null) {
                if (lc <= UNTREEIFY_THRESHOLD)
                    tab[index] = loHead.untreeify(map);
                else {
                    tab[index] = loHead;
                    if (hiHead != null)
                        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);
                }
            }
        }

树节点的拆分代码跟链表拆分类似,唯一有区别的在于会判断拆分出两部分的大小,数量小于6则转换成链表,否则放入红黑树。
举例说明:
原本节点A指向一颗红黑树,数据大小为10。经过拆分,loHead大小为7,hiHead大小为3。
loHead大于6,转换成红黑树,放入A位置。
hiHead小于6,转换成链表,放入A+原数组size处。

总结

HashMap通过合理的设计数组大小,使用位运算巧妙的计算下标位置、扩容后新数组下标位置。
看完HashMap源码,不得不佩服大牛们,继续加油吧骚年!!!

  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值