大道至简-并发编程 | (二)怎么解决可见性有序性和原子性?happens-before和CAS
锁是解决三大问题最趁手的工具。其背后的实现逻辑是一套上传下达的规则,让程序员的想法能被CPU听到。本节先介绍其背后的happens-before和CAS。
happens-before作为规则,用于有序性、可见性。CAS是扎扎实实的保证原子性的操作,但其背后也是一种上传下达的规则设计。
happens-before解决可见性和有序性
Java应用希望能够线程的运行可控,操作系统希望并发线程执行效率高。JMM设计了happens-before规则,保证多线程执行的可见性和有序性。JVM内存模型与操作系统内存模型
静态布局,JVM运行时数据区模型可以分为:
动态映射,在操作系统执行时,物理上只有RAM/CPU缓存和寄存器。JMM实现了逻辑数据模型到物理内存的映射,他规定了主内存和工作内存。作为JVM逻辑内存模型到操作系统物理内存模型的映射。- 工作内存≈CPU写缓存+L1/L2/L3高速缓存+CPU寄存器。
一般情况下,线程之间数据同步方式就是对同一块共享区域(RAM)的读写,想要做好线程同步,就需要设计好什么时候刷回RAM。JMM向程序员承诺,操作系统会遵守happens-before规则,保证按规定刷回主存,按规定限制重排序。happens-before:三方约定
happens-before规定两个操作执行结果的可见性。如果a操作happens-before b操作,则不论a和b是否在同一线程,JMM都保证a执行结果对b的可见性。对于存在happens-before关系的两个操作,只要重排序不影响可见性关系,则JMM允许这种重拍。通过happens-before,程序员可以确保多线程可见性可控,操作系统也具有一定的重排序优化权限。- volatile变量: 对一个 volatile 变量的写操作happens-before 于后续对这个变量的读操作。也就是说,每次读取volatile变量的时候,都保证其已经实现了多线程同步。
- 线程内保证"类似单线程":单个线程内,前面的操作 heppens-before后面的操作。约等于as-if-serial
- 锁规定:对一个锁的解锁happens-before随后的加锁。
- start()规定:线程A如果ThreadB.start()线程,则A的ThreadB.start() heppens-before线程B中任意操作。
- join()规定:线程A如果ThreadB.join()线程并成功返回,线程B的任意操作都happens-before线程A的ThreadB.join()。
- 传递性:如果A happens-before B,B happens-before C, 则A heppens-before C。
happens-before实现原理:内存屏障
内存屏障是操作系统能来理解的禁止重排序的约定标识。JMM通过插入内存屏障实现happens-before。- StoreLoad:例如Store1 StoreLoad Load2,则说明,在进行Load2之前,必须先进行Store1
- LoadStore:例如Load1 LoadStore Store2,则说明,在进行Store2之前,必须先进行Load1。
通过使用内存屏障,阻止编码器重排序、指令重排序以及保证线程可见性,解决内存系统重排序问题。对happens-before的高级封装-锁
把happens-before看成一种约定。JMM封装的锁是约定的压缩包,例如:- Synchronized(classA) :对一个锁的解锁Happens-Before 于随后对这个锁的加锁 ;
- volatile a:写a happens-before 读a。如果线程1已经实现了写回a=1(写到写缓存),后续线程2读的时候,一定能看到a=1。
CAS和锁解决原子性
原子操作的经典例子:线程A对变量i的读写操作之间,被插入其他操作,导致结果错误。若将i的“读和写”变成一个不可分割独立操作,该问题将得到解决。操作系统的原子操作
最基础的,CPU硬件保证一次操作的原子性,当一个CPU操作(包括读和写)某个字节时,其他CPU不可以对该字节进行操作。除此之外,操作系统不保证任何操作的原子性,除非涉及某些规则 LOCK前缀。 当一个CPU在总线上输出LOCK#信号时,其他CPU的请求将会被阻塞,处理器独占内存空间。但是总线锁有个问题就是,锁的范围太大了。比如想要原子的进行i++操作时,只需要保证i读写的原子性,但是却锁了整个内存,禁止其他CPU读取。 我们知道CPU一般都会把数据加载到高速缓存里,而不是直接读内存数据。只要在缓存中把这个数据“锁”住就可以实现锁定,而不至于像总线锁一样影响巨大。 具体而言:每个CPU有独享的一级二级缓存,还有共享的三级缓存。当某个CPU发现自己要做原子操作时(Lock 开头的指令),并且该数据在自己的缓存中。则立刻通知其他CPU,让他们缓存中的该数据失效,并且只有在写回内存之后,才允许其他CPU读该数据源。这样就保证了对该数据的读写操作的原子性。这个机制叫MESI协议。缓存锁和总线锁的合作
可以看到,总线锁兜底可用,但是影响大。缓存锁对整个操作系统性能影响更小,但是存在可行条件:- 数据不在同一个缓存行(缓存行是内存操作的最小单位,主流CPU是64字节):因为MESI协议规定,只能搞定一个缓存行的权限。
所以其合作方式为,尽可能用缓存行锁,总线锁兜底。当CPU看到Lock前缀执行xxx时:- 大多数情况,目标数据在缓存中,触发缓存锁,执行xxx,然后释放缓存锁。
- 数据跨行或未缓存,则CPU触发总线锁LOCK #,RAM和CPU通信拉闸,执行xxx,释放总线锁。
CAS解决原子性
语义:如果没被别人修改,则我才修改。他存储一个旧值,当想要修改为新值时,先查看是否仍然等于旧值。其底层就是利用了LOCK CMPXCHG指令实现原子性。 如图,Time=1的时候,线程A读到i=0。Time=4的时候,i也等于0。但是期间被改过两次,那A此时,是否把新值替换进去?解决方案就是给一个更严格的定义,每次修改,则i的版本号变一次,只有值不变,且版本号不变的情况下。才把新值替换进去。对CAS的高级封装-锁
锁机制保证只有获得锁的线程才能操作锁定的内存空间。直接解决了读写之间,内存被改的可能,实现了原子性操作。