0906(048天 线程集合02 ConcurrentHashMap和CopyOnWrite)
每日一狗(田园犬西瓜瓜)
线程集合ConcurrentHashMap和CopyOnWrite
文章目录
1. ConcurrentHashMap
概述
为了解决HashMap的线程安全问题,ConcurrentHashMap采用了CAS和synchronized共同
具体实现
ReservationNode占位置且加锁
红黑树不是直接挂载在桶里的
单向链表转换成红黑树后,红黑树会存放到TreeBin中,TreeBin挂载在桶里
属性
private transient volatile Node<K,V>[] nextTable; // 这个就是扩容的时候临时用一些的表,从就数组那边往过搬运,最后在将旧表指向新表
几个节点
普通节点
这就是最一开始的节点,一开始构造双向链表的时候挂载创建的节点
TreeNode树节点
TreeNode就是红黑树的结点,TreeNode不会直接链接到table[i]——桶上面,而是由TreeBin链接,TreeBin会指向红黑树的根结点。
TreeBin树的管理节点
树化后桶和红黑树的中间人,TreeBin会直接链接到table[i]——桶上面,该结点提供了一系列红黑树相关的操作,以及加锁、解锁操作。
ForwardingNode 扩容辅助节点
那个桶在重新分发,就在那个位置上用这个节点进行替换,先把这个玩意插到桶上意思是我这个容器在扩容哦,这个桶上的节点在搬运哦
ReservationNode保留结点
- 在并发场景下、在从Key不存在到插入的时间间隔内,为了防止哈希槽被其他线程抢占,当前线程会使用一个reservationNode节点放到槽中并加锁,从而保证线程安全
- hash值固定为-3,不保存实际数据。只在computeIfAbsent和compute这两个函数式API中充当占位符加锁使用
构造器
解决数组初始化数组对象时保证线程安全使用的是cas
初始化数组是在第一次添加数据的时候进行的。
- volatile变量(sizeCtl):它是一个标记位,用来告诉其他线程这个坑位有没有人在,其线程间的可见性由volatile保证
- CAS操作:CAS操作保证了设置sizeCtl标记位的原子性,保证了只有一个线程能设置成功,同时保证了只有一个线程来对数组进行初始化
添加数据
桶为空的时候使用cas保证线程安全
在已经存在数据的桶中追加数据使用synchronized保证线程安全,使用头节点充当同步代码块的锁对象。
扩容
ForwardingNode扩容辅助节点
当table需要扩容时,依次遍历table中的每个槽,如果不为null,把所有元素根据hash值放入扩容后的nextTable中,在原table的槽内放置一个ForwardingNode标识我这正在扩容。
ForwardingNode是一种临时结点,在扩容进行中才会出现,hash值固定为-1,且不存储实际数据。
多线程协助扩容
当一个线程再添加数据的时候,刚巧这个数据的hash值算出来的桶上边的标志是-1,即这个桶上存储的是ForwardingNode,就会被拉去当劳工辅助扩容的节点计算。
重新分发节点时看这个节点要不要移动
例子:容积从16扩容到32时,
节点10:0000 1010(2)
节点17:0001 0001(17)
节点33:0010 0001(1)
16 : 0001 0000
就是那个扩容原始长度表示的那个位上是不是1,10和33的哪一位不是1所以就没移动,只有17节点的哪一位是1,他就得加上扩容前数组容积就是这个索引。
扩容时get
算出来的桶没有在扩容就正常返回,在扩容重新分发的话就去新桶去找,这里就用到多态了,得看这个节点是啥
Node那就单链表的find
TreeBin那就是红黑树的find
ForwardingNode那就得定位到新表去调用对应的find
总结
ConcurrentHashMap运用各类CAS操作{Unsafe},将扩容操作的并发性能实现最大化,在扩容过程中,就算有线程调用get查询方法,也可以安全的查询数据,若有线程进行put操作,还会协助扩容,利用sizeCtl标记位和各种volatile变量进行CAS操作达到多线程之间的通信、协助,在迁移过程中只锁一个Node节点,即保证了线程安全,又提高了并发性能。
get操作的线程安全
对于get操作,其实没有线程安全的问题,只有可见性的问题,只需要确保get的数据是线程之间可见的即可在get操作中除了增加了迁移的判断以外,基本与HashMap的get操作无异使用了tabAt方法Unsafe类volatile的方式去获取Node数组中的Node,保证获得到的Node是最新的。
2. CopyOnWriteXXX
思想
这个就很好说了,但凡是遇到了写容器操作,直接就去拷贝一份,改完之后再将原容器的引用指向新的容器。
所以CopyOnWrite容器就是一种读写分离的思想,读和写不同的容器。
这样做就可以对CopyOnWrite容器进行并发的读,而且不需要加锁,因为读取操作遍历的对象是原来的旧对象。
CopyOnWriteArrayList、CopyOnWriteArraySet
CopyOnWriteArrayList的实现原理
写还是要加锁的,要不然就会Copy出来好多个副本,到时候写入那个副本就是一个线程安全问题了。读的时候不需要加锁,如果读的时候有多个线程正在向ArrayList添加数据,读还是会读到旧的数据,因为写的时候不会锁住旧的ArrayList
public E get(int index) {
return elementAt(getArray(), index);
}
static <E> E elementAt(Object[] a, int index) {
return (E) a[index];
}
public boolean add(E e) {
synchronized (lock) {
......
}
}
CopyOnWriteXXX的应用场景
CopyOnWrite并发容器用于读多写少的并发场景。
比如白名单,黑名单,商品类目的访问和更新场景,假如我们有一个搜索网站,用户在这个网站的搜索框中,输入关键字搜索内容,但是某些关键字不允许被搜索。这些不能被搜索的关键字会被放在一个黑名单当中,黑名单每天晚上更新一次。当用户搜索时,会检查当前关键字在不在黑名单当中,如果在,则提示不能搜索
注意两点:
- 减少扩容开销。根据实际需要,初始化一个Set,将数据全局添加到set中,然后再使用addAll之类的方法一行添加集合中的所有元素,以避免每次写时扩容的开销,合理的评估
- 使用批量添加。因为每次添加,容器每次都会进行复制,所以减少添加次数,可以减少容器的复制次数。
CopyOnWriteXXX的缺点:
- 复制是需要额外的空间和时间上的开销
- 不能保证遍历的数据时最新的
面对第一个复制的修改数据开销问题,可以搞一个临时存储空间,存储需要要改删的数据,隔一段时间更新一次,可以降低在复制上的时空开销
第二个缺点属实是没什么办法,这种拷贝修改覆盖的机制就不可能保证数据的时效性,除非你加锁,在读的时候不允许写,但是这又和这个机制的初衷相悖,但是这个写和写之间可是同步的,这保证了线程安全问题。
3. 容器容错机制
failfast快速失败
- 如果出现故障,则立即报错。
- 通常用于非幂等性操作,如:下单操作,如果写入故障,则立即报错,不必重试
使用迭代器遍历集合的同时,如果集合的结构发生改变,会抛出异常ConcurrentModificationException(并发修改异常)。
原理:就是迭代器中维护的预期的expectedModCount和容器中的modCount,他在每一次进行next时会判定修改次数是否与我这边当时创建迭代器的时候传进来的modCount一致,不同则会抛出异常
public E next() {
checkForComodification();
......
}
/ 2
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
failsafe故障安全
- 如果出现故障,则可以忽略,因为这种故障不会造成损失或损失在可接受范围内。
- 通常用于辅助性操作,如:写入监控日志,如果写入故障,则忽略。
当我们对集合结构上做出改变的时候,fail-fast机制直接会抛出异常。但是对弈采用fail-safe机制来说,不会抛出异常。
因为集合的机构被改变的时候,fail-safe机制会拷贝一份数据出来,然后再这份新的数据上进行增删操作,当写删完毕后会将容器的引用指向新的数组。
failover故障切换
- 如果出现故障,则重试备份操作模式。
- 通常用于幂等性操作,如:MySql的双Master模式,如果主Master故障,则切换至从Master。
- 重试通常会带来更多延时。
failback故障恢复
- 故障切换之后,如果主要操作模式恢复,则自动从备份操作模式恢复主要操作模式。
- 如:MySql的双Master模式,如果主Master故障,则failover至从Master;当主Master恢复之
后,则自动切换至主Master。
针对与快速失败提出的解决办法
方案一:使用Iterator接口中提供的remove方法来进行删除操作。
方案二:fail-safe,拷贝修改覆盖
方案三:不就是异常嘛,我处理一下不就行了
monitor监视器对象或管程
Monitor可以翻译成监视器或者管程,是由JVM提供的,当线程执行到对象方法的临界区时,首先去看对象头是不是关联了Monitor,如果关联了加入Monitor的EntryList中,没有关联就改变对象的对象头的Mark Word或者指向一个Monitor,然后Monitor的拥有者就变成了该线程,执行完了之后,Monitor再把线程释放,去EntryList找一个新的拥有者(非公平竞争,不是先来的就先得)
- 一个对象关联一个Monitor;当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁
- Java虚拟机给每个对象和class字节码都设置了一个监听器Monitor,用于检测并发代码的重入,同时在Object类中还提供了notify和wait方法来对线程进行控制
- 重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址
- 不加synchronized的对象不会关联监视器;
- 自旋优化:在竞争锁的时候,自旋重试几次,要是此时锁被释放就进入,否则进入阻塞状态
java中每个对象都有唯一的一个monitor,想拥有一个对象的monitor的话有以下三种方式:
1、执行该对象的同步方法public synchronize a () {}
2、执行该对象的同步块synchronize(obj) {}
3、执行某个类的静态同步方法public static synchronize b(){}
。
Monitor可以类比为一个特殊的房间,这个房间中有一些被保护的数据,Monitor保证每次只能有一个线程能进入这个房间进行访问被保护的数据,进入房间即为持有Monitor,退出房间即为释放Monitor。
当一个线程需要访问受保护的数据(即需要获取对象的Monitor)时,它会首先在entry-set入口队列中排队(这里并不是真正的按照排队顺序),如果没有其他线程正在持有对象的Monitor,那么它会和entry-set队列和wait-set队列中的被唤醒的其他线程进行竞争(即通过CPU调度),选出一个线程来获取对象的Monitor,执行受保护的代码段,执行完毕后释放Monitor,如果已经有线程持有对象的Monitor,那么需要等待其释放Monitor后再进行竞争。