HashMap之源码阅读了解底层实现

729 阅读17分钟

「这是我参与2022首次更文挑战的第30天,活动详情查看:2022首次更文挑战

前言

本文主要通过阅读HashMap源码来了解它相关功能实现,我们可以围绕下面几个问题来有目的的了解:

  1. 阈值,为啥在调用HashMap无参构造函数的时候是12?
  2. 加载因子为啥默认的是0.75而不是其他?
  3. 为啥要把桶的大小设为2的n次方?
  4. get方法的具体实现-怎么确定桶的数组索引位置的?
  5. put方法的具体实现-扩容的实现?
  6. 怎么使hash算法结果分布均匀?怎么达到时间和空间的平衡?
  7. 红黑树结构?
  8. 什么时候需要转成红黑树?
  9. 为啥链表变红黑树的阈值为8?
  10. 什么时候要从红黑树转成链表?

以下源码均为jdk1.8版本。

HashMap的基本类结构了解

HashMap的结构如下,数组+链表+红黑树(JDK1.8的优化,链表大于等于8时转红黑树,小于等于6时转回链表)

image.png

字段

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
/**
 * 默认初始化容量-必须是2的幂。
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

/**
 * 最大容量,如果任何一个带参数的构造函数隐式指定了更高的值,则使用该容量。必须是2的幂<=1<<30。
 * 
 * 十亿多点
 */
static final int MAXIMUM_CAPACITY = 1 << 30;

/**
 * 当构造函数中没有指定时使用的加载因子。
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

/**
 * 用于树而不是列表的计数阈值。当向至少有这么多节点的容器中添加元素时,容器将转换为树。该值必须大于2,并且至少应为8,以符合tree移除中关于收缩后转换回普通容器的假设。
 */
static final int TREEIFY_THRESHOLD = 8;

/**
 * 在调整大小操作期间取消树结构的计数阈值。应小于TREEIFY_THRESHOLD,最大为6,并在移除时进行收缩检测。
 */
static final int UNTREEIFY_THRESHOLD = 6;

/**
 * 将bins转换为树的最小表容量。(否则,如果bin中的节点太多,则会调整table(即数组)的大小)。
 * 因至少为4倍的TREEIFY_THRESHOLD去避免调整大小和树化阈值之间的冲突。
 */
static final int MIN_TREEIFY_CAPACITY = 64;    
/**
 * table,第一次使用的时候初始化,并根据需要调整大小。分配时,长度总是2的幂。(我们还在某些操作中允许长度为零,以允许目前不需要的引导机制。)
 * 
 * 这里就是map结构中的数组+链表中的数组,又称为桶。
 */
transient Node<K,V>[] table;

/**
 * 保存缓存的entrySet()。请注意,AbstractMap字段用于keySet()和values()。
 * 
 */
transient Set<Map.Entry<K,V>> entrySet;

/**
 * map的元素数量
 */
transient int size;

/**
 * 此HashMap在结构上被修改的次数结构修改指的是那些改变HashMap中映射数量或
 * 以其他方式修改其内部结构的修改(例如,rehash)。此字段用于使HashMap集合
 * 视图上的iterators快速失败。(参见ConcurrentModificationException)。
 */
transient int modCount;

/**
 * 要调整大小的下一个大小值(capacity*load factor)。
 *
 * @serial
 */
// (javadoc描述在序列化时为真。此外,如果尚未分配表数组,则此字段保存初始数组容量,或零表示默认的初始容量。)
int threshold;

/**
 * 哈希表的加载因子。
 *
 * @serial
 */
final float loadFactor;
}

内部类-Node

基本哈希bin节点,用于大多数条目。(参见下面的TreeNode子类,以及LinkedHashMap中的Entry子类。)

可以看到node中存放了key和value,和下一个节点的引用,还有节点的hash。一个典型的单链表。

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;

    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }

    public final K getKey()        { return key; }
    public final V getValue()      { return value; }
    public final String toString() { return key + "=" + value; }

    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }

    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }

    public final boolean equals(Object o) {
        if (o == this)
            return true;
        if (o instanceof Map.Entry) {
            Map.Entry<?,?> e = (Map.Entry<?,?>)o;
            if (Objects.equals(key, e.getKey()) &&
                Objects.equals(value, e.getValue()))
                return true;
        }
        return false;
    }
}

内部类-TreeNode

扩展LinkedHashMap。Entry(它反过来扩展了节点),因此可以用作常规节点或链接节点的扩展。

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;  // red-black tree links
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    //链表中的节点,用于删除元素的时候可以快速找到它的前置节点。意思就是链表转树的时候保留的。
    TreeNode<K,V> prev;    // needed to unlink next upon deletion
    boolean red;
    TreeNode(int hash, K key, V val, Node<K,V> next) {
        super(hash, key, val, next);
    }
}

构造函数

构造具有指定初始容量和负载因子的空hashmap。

可以看到前面就是简单的进行参数校验。核心在于threshold的获得,使用tableSizeFor方法,下面我们会讲到。

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;
    //返回给定目标容量的2次幂大小。
    this.threshold = tableSizeFor(initialCapacity);
}

上面的重载

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

其他的字段都使用默认的,但是我并没有看出来threshold是怎么给默认值的,该构造函数是我们经常用的构造函数,下面我们会通过回答前言中的问题来进行回答。

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

tableSizeFor方法

返回给定目标容量的2次幂大小

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;
}

根据方法的注释,我们知道要返回大于等于cap的最小2的幂。那么HashMap是怎么做的呢?

我们可以先不看代码实现,如果是我们自己怎么实现,要实现:如果是9那我们返回16,如果是7我们返回8,如果是8就返回8。如果是我自己实现我就会一直除二,在过程中进行乘二,除到后面不够除了,如果不等于被除数再乘二就可以实现效果了。

但是很明显我的效率比较低,HashMap使用位运算效率会比较高,我们可以看到n先减1再和n的无符号右移1位的结果进行或运算,以此类推继续移动2位、4位直到16位,最后再加1,下面我们分析下:

  • 为啥是或运算?这样能保留1。
  • 为啥是无符号右移动,因为容量最后需要判断是否小于0。
  • 为什么int是无符号分别右移1,2,4,8,16位呢?因为int是4个字节32位,最长移动一半目的就是让最高位能的1能复制所有比它低的位。
  • 为啥开始要统一减1?主要是对2的整次幂数字起作用,可以方便后面统一加1进位。

可以总结一下,就是减去1后把最高位的1复制给后面的低位,最后加1进行进位,就达到了根据给定数字返回大于等于该数字的最小2次幂。

image.png

笔者疑问

为啥要在最后判断是否小于0,在前面判断不是省得进行位运算吗,效率不是更高吗?开始觉得很有道理,逻辑上很说的通,但是不然,需要考虑大部分情况开发工程师不会赋小于0的cap!所以改不改动意义不是很大。

hash方法

计算key.hashCode()并将散列的高位扩展(XOR)到低位。由于该表使用二次幂掩码,因此仅在当前掩码上方的位上变化的哈希集将始终发生冲突。(已知的例子包括在小表格中包含连续整数的一组Float键。)所以我们应用了一个变换,将高位的影响向下传播。比特传播的速度、实用性和质量之间需要权衡。因为许多常见的散列集已经被合理地分配(所以不能从传播中受益),而且因为我们使用树来处理容器中的大量冲突,所以我们只是以尽可能低的成本对一些移位位进行异或,以减少系统损失,以及合并最高位的影响,否则,由于表边界,最高位将永远不会用于索引计算。

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

源码的注释写的虽然有点绕,结合源码来看还是比较清楚的,先对h=key.hashCode()进行无符号移动16位再和h进行异或的目的是避免桶的size太小从而高位没对桶下标定位产生影响。

注意:key等于null的时候直接返回0所以取模获得下标必定是0,这也就是为啥当key位null的时候下标必定是0。

下图桶的位默认大小:16。 image.png

get方法

根据key获得值,会先调用hash方法计算hash然后再调用getNode方法获得value,下面来介绍这两个方法。

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

getNode方法

实现了Map.get和相关的方法。

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))))
            return first;
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

这里可以到是怎么定位桶的下标的,(n - 1) & hash:即hash和(n-1)与运算获得,为啥这样取模,这里就可以解释“为啥要把桶的大小设为2的n次方?”可以想下其实取模就是取余,用hash除以(n-1)获得余数,如果n-1的二进制从第一个1开始到最低位都是1这样和hash进行与运算就可以直接相当于取模并且尽可能的用上了hash的位这样比较离散(使元素分布更均匀)。比如n=14,需要对13进行取模,13的二进制(这里统一用1个字节)为0000 1101,如果hash为0000 1111那么与运算的结果就为0000 1101,这样没有尽可能的保留hash的位。

其他的就比较简单了,寻找匹配元素,是链表就遍历链表,不是就使用树查询,关于红黑树可以阅览《红黑树了解和手动实现》

put方法

如果映射已经包含了键的映射,则替换旧值。

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
/**
 * Implements Map.put and related methods.
 *
 * @param hash hash for key key的hash通过hash方法获得
 * @param key the key
 * @param value the value to put
 * @param onlyIfAbsent if true, don't change existing value 如果为true当key相同时,不替换value的值
 * @param evict if false, the table is in creation mode 如果为false则表示处于创建模式
 * @return previous value, or null if none
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //如果桶/数组为空或者length为0,则调用resize()进行初始化或者扩容
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //如果存放的key计算出来对应数组没有存放过元素就直接插入   
    if ((p = tab[i = (n - 1) & hash]) == null)
        //这里会在对应位置创建一个新的节点。
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        //如果hash一样,key也一样,都不用遍历链表或树了,后续直接替换value返回旧值。
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        //如果是树的节点,就检查树中是否存在key相同的存在节点,如果是则返回,如果不是就new一个节点并放入树中。    
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            //进行for循环直到存放好或者检查出已经存在相同的key节点
            for (int binCount = 0; ; ++binCount) {
                //如果到了链表尾部就直接在尾部加入
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    //如果大于等于TREEIFY_THRESHOLD(这里为8)就把链表转成树
                    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;
            }
        }
        //key已经存在
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            //onlyIfAbsent这里为false,所以必定替换value值,进行返回
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    //修改计数会进行累加
    ++modCount;
    //元素个数size也会进行累加,如果大于阈值会进行扩容
    if (++size > threshold)
        resize();
        
    afterNodeInsertion(evict);
    return null;
}

大致流程图如下

image.png

put方法中比较复杂的就是扩容(包括初始化)和红黑树的节点增加(包括链表转红黑树),红黑树比较复杂本篇不进行描述,想要一定的了解可以阅览《红黑树了解和手动实现》

笔者疑问
为啥不在插入流程前就进行resize,我们可以知道插入之前需要进行一系列的判断,进而插入列表或者红黑树(还会涉及树结构的调整),如果插入后发现需要扩容,那么插入好了之后还要调整甚至不需要红黑树了岂不是有点白费力气?笔者展示没想到,读者有的话欢迎留言。

下面我们来看看resize方法。

resize方法

将table大小初始化或加倍。如果为空,则根据字段阈值中的初始容量目标进行分配(默认DEFAULT_INITIAL_CAPACITY为16,所以new HashMap使用无参构造函数时table会在put的时候进行初始化数组size为16,因此threshold的值为12)。否则,因为我们使用的是二次幂展开,每个容器中的元素必须要么保持在相同的索引中,要么在新表中以二次幂偏移量移动。这也是cap是二次幂的好处。

final Node<K,V>[] resize() {
    //拿到当前的旧table
    Node<K,V>[] oldTab = table;
    //拿到当前的老cap
    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)
            //这里的往右移动可能会出现负数,但是没关系会在后面的put中修复     
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold 初始化容量设置为阈值,当调用new HashMap有参构造函数时会出发
        newCap = oldThr;
    else {               // 这里就是new HashMap的时候没有指定cap使用默认值DEFAULT_INITIAL_CAPACITY;,并初始化threshold
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //这里newThr == 0的情况:在oldCap大于0,oldCap扩大两倍的newCap等于最大容量限制或者oldCap小于默认值16的时;在当调用new HashMap有参构造函数时;这也是为啥在构造函数中threshold不符合cap*loadfactory的原因,在这里会进行调整
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        //只有newCap和ft都小于MAXIMUM_CAPACITY的时候才允许阈值继续按照规则获得,不然直接给出
        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[newCap];
    //赋值新的数组
    table = newTab;
    //下面为扩容的代码
    if (oldTab != null) {
        //遍历oldTab进行迁移
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            //遍历过程中的oldTab赋值给e,如果有值再进行迁移
            if ((e = oldTab[j]) != null) {
                //不需要了,for循环完之后oldTable不再引用任何任何对象,便于回收
                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;
                        //原索引,注意这里是和oldCap进行与运算不是之前的定位
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        //原索引+oldCap
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    //原索引放到bucket里
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    //原索引+oldCap放在bucket里
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

笔者疑问

为啥HashMap的threshold不在构造函数中进行最终值的确定,要在resize方法中进行呢?我开始很不理解,觉得代码的可读性变的差了,但是其实符合单一职责,resize用于初始化table和扩容,在这里统一进行处理更加方便维护。

和JDK1.7的区别

下面是jdk1.7的扩容代码

  void transfer(Entry[] newTable) {
      Entry[] src = table;                   //src引用了旧的Entry数组
      int newCapacity = newTable.length;
      for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
          Entry<K,V> e = src[j];             //取得旧Entry数组的每个元素
          if (e != null) {
              src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
              //使用头插法
              do {
                  Entry<K,V> next = e.next;
                 int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
                 e.next = newTable[i]; //标记[1]
                 newTable[i] = e;      //将元素放在数组上
                 e = next;             //访问下一个Entry链上的元素
             } while (e != null);
         }
     }
 } 

可以发现每次都要进行重新计算每个元素的下标。newTable[i] = e;使用头插法,原来插入的顺序没有尽量保护,会出现倒置。

jdk1.8即上面的resize中的方法做了以下调整:

不直接使用重新计算下标定位,会和oldCap做与运算,根据是否等于0看是否换下标,换的话也是j + oldCap,所以对性能没啥优化只是定位下标需要进行减1;同时没有使用头插法了,尽量的保证了原来的插入顺序,但是HashMap本来就是无序的其实也感觉不出来。

下标的变化,如下图:

jdk1.7

image.png

image.png

可以总结发现,重新计算hash要么不变,要么+oldCap。

所以,jdk1.8换成了,直接和oldCap与运算,等于0就不用变,大于0就+oldCap,换了种方式而已,如下图:

image.png

阈值,为啥在调用HashMap无参构造函数的时候是12?

”在put方法的resize方法“章节中提及,这里简单说下就是:在put的时候会调用resize方法进行初始化,默认DEFAULT_INITIAL_CAPACITY为16,所以new HashMap使用无参构造函数时table会在put的时候进行初始化数组size为16,因此threshold的值为12。

具体见”在put方法的resize方法“章节的源码。

加载因子为啥默认的是0.75而不是其他?

JDK1.7中源码注释为:

As a general rule, the default load factor (.75) offers a good tradeoff between time and space costs. Higher values decrease the space overhead but increase the lookup cost (reflected in most of the operations of the HashMap class, including get and put). The expected number of entries in the map and its load factor should be taken into account when setting its initial capacity, so as to minimize the number of rehash operations. If the initial capacity is greater than the maximum number of entries divided by the load factor, no rehash operations will ever occur.

大概的意思是:默认值0.75是空间和时间的消耗的平衡。较高的值会减少空间消耗但是会增加查询成本(反应在HashMap中大多数操作,包括get和put)。在设置初始化容量的时,应该考虑预期条目数及其负载系数,以减少rehash的次数。如果初始容量大于最大条目数除以负载系数,则不会发生rehash操作。

意思就是:

  • 如果设置过小,map一下就要扩容了,如果load factor为0.5 cap使用默认的16,当大于threshold=0.5*16,当8使扩容,扩成32,当16时扩容,扩成64当32时扩容,扩成128,当64时扩容,越到后面空间浪费的就越多。如果load factor为1 cap使用默认的16,虽然会减少空间消耗,但每次当用完的时候再扩容会影响put的效率,当load factor小于0时可以后面越扩每次到需要扩充的个数之间相差的空间越大。
  • 如果map中元素的个数可以预期,尽量根据threshold配置好cap,避免没必要的rehash。

为啥是0.75呢?

源码里写的是Poisson distribution 泊松分布,具体的可以参考

为啥要把桶的大小设为2的n次方?

  • ”在get方法的getNode方法“章节中提及,这里不再赘述。
  • 扩容的时候用的上,”在put方法的resize方法“章节中提及,这里不再赘述。

get方法的具体实现-怎么确定桶的数组索引位置的?

”在get方法的getNode方法“章节中提及,这里不再赘述。

put方法的具体实现-扩容的实现?

”在put方法“章节中提及,这里不再赘述。

怎么使hash算法结果分布均匀?怎么达到时间和空间的平衡?

首先key的获得hashCode方法获得的hashCode要足够离散(也就是要有一个好的hash算法,减少hash碰撞),然后为了让hash的离散能再获取下标的时候不会让高位不参与计算HashMap已经做了优化让高位参与。如果有个好的hash算法了,但是计算下标的本质是使用复合好的hash和桶大小减1取模,所以桶的大小越大,余数的范围就越大,也会更加均匀,但是桶大占用空间,所以HashMap设置了阈值,会进行扩容。

红黑树结构?

参见《红黑树了解和手动实现》

什么时候需要转成红黑树?

链表遍历时间复杂度为O(n),红黑树接近O(logn)。同时红黑树比AVL的维护效率高。

为啥链表变红黑树的阈值为8?

因为节点在桶中分布的频率遵循泊松分布,链表中个数为8的概率已经很小了,所以大于等于8才转成红黑树,就算红黑树维护效率不如链表,但是真的链表很长的情况转成红黑树能快速进行查询,功大于过。同时小于8的话链表也很快。

同时也不会因为某个下标的链表长度大于等于8就转成树,也会看(n = tab.length) < MIN_TREEIFY_CAPACITY,还是会优先进行扩容。

什么时候要从红黑树转成链表?

UNTREEIFY_THRESHOLD等于6,所以小于等于6就会进行转成链表,因为链表已经够快了,红黑树优势不明显同时维护成本高。

参考

HashMap 源码详细分析(JDK1.8)

《红黑树了解和手动实现》

Java 8系列之重新认识HashMap