靛尊 发表于 2025-6-3 00:05:34

假设有一个 1G 大的 HashMap,此时用户请求过来刚好触发它的扩容,会怎样?

简要回答

如果刚好触发扩容,那么当前用户请求会被阻塞,因为 HashMap的底层是基于数组+链表(红黑树)来实现的,一旦它发生扩容,就需要新增一个比之前大2倍的数组,然后将元素copy到新的数组上
而 1G 的 HashMap 够大,所以扩容需要一定的时间,而扩容使用的又是当前的线程,所以用户此时会被阻塞,等待扩容完毕。
源码详解、扩展追问

HashMap put方法源码

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}先计算key的hash值,再对其put
static final int hash(Object key) {
    int h;
    //为什么要右移16位? 默认长度为2^5=16,与hash值&操作,容易获得相同的值。
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}这里分为了三步:

[*]h=key.hashCode() //第一步 取hashCode值
[*]h^(h>>>16) //第二步 高位参与运算,减少冲突,hash计算到这里
[*]return h&(length-1); //第三步 取模运算,计算数据在桶中的位置,这里看后面的源码
第3步(n-1)&hash原理:

[*]实际上(n-1) & hash等于 hash%n都可以得到元素在桶中的位置,但是(n-1)&hash操作更快。
[*]取余操作如果除数是 2 的整数次幂可以优化为移位操作。这也是为什么扩容时必须是必须是2的n次方
位运算(&)效率要比代替取模运算(%)高很多,主要原因是位运算(如与、或、移位等)是 CPU 的基本指令,几乎所有现代 CPU 都能在单个时钟周期内完成这些操作。而取模运算通常需要先执行除法,再计算余数,除法操作在 CPU 上比位运算复杂得多,需要多个时钟周期才能完成。
而计算hash是通过同时使用hashCode()的高16位异和低16位实现的(h >>> 16):这么做可以在数组比较小的时候,也能保证考虑到高低位都参与到Hash的计算中,可以减少冲突,同时不会有太大的开销。
hash值其实是一个int类型,二进制位为32位,而HashMap的table数组初始化size为16,取余操作为hashCode & 15 ==> hashCode & 1111 。这将会存在一个巨大的问题,1111只会与hashCode的低四位进行与操作,也就是hashCode的高位其实并没有参与运算,会导很多hash值不同而高位有区别的数,最后算出来的索引都是一样的。 举个例子,假设hashCode为1111110001,那么1111110001 & 1111 = 0001,如果有些key的hash值低位与前者相同,但高位发生了变化,如1011110001 & 1111 = 0001,1001110001 & 1111 = 0001,显然在高位发生变化后,最后算出来的索引还是一样,这样就会导致很多数据都被放到一个数组里面了,造成性能退化。 为了避免这种情况,HashMap将高16位与低16位进行异或,这样可以保证高位的数据也参与到与运算中来,以增大索引的散列程度,让数据分布得更为均匀(个人认为是为了分布均匀)
put流程如下:

// 第四个参数 onlyIfAbsent 如果是 true,那么只有在不存在该 key 时才会进行 put 操作
// 第五个参数 evict 我们这里不关心
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)//初始判断,初始数组为空时
      // resize()初始数组,需要进行扩容操作
      n = (tab = resize()).length;

    //这里就是上面的第三步,根据key的hash值找到数据在table中的位置
    if ((p = tab) == null)
      //通过hash找到的数组下标,里面没有内容就直接赋值
      tab = newNode(hash, key, value, null);
    else {//如果里面已经有内容了
      Node<K,V> e; K k;
      if (p.hash == hash &&
            //hash相同,key也相同,那就直接修改value值
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
      else if (p instanceof TreeNode)
            //key不相同,且节点为红黑树,那就把节点放到红黑树里
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
      else {
      //表示节点是链表
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                  //添加到链表尾部
                  p.next = newNode(hash, key, value, null);
                  if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
               //如果满足链表转红黑树的条件,则转红黑树
                        treeifyBin(tab, hash);
                  break;
                }
                if (e.hash == hash &&
                  ((k = e.key) == key || (key != null && key.equals(k))))
                  break;
                p = e;
            }
      }
      //传入的K元素已经存在,直接覆盖value
      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;
}final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    //检查是否满足转换成红黑树的条件,如果数组大小还小于64,则先扩容
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
      resize();
    else if ((e = tab) != null) {
      TreeNode<K,V> hd = null, tl = null;
   do {
         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 = hd) != null)
         hd.treeify(tab);
    }
}总结put方法流程:

[*]如果table没有初始化就先进行初始化过程
[*]使用hash算法计算key的索引判断索引处有没有存在元素,没有就直接插入
[*]如果索引处存在元素,则遍历插入,有两种情况,

[*]一种是链表形式就直接遍历到尾端插入,
[*]一种是红黑树就按照红黑树结构

[*]插入链表的数量大于阈值8,且数组大小已经大等于64,就要转换成红黑树的结构
[*]添加成功后会检查是否需要扩容
数组扩容源码

//table数组的扩容操作
final Node<K,V>[] resize() {
    //引用扩容前的node数组
    Node<K,V>[] oldTab = table;
    //旧的容量
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    //旧的阈值
    int oldThr = threshold;
    //新的容量、阈值初始化为0
    int newCap, newThr = 0;
   
    //计算新容量
    if (oldCap > 0) {
      //如果旧容量已经超过最大容量,让阈值也等于最大容量,以后不再扩容
      if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
      }
      //没超过最大值,就令newcap为原来容量的两倍
      else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
               oldCap >= DEFAULT_INITIAL_CAPACITY)
            //如果旧容量翻倍没有超过最大值,且旧容量不小于初始化容量16,则翻倍
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
      //旧容量oldCap = 0时,但是旧的阈值大于0,令初始化容量设置为阈值
      newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
      //两个值都为0的时候使用默认值初始化
      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;
   
    //创建新的数组,并引用
    @SuppressWarnings({"rawtypes","unchecked"})
      Node<K,V>[] newTab = (Node<K,V>[])new Node;
    table = newTab;
   
    //如果老的数组有数据,也就是是扩容而不是初始化,才执行下面的代码,否则初始化的到这里就可以结束了
    if (oldTab != null) {
      //轮询老数组所有数据
      for (int j = 0; j < oldCap; ++j) {
            //以一个新的节点引用当前节点,然后释放原来的节点的引用
            Node<K,V> e;
            if ((e = oldTab) != null) {//如果这个桶,不为空,说明桶中有数据
                oldTab = null;
                //如果e没有next节点,证明这个节点上没有hash冲突,则直接把e的引用给到新的数组位置上
                if (e.next == null)
                  //确定元素在新的数组里的位置
                  newTab = 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;
                  //从这条链表上第一个元素开始轮询,如果当前元素新增的bit是0,则放在当前这条链表上
                  //如果是1,则放在"j+oldcap"这个位置上,生成“低位”和“高位”两个链表
                  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);
                  //低位链表放到j这个索引的位置上
                  if (loTail != null) {
                        loTail.next = null;
                        newTab = loHead;
                  }
                  //高位链表放到(j+oldCap)这个索引的位置上
                  if (hiTail != null) {
                        hiTail.next = null;
                        newTab = hiHead;
                  }
                }
            }
      }
    }
    return newTab;
}显然,HashMap的扩容机制,就是当达到扩容条件时会进行扩容。扩容条件就是当HashMap中的元素个数超过临界值时就会自动扩容(threshold = loadFactor * capacity)。如果是初始化扩容,只执行resize的前半部分代码,但如果是随着元素的增加而扩容,HashMap需要重新计算oldTab中每个值的位置,即重建hash表,随着元素的不断增加,HashMap会发生多次扩容,这样就会非常影响性能。所以一般建议创建HashMap的时候指定初始化容量
但是当使用HashMap(int initialCapacity)来初始化容量的时候,HashMap并不会使用传进来的initialCapacity直接作为初始容量。JDK会默认计算一个相对合理的值当做初始容量。所谓合理值,其实是找到第一个比用户传入的值大的2的幂。也就是说,当new HashMap(7)创建HashMap的时候,JDK会通过计算,创建一个容量为8的Map;当new HashMap(9)创建HashMap的时候,JDK会通过计算,创建一个容量为16的Map。当然了,当创建一个HashMap时,表的大小并不会立即分配,而是在第一次put元素时进行分配,并且分配的大小会是大于或等于初始容量的最小的2的幂。
一般来说,initialCapacity = (需要存储的元素个数 / 负载因子) + 1。注意负载因子(即 loaderfactor)默认为 0.75,如果暂时无法确定初始值大小,请设置为 16(即默认值)。HashMap 需要放置 1024 个元素,由于没有设置容量初始大小,随着元素增加而被迫不断扩容,resize() 方法总共会调用 8 次,反复重建哈希表和数据迁移。当放置的集合元素个数达千万级时会影响程序性能。
//初始化容量
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

public HashMap(int initialCapacity, float loadFactor) {
    //保证initialCapacity在合理范围内,大于0小于最大容量
    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);
}

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    //|=计算方式:两个二进制对应位都为0时,结果等于0,否则结果等于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;
}/** * 红黑树分裂方法 */final void split(HashMap map, Node[] tab, int index, int bit) {   //当前这个节点的引用,即这个索引上的树的根节点   TreeNode b = this;   // Relink into lo and hi lists, preserving order   TreeNode loHead = null, loTail = null;   TreeNode hiHead = null, hiTail = null;   //高位低位的初始树节点个数都设成0   int lc = 0, hc = 0;   for (TreeNode e = b, next; e != null; e = next) {            next = (TreeNode)e.next;         e.next = null;         //bit=oldcap,这里判断新bit位是0还是1,如果是0就放在低位树上,如果是1就放在高位树上,这里先是一个双向链表         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
页: [1]
查看完整版本: 假设有一个 1G 大的 HashMap,此时用户请求过来刚好触发它的扩容,会怎样?