并发读写数据一致性保证(一)Java并发容器

业务开发过程,其实就是用户业务数据的处理过程,于是开发的核心任务就是维护数据一致不出错。现实场景中,多个用户会并发读写同一份数据(如秒杀),不加控制会翻车、加了控制则下降并发度,影响性能和用户体验。java

如何优雅的进行并发数据控制呢?本质上须要解决两个问题:数组

  • 读-写冲突
  • 写-写冲突

让咱们看下Java经典的并发容器CopyOnWriteList以及ConcurrentHashMap是如何协调这两个问题的数据结构

CopyOnWriteList

流程示意图

读写策略

CopyOnWrite顾名思义即写时复制策略多线程

针对写处理,首先加ReentrantLock锁,而后复制出一份数据副本,对副本进行更改以后,再将数据引用替换为副本数据,完成后释放锁并发

针对读处理,依赖volatile提供的语义保证,每次读都能读到最新的数组引用app

读-写冲突

显然,CopyOnWriteList采用读写分离的思想解决并发读写的冲突高并发

当读操做与写操做同时发生时:post

  • 若是写操做未完成引用替换,这时读操做处理的是原数组而写操做处理的数组副本,互不干扰
  • 若是写操做已完成引用替换,这时读操做与写操做处理的都是同一个数组引用

可见在读写分离的设计下,并发读写过程当中,读不必定能实时看到最新的数据,也就是所谓的弱一致性。性能

也正是因为牺牲了强一致性,可让读操做无锁化,支撑高并发读学习

写-写冲突

当多个写操做的同时发生时,先拿到锁的先执行,其余线程只能阻塞等到锁的释放

简单粗暴又行之有效,但并发性能相对较差

ConcurrentHashMap(JDK7)

读写策略

主要采用分段锁的思想,下降同时操做一份数据的几率

针对读操做:

  • 先在数组中定位Segment并利用UNSAFE.getObjectVolatile原子读语义获取Segment
  • 接着在数组中定位HashEntry并利用UNSAFE.getObjectVolatile原子读语义获取HashEntry
  • 而后依赖final不变的next指针遍历链表
  • 找到对应的volatile

针对写操做:

  • 先在数组中定位Segment并利用UNSAFE.getObjectVolatile原子读语义获取Segment
  • 而后尝试加锁ReentrantLock
  • 接着在数组中定位HashEntry并利用UNSAFE.getObjectVolatile原子读语义获取HashEntry链表头节点
  • 遍历链表,若找到已存在的key,则利用UNSAFE.putOrderedObject原子写新值,若找不到,则建立一个新的节点,插入到链表头,同时利用UNSAFE.putOrderedObject原子更新链表头
  • 完成操做后释放锁

读-写冲突

若并发读写的数据不位于同一个Segment,操做是相互独立的

若位于同一个Segment,ConcurrentHashMap利用了不少Java特性来解决读写冲突,使得不少读操做都无锁化

当读操做与写操做同时发生时:

  • 若PUT的KEY已存在,直接更新原有的value,此时读操做在volatile的保证下能够读到最新值,无需加锁
  • 若PUT的key不存在增长一个节点,或删除一个节点时,会改变原有的链表结构,注意到HashEntry的每一个next指针都是final的,所以得复制链表,在更新HashEntry数组元素(即链表头节点)的时候又是经过UNSAFE提供的语义保证来完成更新的,若新链表更新前发生读操做,此时仍是获取原有的链表,无需加锁,可是数据不是最新的

可见,支持无锁并发读操做仍是弱一致的

写-写冲突

若并发写操做的数据不位于同一个Segment,操做是相互独立的

若位于同一个Segment,多个线程仍是因为加ReentrantLock锁致使阻塞等待

ConcurrentHashMap(JDK8)

读写策略

与JDK7相比,少了Segment分段锁这一层,直接操做Node数组(链表头数组),后面称为桶

针对读操做,经过UNSAFE.getObjectVolatile原子读语义获取最新的value

针对写操做,因为采用懒惰加载的方式,刚初始化时只肯定桶的数量,并无初始默认值。当须要put值的时候先定位下标,而后该下标下桶的值是否为null,若是是,则经过UNSAFE.comepareAndSwapObject(CAS)赋值,若是不为null,则加Synchronized锁,找到对应的链表/红黑树的节点value进行更改,后释放锁

读-写冲突

若并发读写的数据不位于同一个桶,则相互独立互不干扰

若位于同一个桶,与JDK7的版本相比,简单了许多,但仍是基于Java的特性使得许多读操做无锁化

当读操做与写操做同时发生时:

  • 若PUT的key已经存在,则直接更新值,此时读操做在volatile的保证下能够获取最新值
  • 若PUT的key不存在,会新建一个节点 或 删除一个节点的时候,会改变对原有的结构,这时next指针是volatile的,直接插入到链表尾(超过必定长度变成红黑树)等对结构的修改,此时读操做也是能够获取到最新的next

所以只要写操做happens-before读操做,volatile语义就能够保证读的数据是最新的,能够说JDK8版本的ConcurrentHashMap是强一致的(此处只关注基本读写(GET/PUT),可能会有弱一致的场景遗漏,例如扩容操做,不过应该是全局加锁的,若有错误烦请指出,共同窗习

写-写冲突

若并发读写的数据不位于同一个桶,则相互独立互不干扰

若位于同一个桶,注意到写操做在不一样的场景下采起不一样的策略,CAS或Synchronized

当多个写操做同时发生时,若桶为null,则CAS应对并发写,当第一个写操做赋值成功后,后面的写线程CAS失败,转为竞争Synchronized锁,阻塞等待

小结

为何这么设计(我的观点)

对数据进行存储必然涉及数据结构的设计,任何对数据的操做都得基于数据结构

常规思路是对整个数据结构加锁,可是锁的存在会大大影响性能,因此接下来的任务,就是找到哪些能够无锁化的操做

操做主要分为两大类,读和写。

先看写,由于涉及到原有数据的改动,不加控制确定会翻车,怎么控制呢?

写操做也分两种,一种会改变结构,一种不会

对于会改变结构的写,无论底层是数组仍是链表,因为改动得基于原有的结构,必然得加锁串行化保证原子操做,优化的点就是锁层面的优化了,例如最开始HashTable等synchronized锁到ConcurrentHashMap1.7版本的ReentrantLock锁,再到1.8版本的Synchronized改良锁 。或者数据分散化,concurrnethashmap等基于hash的数据结构比CopyOnWriteList的数据结构就多了桶分散的优点

对于不会改变结构的写,或者改动的频率不大(桶扩容频率低),因为锁的开销实在是太大了,CAS是个不错的思路。为何CopyOnWriteList不用CAS来控制并发写,我我的以为主要缘由仍是由于结构变化频繁,能够看下ActomicReferenceArray等基于CAS的数组容器,都是建立后就不容许结构发生变化的。

确保数据不会改错以后,读相对就好办了

主要考虑是否是要实时读最新的数据(等待写操做完成),也就是强一致仍是弱一致的问题

强一致的话,读就得等写完成,读写竞争同一把锁,这就相互影响了读写的效率。

大多数场景下,读的数据一致性要求没有写的要求高,能够读错,可是坚定不能够写错。要是在读的这一刻,数据还没改完,读到旧数据也不要紧,只要最后写完对读可见便可

还好JMM(Java内存模型)有个volatile可见性的语义,能够保证不加锁的状况下,读也能看到写更改的数据。此外还有UNSAFE包的各类内存直接操做,也可相对高性能的完成可见性语义

对读操做而言,最好的数据,就是不变的数据,不用担忧被修改引起的各类问题。惟一的不变是变化,一些数据仍是有变化的可能,若是要支持这种不变性,或者说尽可能减小变化的频率,变化的部分就得在别的地方处理,也就是所谓的读写分离

以上纯我的理解,受限于水平,想法不必定正确,欢迎讨论指点

推荐阅读

并发容器之CopyOnWriteArrayList

ConcurrentHashMap实现原理和源码解读

Java进阶(六)从ConcurrentHashMap的演进看Java多线程核心技术

并发容器之ConcurrentHashMap(JDK 1.8版本)

完全理解volatile

相关文章
相关标签/搜索