ConcurrentHashMap源码分析(一)

本文详细解析了ConcurrentHashMap在JDK1.8中的实现,包括其线程安全机制、数据结构、get和put操作的源码剖析,以及为何选择Node+Synchronized。讲解了get的无锁与红黑树锁的策略,put的原子性和扩容过程。适合理解并发数据结构和性能优化。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

​一 简介

        ConcurrentHashMap是HashMap的同步容器,是线程安全的,通过CAS和Synchronized来实现线程安全!

        JDK 1.8 将之前的Segment+ReentrantLock改为Node+Synchronized的原因: 

        因为ConcurrentHashMap已经将锁细化到了一个槽中,冲突不会太大。

        Synchronized在重量锁的时候有自旋锁的行为,不会一下就挂起,而ReentrantLock的话,当前线程只要不是排在第二个等待节点就会被挂起,消耗资源。线程的挂起和唤醒会导致线程之间的切换,会频繁的导致内核态和用户态之间的切换,十分耗费资源。

        综合而言,Synchronized的性能会比较好,并且占用内存小。

二 数据结构

        ConcurrentHashMap1.8和HashMap一样都是采用拉链法处理hash冲突,且为了防止链表长度过长影响查询效率,所以当链表长度超过阈值,就会将其转成红黑树。采用数组+链表+红黑树的结构

node结构

final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
​
Node(int hash, K key, V val, Node<K,V> next) {
    this.hash = hash;
    this.key = key;
    this.val = val;  //采用volatile关键字修饰
    this.next = next;  //采用volatile关键字修饰
}

常用属性

//数组最大容量
private static final int MAXIMUM_CAPACITY = 1 << 30;
//默认初始化容量,必须是2的幂次方  
private static final int DEFAULT_CAPACITY = 16;
//最大可能数组大小
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;
//存放元素的数组容器
transient volatile Node<K,V>[] table;
//扩容时生成的数组,容量为之前的2倍
private transient volatile Node<K,V>[] nextTable;
//多个线程的共享变量,操作的控制标识符:
// -1代表正在初始化
// -(1+nThreads),表示有n个线程正在一起扩容
// 0代表rehash还没有被初始化,默认值
// 大于0表示下一次进行扩容的容量大小
private transient volatile int sizeCtl;
// putVal时,无锁竞争统计的value个数
private transient volatile long baseCount;
//putVal时,有锁竞争统计的value个数(数组形式)
private transient volatile CounterCell[] counterCells;

三 源码详解

1、get()方法

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    // 扰动函数计算key的hash值,取到其所在的数组下标位置
    int h = spread(key.hashCode());
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) { // 读取首节点的Node元素
        // 如果当前节点的hash值和首节点的Hash值相同,并且value也相同,直接返回。
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        // hash为负值,表示正在扩容。这时需要用到ForwardingNode的find()方法定位到nextTable。
        //   1) eh=-1,说明该节点是一个ForwardingNode,正在扩容迁移,此时调用ForwardingNode的find()方法去nextTable里找
        //   2) eh=-2,说明该节点是一个TreeBin,此时调用TreeBin的find()方法遍历红黑树,注意:由于红黑树可能正在旋转变色,所以find()方法里会加一个读写锁。
        else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;
        // eh >=0 说明该节点是一个链表节点,直接遍历链表即可。
        while ((e = e.next) != null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

流程:

        1:使用扰动函数计算key的hash值,取到其所在的数组下标位置。

        2:如果节点是首节点,直接返回。

        3:如果节点的hash值eh是负值,说明ConCurrentHashMap正在进行扩容,或该节点是一个树节点TreeBin。

如果eh=-1表示正在扩容,该节点是一个 ForwardingNode(树头节点),直接调用ForwardingNode的find方法去nextTable里找;
如果eh=-2,该节点是一个TreeBin,调用TreeBin的find()方法遍历红黑树,由于红黑树有可能在变色旋转,所以find()里会有读写锁。

        4:最后如果eh>0说明节点是链表,遍历链接即可。

TreeBin.find()方法:

        该方法多了一个基于CAS实现的读写锁,通过TreeBin查找某个节点时,如果当前写锁被占用或者有等待获取写锁的线程,表示红黑树处于正在调整的过程中,则遍历链表查找;

        如果写锁没有被占用且没有等待的线程,则抢占读锁,遍历红黑树的节点来查找,读锁释放时会判断是否所有的读锁都释放了,如果都释放了且当前有等待获取写锁的线程,则唤醒正在等待中的线程。

final Node<K,V> find(int h, Object k) {
    if (k != null) {
        for (Node<K,V> e = first; e != null; ) {
            int s; K ek;
            // 如果当前读写锁的状态是WAITER或者WRITER,则通过链表遍历查找,此时红黑树正在调整的过程中。
            if (((s = lockState) & (WAITER|WRITER)) != 0) {
                if (e.hash == h &&
                    ((ek = e.key) == k || (ek != null && k.equals(ek))))
                    return e;
                e = e.next;
            }
            // //如果不是上述两种状态,则将状态设置为READER,每次都会加上READER,表示正在遍历红黑树,此时就不能调整红黑树了
            else if (U.compareAndSwapInt(this, LOCKSTATE, s,
                                         s + READER)) {
                TreeNode<K,V> r, p;
                try {
                    // 如果根节点为null,则返回null;否则通过根节点查找匹配的节点。注意:进入此方法根节点不可能为null
                    p = ((r = root) == null ? null :
                         r.findTreeNode(h, k, null));
                } finally {
                    Thread w;
                    if (U.getAndAddInt(this, LOCKSTATE, -READER) == //所有的读锁都释放了
                        (READER|WAITER) && (w = waiter) != null) //且有等待获取写锁的线程
                        // 唤醒该线程
                        LockSupport.unpark(w);
                }
                return p;
            }
        }
    }
    return null;
}

总结

        1、java8中ConcurrentHashMap的get()正常情况是不需要加锁的,不加锁的原因:

1:get()方法访问的大多数变量是volatile关键字修饰的,比如:Node.val、Node.next、count,volatile保证了其值的修改对其他线程的可见性。
即:在多线程环境下线程A修改Node节点的val或者新增节点对线程B是可见的。
2:像引用类型:数组table用volatile修饰,在数组扩容的时候就保证了其引用的改变对其他线程的可见性
3:final域确保变量初始化的安全性,初始化安全性让不可变对象不需要同步就可以被自由的访问和共享。

        这也是它比其他并发集合,如hashtable、用Collections.synchronizedMap()包装的hashmap;效率高的原因之一。

        2、但是当节点是红黑树的时候,如果树正在变色旋转并且要查询的值不是红黑树的头节点,会加一个读写锁。

        3、另外其迭代器iterator是弱一致性的,因为在迭代过程中可以向map中添加元素;而HashMap是强一致性的。

2、put()方法

        首先我们先看下源码:

public V put(K key, V value) {
    return putVal(key, value, false);
}
​
/**
 * Implementation for put and putIfAbsent
 */
final V putVal(K key, V value, boolean onlyIfAbsent) {
    // key和value都不允许为空,因为如果为空的话,线程不安全,会出现数据错乱。
    if (key == null || value == null) throw new NullPointerException();
    // 使用扰动函数计算key的hash值
    int hash = spread(key.hashCode());
    int binCount = 0;
    // phase1:对数组做无限循环,其中包含四种情况的判断
    for (Node<K, V>[] tab = table; ; ) {
        Node<K, V> f;
        int n, i, fh;
​
        /**
         * part1, table数组需要被创建,即table数组为null 或 长度为0,则懒汉式初始化数组
         */
        if (tab == null || (n = tab.length) == 0)
            // todo 如何保证初始化数组是线程安全的?
            tab = initTable();
​
        /**
         * part2,table数组已经被创建,并且寻址后的数组位置没有被占用。
         * 因为table数组是线程间可见的,但数组元素不是。所以采用volatile读的方式来读取数组中的元素,保证每次拿到的数据都是最新的。
         * tabAt(tab, i = (n - 1) & hash)) 相当于 table[i]
         */
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 直接基于主内存地址,创建Node节点 并 通过CAS将其插入到当前位置;CAS失败则进入下一次table数组循环
            if (casTabAt(tab, i, null,
                    new Node<K, V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
​
        // part3: 寻址到的位置正在进行扩容/迁移操作,即:数组位置的hash值 = MOVED = -1,
        else if ((fh = f.hash) == MOVED)
            // 当前线程加入到扩容/迁移大军,帮助数组进行扩容/迁移。
            tab = helpTransfer(tab, f);
​
            // part4:其他情况,即:hash冲突的场景
        else {
            V oldVal = null;
            // 只针对单个数组元素采用synchronized锁(而不是对整个数组加锁),确保线程安全的将节点插入到链表 或 红黑树中
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    // 当前节点的hash值大于0,表明该节点是正常节点,不是被扩容/迁移中的节点。
                    if (fh >= 0) {
                        binCount = 1;
                        // phase1: 遍历链表,将节点插入到链表中
                        for (Node<K, V> e = f; ; ++binCount) {
                            K ek;
                            // 待插入的key与已有链表节点f的key相同
                            if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                            (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                // 如果onlyIfAbsent为false对value值进行替换
                                if (!onlyIfAbsent)
                                    e.val = value;
                                break;
                            }
                            Node<K, V> pred = e;
                            // 如果链表中没有key和待插入的key相同,封装新节点插入到链表的尾部。
                            if ((e = e.next) == null) {
                                pred.next = new Node<K, V>(hash, key,
                                        value, null);
                                break;
                            }
                        }
                    }
                    /**
                     * 红黑树的操作
                     * phase2: 如果节点类型是红黑树节点,将节点插入到红黑树中,如果找到相同key的节点,则根据onlyIfAbsent判断是否替换value值
                     */
                    else if (f instanceof TreeBin) {
                        Node<K, V> p;
                        binCount = 2;
                        if ((p = ((TreeBin<K, V>) f).putTreeVal(hash, key,
                                value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
            /**
             * phase3: 转换元素存储类型,即:链表转红黑树的操作
             */
            if (binCount != 0) {
                // 如果链表的个数大于等于8,则将链表转为红黑树
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                // 如果存在旧的value值,则将旧的value值返回
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    // phase2:将ConcurrentHashMap中存储的KV键值对总数+1
    addCount(1L, binCount);
    return null;
}

引用别人的一张流程图:

操作步骤:

ConCurrentHashMap的put流程可以分为校验和两个阶段。

        首先校验key和value不能为null,否者会抛出空指针异常。检验通过之后会通过使用异或和与操作的扰动函数计算key的hash值。

其次,两个阶段分别为:

  • 第一阶段对table数组做无限循环进行插入KV键值对操作,其中包含四种情况;
  • 第二阶段将ConcurrentHashMap中存储的KV键值对总数+1。

第一阶段可以分为四个步骤来看:

1、table数组需要被创建时,即table数组为null 或 长度为0时,则懒汉式初始化数组(第一次put时才初始化)。由于可能存在多个线程并发的初始化数组,所以首先初始化数组要保证线程安全:

  • initTable()中while死循环table数组为空的场景:
  • 如果sizeCtl < 0 表示有其他线程正在初始化数组,此时使用 Thread.yield() 将当前线程由运行态进入到就绪态,让出CPU资源;即当前线程在外面自旋,直到table初始化完毕才能跳出while循环。

  • 否则,如果通过CAS成功将sizeCtl设置为-1,则当前线程进行数组的初始化,并将sizeCtl设置为扩容阈值(默认为12)。

  • CAS竞争失败的线程;同样在外面自旋,直到table数组初始化完毕才能跳出While循环。

  • 总的来说initTable()初始化数组的线程安全性是通过一个volatile修饰的变量sizeCtl 结合 CAS来实现的。

2、table数组已经被创建,并且寻址后的数组位置没有被占用。

  • 因为table数组是线程间可见的,但数组元素不是。所以采用volatile读的方式来读取数组中的元素,保证每次拿到的数据都是最新的。
  • 然后直接基于主内存地址,通过CAS将创建的Node节点插入到当前位置;CAS失败则进入下一次table数组循环。

3、寻址到的位置正在进行扩容/迁移操作,即:数组位置的hash值 = MOVED = -1;

        将当前线程加入到扩容/迁移大军,通过执行helpTransfer()方法来协助其他线程进行扩容/迁移操作。

4、出现hash冲突的场景;

        首先只针对单个数组元素加synchronized锁(而不是对整个数组加锁),确保线程安全的将节点插入到链表 或 红黑树中;

这种情况大致分为三个部分:

  • 遍历链表,将节点插入到链表中;如果链表中存在相同key的节点,则根据onlyIfAbsent判断是否替换value值;否者将新Node放入到链表尾部。
  • 如果节点类型是红黑树节点,将节点插入到红黑树中;如果找到相同key的节点,则根据onlyIfAbsent判断是否替换value值。

  • 如果链表长度大于8,则转换元素存储类型,即链表转红黑树。

总结:

1、当不存在hash冲突时,采用基于主内存的volatile读来读取数组元素、采用CAS确保存储数据的线程安全性;

2、当存在hash冲突时,针对单个数组位置加synchronized锁;链表转红黑树时也是对单个数组位置加synchronized锁;

3、帮助其他线程进行数据迁移/扩容时,通过对volatile修饰的变量nextTable、sizeCtl进行volatile读确保线程安全性。

第二阶段调用addCount()方法,将ConcurrentHashMap中存储的KV键值对总数+1。

addCount()方法,我们先只看前半部,后半部分的扩容下篇文章讲接:

private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
​
    //U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x) 每次竟来都baseCount都加1因为x=1
    if ((as = counterCells) != null ||
        !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {//1
        CounterCell a; long v; int m;
        boolean uncontended = true;
        if (as == null || (m = as.length - 1) < 0 ||
            (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
            !(uncontended =
              U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
            //多线程CAS发生失败的时候执行
            fullAddCount(x, uncontended);//2
            return;
        }
        if (check <= 1)
            return;
        s = sumCount();
    }
    if (check >= 0) {
        Node<K,V>[] tab, nt; int n, sc;
        //当条件满足开始扩容
        while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
               (n = tab.length) < MAXIMUM_CAPACITY) {
            int rs = resizeStamp(n);
            if (sc < 0) {//如果小于0说明已经有线程在进行扩容操作了
                //一下的情况说明已经有在扩容或者多线程进行了扩容,其他线程直接break不要进入扩容操作
                if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                    sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                    transferIndex <= 0)
                    break;
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))//如果相等说明扩容已经完成,可以继续扩容
                    transfer(tab, nt);
            }
            //这个时候sizeCtl已经等于(rs << RESIZE_STAMP_SHIFT) + 2等于一个大的负数,这边加上2很巧妙,因为transfer后面对sizeCtl--操作的时候,最多只能减两次就结束
            else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                         (rs << RESIZE_STAMP_SHIFT) + 2))
                transfer(tab, null);
            s = sumCount();
        }
    }
}

        通过对volatile修饰的变量baseCount、sizeCtl进行CAS,对CounterCell进行CAS确保对数量修改的线程安全性。在并发量很高时,如果存在两个线程同时执行CAS修改baseCount值,失败的线程会继续执行fullAddCount(x, uncontended)方法,这个方法其实就是初始化counterCells,并将x的值插入到counterCell类中,而x值一般也就是1。

3、size()方法

public int size() {
    long n = sumCount();
    return ((n < 0L) ? 0 : (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
}
​
final long sumCount() {
    CounterCell[] as = counterCells; CounterCell a;
    long sum = baseCount;
    if (as != null) {
        for (int i = 0; i < as.length; ++i) {
            if ((a = as[i]) != null)
                sum += a.value;
        }
    }
    return sum;
}

当 counterCells 不是 null,就遍历元素,并和 baseCount 累加。(变量上边文章有介绍)

counterCell结构

// 一种用于分配计数的填充单元。改编自LongAdder和Striped64。请查看他们的内部文档进行解释。
@sun.misc.Contended 
static final class CounterCell {
    volatile long value;
    CounterCell(long x) { value = x; }
}

其中的@sun.misc.Contended,是Java8新增的一个注解,对某个字段加上该注解 则表示该字段会单独占用一个缓存行(Cache Line);

  • 缓存行是指CPU缓存(L1、L2、L3)的存储单元,常见的缓存行大小为64字节;

  • JVM添加-XX:-RestrictContended参数后@sun.misc.Contended注解才有效。

单独使用一个缓存行可以避免伪共享:

        为了提高读取速度,每个CPU都有自己的缓存,CPU读取数据后会存到自己的缓存里;并且为了节省空间,一个缓存行可能存储着多个变量,即伪共享。但是伪共享对于共享变量,会造成性能问题,比如:

当一个CPU要修改共享变量A时会先锁定自己缓存里 A所在的缓存行,并且把其他CPU缓存上相关的缓存行设置为无效。但如果被锁定或失效的缓存行里,还存储了其他不相干的变量B,其他线程此时就访问不了B。

或者由于缓存行失效需要重新从内存中读取加载到缓存里,也就造成了开销。

所以让共享变量A单独使用一个缓存行就不会影响到其他线程对其他共享变量的访问。

适用场景

        主要适用于会被频繁写的共享数据上。如果不是被频繁写的数据,那么CPU缓存行被锁的几率就不大,所以没必要使用;否则不仅占空间还会浪费CPU访问/操作数据的时间。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值