JUC并发编程11——ConcurrentHashMap

目录

一.简介

1.1各种锁简介

(1)synchronized

(2)CAS

(3)volatile(非锁)

(4)自旋锁

(5)分段锁

(6)ReentrantLock

二.LongAdder

2.1引入背景

2.2源码分析

主要内部类

主要属性

add(x)方法

longAccumulate()方法

sum()方法

三.ConcurrentHashMap

添加元素

初始化桶数组

判断是否需要扩容

协助扩容(迁移元素)

迁移元素

获取元素

获取元素个数

总结

误区


一.简介

ConcurrentHashMap是HashMap的线程安全版本,内部也是使用(数组 + 链表 + 红黑树)的结构来存储元素。

相比于同样线程安全的HashTable来说,效率等各方面都有极大地提高。

1.1各种锁简介

(1)synchronized

java中的关键字,内部实现为监视器锁,主要是通过对象监视器在对象头中的字段来表明的。

synchronized从旧版本到现在已经做了很多优化了,在运行时会有三种存在方式:偏向锁,轻量级锁,重量级锁。

  1. 偏向锁是指一段同步代码一直被一个线程访问,那么这个线程会自动获取锁,降低获取锁的代价。
  2. 轻量级锁:是指当锁是偏向锁时被另一个线程所访问,偏向锁会升级为轻量级锁,这个线程会通过自旋的方式尝试获取锁,不会阻塞,提高性能
  3. 重量级锁:是指当锁是轻量级锁时,当自旋的线程自旋了一定的次数后,还没有获取到锁,就会进入阻塞状态该锁升级为重量级锁,重量级锁会使其他线程阻塞,性能降低。

(2)CAS

CAS,Compare And Swap,它是一种乐观锁,认为对于同一个数据的并发操作不一定会发生修改,在更新数据的时候,尝试去更新数据,如果失败就不断尝试。

(3)volatile(非锁)

java中的关键字,当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

volatile只保证可见性,不保证原子性。比如 volatile修改的变量 i,针对i++操作,不保证每次结果都正确,因为i++操作是两步操作,相当于 i = i +1,先读取,再加1,这种情况volatile是无法保证的。

(4)自旋锁

自旋锁,是指尝试获取锁的线程不会阻塞,而是循环的方式不断尝试,这样的好处是减少线程的上下文切换带来的开锁,提高性能,缺点是循环会消耗CPU。

(5)分段锁

分段锁,是一种锁的设计思路,它细化了锁的粒度,主要运用在ConcurrentHashMap中,实现高效的并发操作,当操作不需要更新整个数组时,就只锁数组中的一项就可以了。

(6)ReentrantLock

可重入锁,是指一个线程获取锁之后再尝试获取锁时会自动获取锁,可重入锁的优点是避免死锁。

synchronized也是可重入锁。

二.LongAdder

2.1引入背景

LongAdder是JDK1.8在java.util.concurrent.atomic包下新引入的 为了高并发下实现高性能统计的类。

AtomicLong是通过volatile修饰的long成员变量value保存当前值,保证可见性。在高并发下对单一变量进行CAS操作,从而保证其原子性

public final long getAndAdd(long delta) {
    return unsafe.getAndAddLong(this, valueOffset, delta);
}

在Unsafe类中,如果有多个线程进入,只有一个线程能成功CAS,其他线程都失败。失败的线程会重复进行下一轮的CAS,但是下一轮还是只有一个线程成功。

public final long getAndAddLong(Object o, long offset, long delta) {
    long v;
    do {
        v= this.getLongVolatile(o,offset);
    } while(!this.compareAndSwapLong(o,offset, v, v+delta));
    return v;
}

即在高并发下,AtomicLong的性能会越来越差劲。因此,引入了替代方案,LongAdder。

2.2源码分析

LongAdder的原理是,在最初无竞争时,只更新base的值,当有多线程竞争时通过分段的思想,让不同的线程更新不同的段,最后把这些段相加就得到了完整的LongAdder存储的值。

LongAdder继承自Striped64抽象类,Striped64中定义了Cell内部类和各重要属性。

主要内部类

  • Cell类使用@sun.misc.Contended注解,说明是要避免伪共享的。
  • 使用Unsafe的CAS更新value的值,其中value的值使用volatile修饰,保证可见性。

主要属性

最初无竞争或有其它线程在创建cells数组时使用base更新值,有过竞争时使用cells更新值。

  • 最初无竞争:是指一开始没有线程之间的竞争,但也有可能是多线程在操作,只是这些线程没有同时去更新base的值。
  • 有过竞争:是指只要出现过竞争不管后面有没有竞争都使用cells更新值,规则是不同的线程hash到不同的cell上去更新,减少竞争。

add(x)方法

add(x)方法是LongAdder的主要方法,使用它可以使LongAdder中存储的值增加x,x可为正可为负。

  1. 最初无竞争时只更新base;
  2. 直到更新base失败时,创建cells数组;
  3. 当多个线程竞争同一个Cell比较激烈时,可能要扩容

longAccumulate()方法

  1. 如果cells数组未初始化,当前线程会尝试占有cellsBusy锁并创建cells数组;
  2. 如果当前线程尝试创建cells数组时,发现有其它线程已经在创建了,就尝试更新base,如果成功就返回;
  3. 通过线程的probe值找到当前线程应该更新cells数组中的哪个Cell;如果当前线程所在的Cell未初始化,就占有cellsBusy锁并在相应的位置创建一个Cell;
  4. 尝试CAS更新当前线程所在的Cell,如果成功就返回,如果失败说明出现冲突;当前线程更新Cell失败后并不是立即扩容,而是尝试更新probe值后再重试一次;如果在重试的时候还是更新失败,就扩容;
  5. 扩容时当前线程占有cellsBusy锁,并把数组容量扩大到两倍,再迁移原cells数组中元素到新数组中;
  6. cellsBusy在创建cells数组、创建Cell、扩容cells数组三个地方用到;

sum()方法

sum()方法是获取LongAdder中真正存储的值的大小,通过把base和所有段相加得到。

可以看到sum()方法是把base和所有段的值相加得到,那么,这里有一个问题,如果前面已经累加到sum上的Cell的value有修改,不是就没法计算到了么?

答案确实如此,所以LongAdder可以说不是强一致性的,它是最终一致性的。

三.ConcurrentHashMap

ConcurrentHashMap中没有threshold和loadFactor这两个字段,而是采用sizeCtl来控制;

  1. sizeCtl = -1,表示正在进行初始化;
  2. sizeCtl = 0,默认值,表示后续在真正初始化的时候使用默认容量;
  3. sizeCtl > 0,在初始化之前存储的是传入的容量,在初始化或扩容后存储的是下一次的扩容门槛;
  4. sizeCtl = (resizeStamp << 16) + (1 + nThreads),表示正在进行扩容,高位存储扩容邮戳,低位存储扩容线程数加1;

添加元素

整体流程跟HashMap比较类似,大致是以下几步:

  1. 如果桶数组未初始化,则初始化;
  2. 如果待插入的元素所在的桶为空,则尝试把此元素直接插入到桶的第一个位置;
  3. 如果正在扩容,则当前线程一起加入到扩容的过程中;
  4. 如果待插入的元素所在的桶不为空且不在迁移元素,则锁住这个桶(分段锁)
  5. 如果当前桶中元素以链表方式存储,则在链表中寻找该元素或者插入元素;
  6. 如果当前桶中元素以红黑树方式存储,则在红黑树中寻找该元素或者插入元素;
  7. 如果元素存在,则返回旧值;
  8. 如果元素不存在,整个Map的元素个数加1,并检查是否需要扩容;

添加元素操作中使用的锁主要有(自旋锁 + CAS + synchronized + 分段锁)。

初始化桶数组

第一次放元素时,初始化桶数组。

  1. 使用CAS锁控制只有一个线程初始化桶数组;
  2. sizeCtl在初始化后存储的是扩容门槛;
  3. 扩容门槛写死的是桶数组大小的0.75倍,桶数组大小即map的容量,也就是最多存储多少个元素。

判断是否需要扩容

每次添加元素后,元素数量加1,并判断是否达到扩容门槛,达到了则进行扩容或协助扩容。

  1. 元素个数的存储方式类似于LongAdder类,存储在不同的段上,减少不同线程同时更新size时的冲突;
  2. 计算元素个数时把这些段的值及baseCount相加算出总的元素个数;
  3. 正常情况下sizeCtl存储着扩容门槛,扩容门槛为容量的0.75倍;
  4. 扩容时sizeCtl高位存储扩容邮戳(resizeStamp),低位存储扩容线程数加1(1+nThreads);
  5. 其它线程添加元素后如果发现存在扩容,也会加入的扩容行列中来;

协助扩容(迁移元素)

线程添加元素时发现正在扩容且当前元素所在的桶元素已经迁移完成了,则协助迁移其它桶的元素。

当前桶元素迁移完成了才去协助迁移其它桶元素;

迁移元素

扩容时容量变为两倍,并把部分元素迁移到其它桶中。

  1. 新桶数组大小是旧桶数组的两倍;
  2. 迁移元素先从靠后的桶开始;
  3. 迁移完成的桶在里面放置一ForwardingNode类型的元素,标记该桶迁移完成;
  4. 迁移时根据hash&n是否等于0把桶中元素分化成两个链表或树;
    1. 低位链表(树)存储在原来的位置;
    2. 高们链表(树)存储在原来的位置加n的位置;
  5. 迁移元素时会锁住当前桶,也是分段锁的思想;

获取元素

获取元素,根据目标key所在桶的第一个元素的不同采用不同的方式获取元素,关键点在于find()方法的重写。

  1. hash到元素所在的桶;
  2. 如果桶中第一个元素就是该找的元素,直接返回;
  3. 如果是树或者正在迁移元素,则调用各自Node子类的find()方法寻找元素;
  4. 如果是链表,遍历整个链表寻找元素;
  5. 获取元素没有加锁;所以它不是强一致性的,而是最终一致性的。

获取元素个数

元素个数的存储也是采用分段的思想,获取元素个数时需要把所有段加起来。

  1. 元素的个数依据不同的线程存在在不同的段里;(见addCounter()分析)
  2. 计算CounterCell所有段及baseCount的数量之和;
  3. 获取元素个数没有加锁;所以它不是强一致性的,而是最终一致性的。

总结

  1. ConcurrentHashMap中值得关注的亮点:
  2. CAS + 自旋,乐观锁的思想,减少线程上下文切换的时间;
  3. 分段锁的思想,减少同一把锁争用带来的低效问题;
  4. CounterCell,分段存储元素个数,减少多线程同时更新一个字段带来的低效;
  5. @sun.misc.Contended(CounterCell上的注解),避免伪共享;
  6. 多线程协同进行扩容;

误区

ConcurrentHashMap是线程安全的,但不是说它无论在什么情况下都是线程安全的。

如下:

这里如果有多个线程同时调用unsafeUpdate()这个方法,很显然此时并不能保证线程安全。因为get()之后if之前可能有其它线程已经put()了这个元素,这时候再put()就把那个线程put()的元素覆盖了。

我们可以使用putIfAbsent()方法,它会保证元素不存在时才插入元素,如下:

ConcurrentHashMap还提供了另一个方法叫replace(K key, V oldValue, V newValue)也可以解决这个问题。注意如果传入的newValue是null,则会删除元素。

  • 24
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值