JAVA源码之ConcurrentHashMap

·  阅读 568

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

与HashMap一样,1.7与1.8版本的ConcurrentHashMap的数据结构也不一样。

1.7与1.8的区别

1.7

JDK1.7 中的 ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成,即 ConcurrentHashMap 把哈希桶数组切分成小数组(Segment ),每个小数组有 n 个 HashEntry 组成。

如下图所示,首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一段数据时,其他段的数据也能被其他线程访问,实现了真正的并发访问。

image-20220314141742909-16472386654097.png Segment 继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。Segment 默认为 16,也就是并发度为 16。

1.8

在数据结构上, JDK1.8 中的ConcurrentHashMap 选择了与 HashMap 相同的Node数组+链表+红黑树结构;在锁的实现上,抛弃了原有的 Segment 分段锁,采用CAS + synchronized实现更加细粒度的锁。

将锁的级别控制在了更细粒度的哈希桶数组元素级别,也就是说只需要锁住这个链表头节点(红黑树的根节点),就不会影响其他的哈希桶数组元素的读写,大大提高了并发度。

image-20220314142026203.png

JDK1.8 中为什么使用内置锁 synchronized替换 可重入锁 ReentrantLock?

  1. 在 JDK1.6 中,对 synchronized 锁的实现引入了大量的优化,并且 synchronized 有多种锁状态,会从无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁一步步转换。
  2. 减少内存开销 。假设使用可重入锁来获得同步支持,那么每个节点都需要通过继承 AQS 来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表的头节点(红黑树的根节点)需要同步,这无疑带来了巨大内存浪费。

基础说明

常量定义

//最大容量
private static final int MAXIMUM_CAPACITY = 1 << 30;
//默认容量
private static final int DEFAULT_CAPACITY = 16;
//最大的数组长度,toArray方法需要
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
//负载因子
private static final float LOAD_FACTOR = 0.75f;
//链表树化的阈值
static final int TREEIFY_THRESHOLD = 8;
//红黑树变成链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;
//链表需要树化的最小容量要求
static final int MIN_TREEIFY_CAPACITY = 64;
//在进行扩容时单个线程处理的最小步长。
private static final int MIN_TRANSFER_STRIDE = 16;
//sizeCtl 中用于生成标记的位数。对于 32 位数组,必须至少为 6。
private static int RESIZE_STAMP_BITS = 16;
//可以帮助调整大小的最大线程数。必须适合 32 - RESIZE_STAMP_BITS 位。
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;
//在 sizeCtl 中记录大小标记的位移。
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
//哈希表中的节点状态,会在节点的hash值中体现
static final int MOVED     = -1; // hash for forwarding nodes
static final int TREEBIN   = -2; // hash for roots of trees
static final int RESERVED  = -3; // hash for transient reservations
static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash

//基本计数器值(拿来统计哈希表中元素个数的),主要在没有争用时使用,但也可作为表初始化竞赛期间的后备。通过 CAS 更新。
private transient volatile long baseCount;
//表初始化和调整大小控制。如果为负数,则表正在初始化或调整大小:-1 表示初始化,否则 -(1 + 活动调整大小线程的数量)。否则,当 table 为 null 时,保存要在创建时使用的初始表大小,或者默认为 0。初始化后,保存下一个元素计数值,根据该值调整表的大小。
private transient volatile int sizeCtl;
//调整大小时要拆分的下一个表索引(加一个)。
private transient volatile int transferIndex;
//调整大小和/或创建 CounterCell 时使用自旋锁(通过 CAS 锁定)。
private transient volatile int cellsBusy;
//计数单元表。当非空时,大小是 2 的幂。   与baseCount一起记录哈希表中的元素个数。
private transient volatile CounterCell[] counterCells;
复制代码

spread

/**
将散列的较高位传播(XOR)到较低位,并将最高位强制为 0。由于该表使用二次幂掩码,因此仅在当前掩码之上的位中变化的散列集总是会发生冲突。 (已知的例子是在小表中保存连续整数的 Float 键集。)因此,我们应用了一种变换,将高位的影响向下传播。在位扩展的速度、实用性和质量之间存在折衷。因为许多常见的散列集已经合理分布(所以不要从传播中受益),并且因为我们使用树来处理 bin 中的大量冲突,我们只是以最便宜的方式对一些移位的位进行异或,以减少系统损失,以及合并最高位的影响,否则由于表边界,这些最高位将永远不会用于索引计算。
*/
static final int spread(int h) {
    return (h ^ (h >>> 16)) & HASH_BITS;
}
复制代码

通过spread后,所有Key操作的hash值都是大于等于0的数。所以ConcurrentHashMap就用Node的hash来记录节点的状态。参考上面的常量定义:MOVED、TREEBIN、RESERVED。

put操作

image-20220314180212299-16472521346978.png

initTable

初始化操作很简单,直接看源码就好了

private final Node<K,V>[] initTable() {
        Node<K,V>[] tab; int sc;
    //while循环一直来检查table是不是已经被初始化好了
        while ((tab = table) == null || tab.length == 0) {
            //看变量是不是小于0,负数表示有其他线程正在则表正在初始化或调整大小,当前线程像调度器表示当前线程愿意让出CPU
            if ((sc = sizeCtl) < 0)
                Thread.yield(); // 失去了初始化竞赛;只是自旋
            //CAS加锁:比较当前对象的SIZECTL偏移量的位置的值是不是sc,如果是则将SIZECTL偏移量的位置的值设置成-1。    如果当前线程成功设置成-1,那么其他线程再CAS的时候就会发现这个地方的值不是原来的sc了,就加锁失败,退出。
            //另外:sc>0表示哈希表初始化或要扩容的大小
            else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
                try {
                    //CAS成功后,再次判断table是不是未初始化,避免在CAS的之前一刻,其他线程完成了初始化操作。
                    if ((tab = table) == null || tab.length == 0) {
                        //计算扩容大小,如果没指定扩容大小,那么按默认容量初始化
                        int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                        @SuppressWarnings("unchecked")
                        Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                        table = tab = nt;
                        // >>> 无符号右移,所有sc=(3/4)n,也就是n*0.75;所以这行代码的意思就是把下一次扩容的阈值设置给sc
                        sc = n - (n >>> 2);
                    }
                } finally {
                    //最后将sc设置给sizeCtl,try成功的情况下,sizeCtl记录的则是下一次扩容的阈值;
                    sizeCtl = sc;
                }
                //退出初始化操作
                break;
            }
        }
        return tab;
    }
复制代码

addCount

转移操作是在扩容的时候发生的,扩容是在元素个数增加后发生的:

在看addCount源码前看下ConcurrentHashmap的计数设计,这样再去看源码比较容易看懂。

计数设计概览

image-20220315144505540.png

计数逻辑

image-20220315144203536.png

fullAddCount过程

TODO 待补充

helpTransfer

TODO 待补充

扩容和协助扩容是通过多线程领取任务的方式协助完成的。扩容是先nextTable指向一个扩容大小长度Node数组,然后由多线程一起协助将Table里面的数据转移到nextTable上。通过领取任务的方式从table上按步长领取转移任务,最后一个线程完成转移任务后将table指向nextTable即完成扩容操作。

分类:
后端
标签:
分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改