【并发编程】(七)volatile原理——解决可见性、有序性问题

1.为什么要使用volatile

volatile是作用是保证被修饰变量的可见性和有序性,但是之前提到的synchronized已经可以保证多线程并发情况下的线程安全,其中就包括了保证可见性和有序性,为什么还要(或者还需要)使用volatile呢?

synchronized是将修饰的代码块变成了同步代码块,在线程退出同步块的时候,会将同步块中的所有共享变量副本从线程的工作内存同步到主内存中的共享变量中,这是synchronized保证可见性的方式。
但是,synchronized毕竟涉及到了线程的阻塞和线程的上下文切换,在某些简单的原子性操作中,这样的处理方式必然带来了额外的性能开销。

所以JVM在面对这些简单的原子性操作时,引入了更轻量级的处理方式——volatile,以此来保证某些变量在并发且不加锁的情况下保证可见性和有序性。


2.volatile如何解决可见性问题

2.1.CPU与内存交互

JMM是JDK为了屏蔽不同硬件和不同操作系统中指令执行的差异性,在操作系统的CPU与内存的交互机制上抽象出了一套供Java使用的内存模型,我们使用这套模型进行研究和学习可以更好的帮助我们清楚的认知线程之间的同步和通信问题。上面提到得工作内存和主内存就属于这个模型的一部分。
JMM图示如下:
在这里插入图片描述
同理,将JMM抽象模型套用在CPU和内存之间,就得到了一个CPU与内存交互的简图,我们可以通过这个简图去研究可见性问题:
在这里插入图片描述

2.2.1.可见性问题产生的原因

上图可见,每个CPU对共享变量的操作都是将内存中的共享变量复制一份副本到自己高速缓存中,然后对这个副本进行操作。如果没有正确的同步,即使CPU0修改了某个变量,这个已修改的值还是只存在于副本中,此时CPU1需要使用到这个变量,从内存中读取的还是修改前的值,这就是其中一种可见性问题。

还有一种可见性问题,CPU0和CPU1都读取了变量a到自己的副本中,CPU0对变量a 做了写操作并同步到了内存,但是CPU1在后面的操作中没有从内存更新变量值,而是直接使用了之前缓存的值,这样也会导致数据结果不正确。

2.3.volatile的实现——汇编指令lock

Java代码的运行是先将Java代码编译成字节码,然后JVM加载字节码最终转换成汇编指令在CPU上运行,被volatile修饰的字段会在其对应的汇编操作指令上加个lock前缀指令,这个指令就可以解决上述的两种可见性问题。

2.3.1.lock指令做了什么

处理器在接收到lock指令的时候会做两件事:
1.将缓存行缓存的数据写回到系统内存中。
2.这个写回到系统内存中的数据,如果在其他CPU的缓存行中存在相同的数据,则将其置为失效状态

而lock指令更底层的实现主要有两种方式:总线锁缓存锁。在谈这两种方式之前先解释下缓存行是什么。


缓存行(cache line)是什么?
因为CPU和内存之间运行速度的差异,为了保证CPU的运行速度不被内存给“拖垮”,CPU在对内存中的资源进行操作的时候不会直接去操作内存,而是将内存中的资源先加载到CPU的高速缓存中,在使用的时候就直接从高速缓存中去取。
缓存行就是高速缓冲中用于分配的最小存储单位,也就是说CPU加载高速缓存中的资源,最少也会加载一整个缓存行。

2.3.2.总线锁

在一些比较“古老”的处理器中可能还会使用的总线锁的方式,CPU与内存数据的交互是通过总线来“运输的”,某个处理器使用了总线锁就表示这段时间就只有它能与内存发生数据交互,其它的处理器都无法访问到系统内存,这种方式必然会导致开销比较大。
所以现代的处理器一般不会使用总线锁,而是使用缓存锁作为替代方案。

2.3.3.缓存一致性协议

缓存锁严格来锁并不是锁,而是基于缓存一致性协议,实现对缓存行数据的状态转换的控制,以此来完成对可见性的保证。

缓存一致性协议(MESI),将缓存行中的数据划分成了4个状态:修改、独占、共享、失效

  • M(Modified): 当前缓存行中的数据与内存中不一致,且只有当前一个缓存行存储了这个数据。
  • E(Exclusive):缓存行和内存数据一致,且只有当前一个缓存行存储了这个数据。
  • S(Shared):缓存行和内存数据一致,数据存在于多个缓存行中。
  • I(Invalid):当前缓存行失效。

缓存一致性协议是如何使用的呢?
下面针对1.1提到的第二种可见性问题来分析执行流程。
在这里插入图片描述
失效的缓存行会在下次使用到的时候,重新从内存中获取数据,这么一来线程A对共享变量做出了修改,对线程B就立即可见了。

再看一下线程A在将缓存行中的变量同步到的内存时,会通知其他的线程将这个变量所在缓存行置为失效状态的动作,这个动作实际上是一个同步阻塞的动作,A向B发起一个失效通知,然后阻塞等待B响应结果。图示如下:
在这里插入图片描述
这里阻塞的时间可能会远大于CPU执行每条指令的时间,可以想到的是,如果这种阻塞操作过多必然会导致CPU运行性能降低。
为了解决CPU算力浪费的问题,引入了Store Bufferes这样一个缓冲区域,把将要同步回内存的数据的放入Store Buffers中,然后当前CPU发送失效通知,发送后不再阻塞等待,而是去执行其它的指令,直到所有失效通知都响应回来后,再将Store Buffers中的数据取出同步到内存。

2.3.4.Store Bufferes带来的问题

引入Store Buffers对缓存一致性协议的阻塞问题做了优化,但是这个优化可能会导致后面的指令先于前面的指令执行完毕,这种顺序流入、乱序流出的现象,属于指令重排序


3.volatile如何解决有序性问题

造成有序性问题的其中一个原因是处理器的指令重排序,指令重排序实际上是处理器的优化方案,让CPU能发挥更好的性能,严格的说不能算是一个问题。

在单线程的情况下,一个方法内的代码对应的CPU指令即使是乱序执行的,对结果来说也不会有影响(除非是后面的指令依赖前面的结果,这种情况由Happen-Before规则禁止重排序),但是对于并发条件下的程序,乱序修改共享变量就可能导致其他的线程读取到的变量值不正确

作为一个优化来讲,指令重排序是有其存在的必要性的,但是处理器并不知道什么时候应该禁止这种重排序优化来保证程序执行的正确性,于是将禁用的时机抛给了使用者。

针对两种不同的重排序,编译器的重排序处理器的重排序,JMM统一制定了重排序的管理规则,通过插入内存屏障来标识什么时候是禁止重排序优化的,而插入内存屏障时机,就是volatile修饰的变做读写操作的时候。

3.1.内存屏障

JMM定义的内存屏障一共有4种:

屏障类型指令说明
StoreStore写写屏障,插入两个写之间,volatile写之前,禁止前面的普通写或volatile写与当前的volatile写发生重排序
StoreLoad写读屏障,插入volatile写之后,禁止当前的volatile写与后面的volatile读发生重排序
LoadLoad读读屏障,插入 volatile读之后,禁止当前的volatile读与后面普通读或volatile读发生重排序
LoadStore读写屏障,插入volatile读之后,禁止当前的volatile读与后面的普通写或volatile写发生重排序

内存屏障就是将代码划分为多个区域,区域与区域之间按照顺序执行,各个区域中的代码可以重排序,如下图所示:
在这里插入图片描述

3.1.1.volatile内存屏障代码示例及其图示

先看一个代码示例,对四个成员变量的内存操作:

public class VolatileBarrierDemo {

    int i1;
    int i2;
    volatile int v1;
    volatile int v2;

    void readAndWrite() {
        int a1 = i1; // 操作一
        int a2 = i1; // 操作二
        int b1 = v1; // 操作三
        int b2 = v2; // 操作四
        i1 = i1 + 1; // 操作五
        v1 = i1 * 2; // 操作六
        v2 = i1 * 3; // 操作七
        i2 = i2 + 1; // 操作八
    }
}

在readAndWrite方法中,四种内存屏障都用到了,具体流程如下图所示:
在这里插入图片描述
JMM通过在各个指令之间插入不同内存屏障,来达到在某些问题禁用重排序优化的效果,以此来保证并发环境下由有序性造成的线程安全问题。

4.volatile读写的内存语义

在2.3.3缓存一致性协议中提到了CPU通过“缓存锁”来解决可见性问题,JMM通过对底层实现的抽象,将3.1中提到的volatile读/写操作,分别定义了其内存语义,更方便与理解和使用。

  • volatile写:将当前线程的工作内存中的共享变量副本,同步到主内存中。
  • volatile读:将当前线程的工作内存中的共享变量副本置为无效,下次使用的时候重新从主内存读取

如果将读写的内存语义连起来看,就是一个做volatile写操作的线程A,和一个做volatile读操作的线程B,A对这个volatile修饰的共享变量的修改,对线程B可见

5.总结

volatile的作用是并发环境下,在一定的作用范围内解决共享变量的可见性和有序性问题,相对于synchronized和显示的加锁,volatile在性能上根据优势,可以尽可能的以更细的粒度来保证线程安全。

可见性:

  • 通过JMM提供的lock指令,来使用CPU底层的提供的总线锁缓存锁来保证共享资源的实时同步和更新。
  • 总线锁在加锁后会阻塞CPU访问内存,所以大多数CPU是通过缓存一致性协议对缓存行状态的控制来达到多个线程之间的共享变量值同步。

有序性:

  • JMM提供了四种内存屏障,插入到各个指令之间,让各个指令不能越过屏障改变执行的顺序,通过这种主动禁用重排序优化的方式来屏蔽掉并发环境下,因为重排序导致的有序性问题。
  • 8
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 10
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

挥之以墨

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值