深入解析ConcurrentHashMap

前言

ConcurrentHashMap是个老生常谈的集合类了,我们都知道多线程环境下不能直接使用HashMap,而需要使用ConcurrentHashMap,但有没有了解过ConcurrentHashMap到底是如何实现线程安全的呢?他到底跟传统的Hashtable和SynchronizeMap(没听过SynchronizeMap?他就是Collections.synchronizeMap方法返回的对象)到底好在哪?

ConcurrentHashMap建立在HashMap的基础上实现了线程安全,关于HashMap读者可以参考这篇文章:深入剖析HashMap,从散列表的三大要素:哈希函数、哈希冲突、扩容方案、以及线程安全展开详解HashMap的设计。关于HashMap的内容本文不再赘述,读者若对HashMap的底层设计不了解,一定要先去阅读前面的文章。ConcurrentHashMap中蕴含的并发编程智慧是非常值得我们学习的,正如文章开头的两个问题,你会如何解决呢?可能会直接上锁或用更高性能的CAS,但ConcurrentHashMap给了我们更不一样的解决方案。

本文的主要内容是讲解ConcurrentHashMap中的并发设计,重点分析ConcurrentHashMap的四个方法源码:putValinitTableaddCounttransfer。分析每个方法前会使用图解介绍ConcurrentHashMap的核心思路。源码中我加了非常详细的注释,有时间仍建议读者阅读完源码,ConcurrentHashMap的并发智慧,都蕴含在源码中。

CAS与自旋锁

CAS是ConcurrentHashMap中的一个重点,也是ConcurrentHashMap提升性能的根基所在。在阅读源码中,可以发现CAS无处不在。在介绍ConcurrentHashMap前,必须先介绍一下这两个重点。

Java中的运算并不是原子操作,如count++可分为:

  1. 获取count副本count_
  2. 对count_进行自增
  3. 把count_赋值给count

如果在第一步之后,count被其他的线程修改了,第三步的赋值会直接覆盖掉其他线程的修改。synchronize可以解决这个问题,但上锁为重量级操作,严重影响性能,CAS是更好的解决方案。

CAS的思路并不复杂。还是上面的例子:当我们需要对变量count进行自增时,我们可以认为没有发生并发冲突,先存储一个count副本,再对count进行自增,然后把副本和count本身进行比较,如果两者相同,则证明没有发生并发冲突,修改count的值;如果不同,则说明count在我们自增的过程中被修改了,把上述整个过程重新来一遍,直到修改成功为止,如下图:

那,如果我们在判断count==count_之后,count被修改了怎么办?比较赋值的操作操作系统会保证的原子性,保证不会出现这种情况。在java中常见的CAS方法有:

// 比较并替换
U.compareAndSwapInt();
U.compareAndSwapLong();
U.compareAndSwapObject();

在后续的源码中,我们会经常看到他们。通过这种思路,我们不需要给count变量上锁。但如果并发度过高,处理时间过长,则会导致某些线程一直在循环自旋,浪费cpu资源

自旋锁是利用CAS而设计的一种应用层面的锁。如下代码:

// 0代表锁释放,1代表锁被某个线程拿走了
int lock = 0;

while(true){
  	if(lock==0){
    	int lock_ ;
    	if(U.compareAndSwapInt(this,lock_,0,1)){
            ... // 获取锁后的逻辑处理
                
            // 最后释放锁
            lock = 0;
            break;
    	}
	}  
}

上面就是很经典自旋锁设计。判断锁是否被其他线程拥有,若没有则尝试使用CAS获得锁;前两步失败都会重新循环再次尝试直到获得锁。最后逻辑处理完成要令lock=0来释放锁。冲突时间短的并发情景下这种方法可以大大提升效率。

CAS和自旋锁在ConcurrentHashMap应用地非常广泛,在源码中我们会经常看到他们的身影。同时这也是ConcurrentHashMap的设计核心所在。

认识ConcurrentHashMap的并发策略

Hashtable与SynchronizeMap采取的并发策略是对整个数组对象加锁,导致性能及其低下。jdk1.7之前,ConcurrentHashMap采用的是锁分段策略来优化性能,如下图:

相当于把整个数组,拆分成多个小数组。每次操作只需要锁住操作的小数组即可,不同的segment之间不互相影响,提高了性能。jdk1.8之后,对整个策略进行了重构:锁的不是segment,而是节点,如下图:

锁的粒度进一步被降低,并发的效率也提高了。jdk1.8做得优化不只是细化锁粒度,还带来了CAS+synchronize的设计。那么下面,我们针对ConcurrentHashMap的常见方法:添加、删除、扩容、初始化等进行详解他的设计思路。

添加数据:putVal()

ConcurrentHashMap添加数据时,采取了CAS+synchronize结合策略。首先会判断该节点是否为null,如果为null,尝试使用CAS添加节点;如果添加失败,说明发生了并发冲突,再对节点进行上锁并插入数据。在并发较低的情景下无需加锁,可以显著提高性能。同时只会CAS尝试一次,也不会造成线程长时间等待浪费cpu时间的情况。

ConcurrentHashMap的put方法整体流程如下(并不是全部流程):

  1. 首先会判断数组是否已经初始化,如若未初始化,会先去初始化数组;
  2. 如果当前要插入的节点为null,尝试使用CAS插入数据;
  3. 如果不为null,则判断节点hash值是否为-1;-1表示数组正在扩容,会先去协助扩容,再回来继续插入数据。(协助扩容后面会讲)
  4. 最后会执行上锁,并插入数据,最后判断是否需要返回旧值;如果不是覆盖旧值,需要更新map中的节点数,也就是图中的addCount方法。

ConcurrentHashMap是基于HashMap改造的,其中的插入数据、hash算法和HashMap都大同小异,这里不再赘述。思路清晰之后,下面我们看源码分析:

final V putVal(K key, V value, boolean onlyIfAbsent) {
    // 不允许插入空值或空键
    // 允许value空值会导致get方法返回null时有两种情况:
    // 1. 找不到对应的key2. 找到了但是value为null;
    // 当get方法返回null时无法判断是哪种情况,在并发环境下containsKey方法已不再可靠,
    // 需要返回null来表示查询不到数据。允许key空值需要额外的逻辑处理,占用了数组空间,且并没有多大的实用价值。
    // HashMap支持键和值为null,但基于以上原因,ConcurrentHashMap是不支持空键值。
    if (key == null || value == null) throw new NullPointerException();
    // 高低位异或扰动hashcode,和HashMap类似
    // 但有一点点不同,后面会讲,这里可以简单认为一样的就可以
    int hash = spread(key.hashCode());
    // bincount表示链表的节点数
    int binCount = 0;
    // 尝试多种方法循环处理,后续会有很多这种设计
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        // 情况一:如果数组为空则进行初始化
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        // 情况二:目标下标对象为null
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 重点:采用CAS进行插入
            if (casTabAt(tab, i, null,new Node<K,V>(hash, key, value, null)))
                break;
        }
        // 情况三:数组正在扩容,帮忙迁移数据到新的数组
        // 同时会新数组,下次循环就是插入到新的数组
        // 关于扩容的内容后面再讲,这里理解为正在扩容即可
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        // 情况四:直接对节点进行加锁,插入数据
        // 下面代码很多,但逻辑和HashMap插入数据大同小异
        // 因为已经上锁,不涉及并发安全设计
        else {
            V oldVal = null;
            // 同步加锁
            synchronized (f) {
                // 重复检查一下刚刚获取的对象有没有发生变化
                if (tabAt(tab, i) == f) {
                    // 链表处理情况
                    if (fh >= 0) {
                        binCount = 1;
                        // 循环链表
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            // 找到相同的则记录旧值
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                 (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                // 判断是否需要更新数值
                                if (!onlyIfAbsent)
                                    e.val = value;
                                break;
                            }
                            Node<K,V> pred = e;
                            // 若未找到则插在链表尾
                            if ((e = e.next) == null) {
                                pred.next = new Node<K,V>(hash, key,
                                                          value, null);
                                break;
                            }
                        }
                    }
                    // 红黑树处理情况
      
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值