聊聊HashMap和ConcurrentHashMap的扩容机制

目录

一、前言

二、为什么需要扩容

三、为什么负载因子是0.75

四、什么时候扩容

4.1 HashMap的扩容

4.2 ConcurrentHashMap的扩容

 五、红黑树

    5.1 红黑树维持平衡的方式

     5.1.1 染色

     5.1.2 旋转

     5.1.3 验证方式

六、 结束


一、前言

        众所周知,JDK1.8对HashMap做了优化,1.8之前HashMap的结构是数组+链表,1.8之后是数组+链表 or 红黑树。ConcurrentHashMap和HashMap一样,1.8之前是Segment+HashEntry+链表,结构和HashMap类似,1.8之后是Node节点组成的数组+链表or红黑树,跟HashMap类似。

        最近突然兴起,看起了HashMap(以下简称HM)和ConcurrentHashMap(以下简称CHM)的源码,有一些自己的理解和疑惑的地方,如果有同学发现理解不到位或者可以解惑的地方欢迎指出,本次只讨论JDK8的源码部分,JDK7不作考虑。

二、为什么需要扩容

        HMCHM的构造方法都支持一个int类型的参数,public HashMap(int initialCapacity) 和 public ConcurrentHashMap(int initialCapacity)。如果集合长度确定的话,直接传入参数,其初始表大小可容纳指定数量的元素,而无需动态调整大小。

        HMCHM初始完之后,如果该集合还要继续插入数据的话,当出现容量默认大小不能满足需求时,就会进行扩容。扩容操作是由集合自动完成的,当集合中的元素个数超过阈值的时候会自动触发扩容,扩容的阈值就是 集合的当前数组长度 * 负载因子。负载因子在HM中的属性名为 DEFAULT_LOAD_FACTOR,在CHM中的属性名为 LOAD_FACTOR,默认值为0.75。

三、为什么负载因子是0.75

        LOAD_FACTOR(直译:负载因子,因为和扩容操作相关也称扩容因子)为什么是0.75,而不是0.5或者1.0呢,这个在源码中有一段注释说明了,大致意思就是在时间和空间成本权衡而来,太小的值会浪费大部分空间,太大的值会增加get和put等操作的查找成本。还有根据泊松分布计算后得出的结论,有兴趣的小伙伴可以研究一下。

 * <p>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 <tt>HashMap</tt> class, including
 * <tt>get</tt> and <tt>put</tt>).  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.7或者0.8呢,CHM的 initTable()方法中计算阈值的代码为:   sc = n - (n >>> 2) ,其中 n 在第一次初始化时,如果没有设置指定长度initialCapacity的话,默认值为16,一个2的整数次幂 减 右移两位的自己 可以看作为:1-1/4 = 3/4 = 0.75。位运算的效率大于乘法运算的效率,但是在HashMap中的代码是直接用乘法计算的 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);

四、什么时候扩容

        除了上面说的当集合中的元素个数超过阈值的时扩容,还有一种情况就是链表长度超过8时,如果此时集合长度(集合的数组长度)不小于64则转换为红黑树,如果小于64则进行扩容。在HM中是进行一次扩容,CHM中可能是多次扩容。

4.1 HashMap的扩容

写个Demo验证一下HM的链表扩容条件,因为最终目的是转红黑树,红黑树的要求是集合长度达到64,所以设定传入的key最终取模64后结果都一致,代码如下:

public class HashMapDemo {
    public static void main(String[] args) throws Exception{
        hashMapTest();
    }
    public static void hashMapTest() throws Exception{
        HashMap<Integer,String> map = new HashMap<>();
        //获取HashMap整个类
        Class<?> mapType = map.getClass();
        //获取指定属性,也可以调用getDeclaredFields()方法获取属性数组
        Field threshold =  mapType.getDeclaredField("threshold");
        //将目标属性设置为可以访问
        threshold.setAccessible(true);
        //获取指定方法,因为HashMap没有容量这个属性,但是capacity方法会返回容量值
        Method capacity = mapType.getDeclaredMethod("capacity");
        //设置目标方法为可访问
        capacity.setAccessible(true);

        int index = 64;
        for (int i = 0; i < 11 ; i++) {
            map.put(1 + index * i,1+ index * i+"");
            //打印刚初始化的HashMap的容量、阈值和元素数量
            System.out.println("容量:"+capacity.invoke(map)+"    阈值:"+threshold.get(map)+"    元素数量:"+map.size());
        }
        System.out.println(map);
    }
}

有兴趣的小伙伴可以自己运行Debug一下,当插入第9个值的时候此时链表长度达到8,会进行第一次扩容,具体可以定位到putVal方法中的treeifyBin方法,treeifyBin方法中会判断数组长度是否达到64,如果没有的话则扩容一次。如图

putVal()

 treeifyBin()

 按照当前的例子中,当HM的链表插入第9个key的时候,集合第一次进行扩容达到32长度;插入第10个key的时候,集合第二次进行扩容达到64长度;插入第11个key的时候,treeifyBin中的(n = tab.length) < MIN_TREEIFY_CAPACITY判断就不成立,触发红黑树判断,正式转为红黑树。

4.2 ConcurrentHashMap的扩容

        CHM的扩容条件和HM一样,集合内元素达到阈值或者链表长度达到8时扩容,不同的是CHM是线程安全的,支持多线程扩容,考虑的更多,也更为复杂。在JDK1.8中 CHM采用了CAS(CompareAndSwap,比较置换)+ synchronized 的方法来保证并发安全。CAS主要用到的主要属性sizeCtl,sizeCtl默认为0,当sizeCtl为负数时代表正在扩容或者初始化,当sizeCtl为正数时代表当前集合的扩容阈值,table为当前集合的数组。

 /**
  * Table initialization and resizing control.  When negative, the
  * table is being initialized or resized: -1 for initialization,
  * else -(1 + the number of active resizing threads).  Otherwise,
  * when table is null, holds the initial table size to use upon
  * creation, or 0 for default. After initialization, holds the
  * next element count value upon which to resize the table.
  */
 private transient volatile int sizeCtl;

/**
  * The array of bins. Lazily initialized upon first insertion.
  * Size is always a power of two. Accessed directly by iterators.
  */
 transient volatile Node<K,V>[] table;

        前面提到CHM扩容的时候会进行多次扩容,再加上理论转红黑树的条件,集合长度达到64,链表长度达到8时,链表转换为红黑树。我们把上面HM的demo改造一下,由于CHM没有threshold和capacity属性,目前只知道sizeCtl会存入扩容阈值,table.length为当前集合长度,但是本人反射原理理解有限,没办法输出当前数组长度,有兴趣的小伙伴可以自己尝试一下,也可以教教我怎么输出。改造后的代码如下:

public class ConcurrentHashMapDemo {

    public static void main(String[] args)throws Exception {
        ConcurrentHashMap<Integer,String> CHMap = new ConcurrentHashMap<>();
        //获取HashMap整个类
        Class<?> mapType = CHMap.getClass();
        Field sizeCtl =  mapType.getDeclaredField("sizeCtl");
        //将目标属性设置为可以访问
        sizeCtl.setAccessible(true);
        Field table =  mapType.getDeclaredField("table");
        //将目标属性设置为可以访问
        table.setAccessible(true);
        for (int i = 0; i < 11 ; i++) {
            int key = 1 + 64 * i;
            chMap.put(key,key+"");
            System.out.println("key:" + key + ", 阈值:"+sizeCtl.get(chMap)+",元素数量:"+chMap.size());
        }
        System.out.println(CHMap);
    }
}

debug运行之后,和HM一样进入putVal()方法,当链表长度达到8的时候进行treeifyBin()判断,关键就在于treeifyBin()方法内部的tryPresize()方法,这个方法可能会直接多次扩容,该方法使用轮训判断条件是(sc = sizeCtl) >= 0 ,上面介绍过sizeCtl为负数的时候表示正在扩容或者初始化,正数的时候表示当前集合的扩容阈值,所以此时进入循环后结果必定为true。循环内唯一的跳出判断条件是(c <= sc || n >= MAXIMUM_CAPACITY) MAXIMUM_CAPACITY代表集合内数组的最大长度,值为1<<30 = 1073741824,c 进入方法时初始化结果为64,所以sc一定要达到64才会跳出循环,sc 由 sizeCtl 赋值,sizeCtl 又表示集合的扩容阈值,一般是 只会是12、24、48、96,所以当因为链表过长而扩容的时候,数组长度会直接扩容到128。下面贴出源码和运行结果

treeifyBin() ,判断逻辑和 HashMap类似

 tryPresize()

 运行结果:

         在集合中插入第9个元素的时候,阈值直接变成了96,长度没办法输出,可以debug看到table.length是128,按道理长度应该是64,阈值48,目前在其他帖子里面没看到说这个的情况,有知道的小伙伴可以帮忙解惑一下。因为长度变成了128,所以原来的key取模后链表长度达不到8,链表没有树化,可能这也是Doug Lea的细节之处。

        我们可以再debug验证一下,在putVal()treeifyBin()判断加上断点,在树化结构加上断点,因为第9个元素插入的时候已经扩容到了128,如果第10个元素链表没变的话应该直接树化了,我们直接把key设置成578,直接看第11个元素会不会走树化逻辑。最后debug结果是链表长度为5。数组长度128,不满足树化条件,有兴趣的小伙伴可以尝试把插入元素的key的 i * 64改成 i * 128。这个时候CHM会在第10个元素的时候树化。

 五、红黑树

        红黑树是一种特化的AVL树(平衡二叉树),都是在进行插入和删除操作时通过特定操作保持二叉查找树的平衡,从而获得较高的查找性能。

        红黑树的五大特征:

  1. 节点是红色或者黑色
  2. 根(root)结点为黑色
  3. 所有叶子都是黑色。(叶子是NIL结点)
  4. 从任一结点到其每个叶子的所有路径都包含相同数目的黑色结点
  5. 每个红色结点的两个子结点都是黑色

        在满足红黑树五大特征的前提,便于红色树的染色和旋转算法判断,所有新插入的节点默认为红色。

    5.1 红黑树维持平衡的方式

        红黑树维持平衡的方式有染色和旋转(左旋或右旋)。

     5.1.1 染色

        已知插入根节点涂为黑色,其他节点都是涂红色;如果插入结点的父节点为黑色,就不需要进行旋转变色调整,其他情况都需要根据实际选择合适的处理策略进行调整,使其符合红黑树性质。最开始调整的时候是将插入结点作为当前节点。已经有大佬整理出来了,给大佬点赞收藏了。列表如下:

红黑树元素插入旋转变色规则
实际情况处理策略
第一种当前节点的父节点是红色,且其祖父节点的另一个子节点(叔叔节点)也是红色,祖父节点不是根节点。

(1)将父节点和叔叔节点设为黑色。

(2)将祖父节点设为红色。

(3)将祖父节点作为新的当前节点。

第二种当前节点的父节点是红色,叔叔节点也是红色,且当前节点在最边上(即每行最左边或最右边的节点),祖父节点是根节点。

(1)将根节点作为新的当前节点,以根节点为支点进行左旋(插入的是右孩子)或者右旋(左孩子)。

(2)旋转后将新的根节点变黑色,其他节点根据需要变色,只要保证不出现红红连续节点即可。

(3)判断性质5是否已满足,不满足则以当前节点为支点进行一次左旋或右旋,旋转后依旧要保证不出现红红连续节点,否则进行变色。

第三种其他所有情况,前提是当前节点的父节点是红色。

(1)将父节点作为新的当前节点。

(2)以新的当前节点为支点进行左旋(插入的是右孩子)或者右旋(左孩子)。

        参考链接:红黑树旋转变色规则(最全面详细版)_红黑树染色规则_”PANDA的博客-CSDN博客

     5.1.2 旋转

        看到一个作者写的很好,主要是他的小图清晰易懂,我已经给他点赞了。参考链接:红黑树维持平衡的方式解析_红黑树如何保持平衡_我是一颗小小的螺丝钉的博客-CSDN博客

 

左旋动图

右旋动图 

     5.1.3 验证方式

        验证方式:Data Structure Visualization,主页有各种树结构可以测试,虽然页面都是全英文但是对于开发的小伙伴来说清晰易懂,有手就行。

六、 结束

  

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值