当前位置:首页>java>大道至简-并发编程 | (二)怎么解决可见性有序性和原子性?happens-before和CAS

大道至简-并发编程 | (二)怎么解决可见性有序性和原子性?happens-before和CAS

  • 2026-10-21 13:28:19
大道至简-并发编程 | (二)怎么解决可见性有序性和原子性?happens-before和CAS

    锁是解决三大问题最趁手的工具。其背后的实现逻辑是一套上传下达的规则,让程序员的想法能被CPU听到。本节先介绍其背后的happens-before和CAS。

    happens-before作为规则,用于有序性、可见性。CAS是扎扎实实的保证原子性的操作,但其背后也是一种上传下达的规则设计。

happens-before解决可见性和有序性

    Java应用希望能够线程的运行可控,操作系统希望并发线程执行效率高。JMM设计了happens-before规则,保证多线程执行的可见性和有序性。

JVM内存模型与操作系统内存模型

静态布局,JVM运行时数据区模型可以分为:

  • 共享内存:堆、方法区。
  • 独有内存:虚拟机栈、本地方法栈、程序计数器。
动态映射,在操作系统执行时,物理上只有RAM/CPU缓存和寄存器。
JMM实现了逻辑数据模型到物理内存的映射,他规定了主内存和工作内存。作为JVM逻辑内存模型到操作系统物理内存模型的映射。
  • 共享内存≈RAM
  • 工作内存≈CPU写缓存+L1/L2/L3高速缓存+CPU寄存器。
线程同步问题≈刷内存问题
一般情况下,线程之间数据同步方式就是对同一块共享区域(RAM)的读写,想要做好线程同步,就需要设计好什么时候刷回RAM。
三方约定-happens-before
JMM向程序员承诺,操作系统会遵守happens-before规则,保证按规定刷回主存,按规定限制重排序。

happens-before:三方约定

    happens-before规定两个操作执行结果的可见性。如果a操作happens-before b操作,则不论a和b是否在同一线程,JMM都保证a执行结果对b的可见性。
对于存在happens-before关系的两个操作,只要重排序不影响可见性关系,则JMM允许这种重拍。
通过happens-before,程序员可以确保多线程可见性可控,操作系统也具有一定的重排序优化权限。
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。
  • StoreStore:xxx
  • LoadLoad:xxx
通过使用内存屏障,阻止编码器重排序、指令重排序以及保证线程可见性,解决内存系统重排序问题。

对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前缀。
LOCK#触发总线锁
    当一个CPU在总线上输出LOCK#信号时,其他CPU的请求将会被阻塞,处理器独占内存空间。但是总线锁有个问题就是,锁的范围太大了。比如想要原子的进行i++操作时,只需要保证i读写的原子性,但是却锁了整个内存,禁止其他CPU读取。
Lock 触发缓存锁
    我们知道CPU一般都会把数据加载到高速缓存里,而不是直接读内存数据。只要在缓存中把这个数据“锁”住就可以实现锁定,而不至于像总线锁一样影响巨大。
    具体而言:每个CPU有独享的一级二级缓存,还有共享的三级缓存。当某个CPU发现自己要做原子操作时(Lock 开头的指令),并且该数据在自己的缓存中。则立刻通知其他CPU,让他们缓存中的该数据失效,并且只有在写回内存之后,才允许其他CPU读该数据源。这样就保证了对该数据的读写操作的原子性。这个机制叫MESI协议。

缓存锁和总线锁的合作

    可以看到,总线锁兜底可用,但是影响大。缓存锁对整个操作系统性能影响更小,但是存在可行条件:
  1. 数据在缓存中。
  2. 数据不在同一个缓存行(缓存行是内存操作的最小单位,主流CPU是64字节):因为MESI协议规定,只能搞定一个缓存行的权限。
所以其合作方式为,尽可能用缓存行锁,总线锁兜底。当CPU看到Lock前缀执行xxx时:
  • 大多数情况,目标数据在缓存中,触发缓存锁,执行xxx,然后释放缓存锁。
  • 数据跨行或未缓存,则CPU触发总线锁LOCK #,RAM和CPU通信拉闸,执行xxx,释放总线锁。

CAS解决原子性

    语义:如果没被别人修改,则我才修改。他存储一个旧值,当想要修改为新值时,先查看是否仍然等于旧值。
其底层就是利用了LOCK CMPXCHG指令实现原子性。
经典面试问题-CAS的ABA问题
    如图,Time=1的时候,线程A读到i=0。Time=4的时候,i也等于0。但是期间被改过两次,那A此时,是否把新值替换进去?解决方案就是给一个更严格的定义,每次修改,则i的版本号变一次,只有值不变,且版本号不变的情况下。才把新值替换进去。

对CAS的高级封装-锁

锁机制保证只有获得锁的线程才能操作锁定的内存空间。直接解决了读写之间,内存被改的可能,实现了原子性操作。

最新文章

随机文章