美文网首页IT笔记与心得
volatile的原子性、可见性,有序性问题

volatile的原子性、可见性,有序性问题

作者: 会跳的八爪鱼 | 来源:发表于2021-05-31 15:54 被阅读0次

    volatile是java常见的关键字,但是volatile只能保证可见性,并不能保证原子性是怎么回事?

    前言:jmm模型中说明了java分为主内存和工作内存,每个线程对应一个工作内存,每次需要读写变量时都需要从主内存中读取变量到线程所在的工作内存,如果发生修改需要从再从工作内存同步到主内存。多处理器提高了并发处理的能力,但是也会带来可见性与原子性的问题。


    jmm模型与计算机系统

    1. 可见性

    多个线程同时处理同一个共享变量时,如果其中一个cpu中的线程在自己的工作内存中改变了共享变量的值,但是其他cpu中该共享变量还是没有变化,这就会导致共享变量的不一致问题。例如:

        private static boolean flag = true;
        public static void main(String[] args) throws InterruptedException {
           //线程a
            new Thread(() -> {
                System.out.println("--start--");
                while (flag) {
                }
                System.out.println("--end--");
            }).start();
            TimeUnit.SECONDS.sleep(1);
            //线程b
            new Thread(() -> {
                flag = false;
            }).start();
            TimeUnit.SECONDS.sleep(10);
        }
    

    以上代码“--end--”将无法打印,因为线程b只修改了本cpu内flag的值,但是操作系统不会立即将此变量写回到主内存中,这是由于操作系统的cache中的写回策略(cpu修改数据分为直写和写回两种策略,但基本上都是使用写回方式)导致的。而且即使操作系统将flag值写回到主内存,但是由于线程a中的cpu cache中已经有flag变量了,所以也不会从主内存中读。

    解决方法:对于可见性问题,可以使用操作系统原子性与锁小结中的总线锁与缓存一致性协议,java采用的是volatile关键字,这个关键字会在生成汇编语言是添加lock前缀,该lock前缀会有两个作用1、使用总线锁或者缓存锁(缓存一致性协议),2、阻止指令重排。所以在flag前面添加flag关键字就可以解决可见性问题。

    2. 原子性

    原子性是一个操作要么完成,要么失败,上述的缓存一致性协议只能保证单变量单操作的原子性。不能保证多操作的原子性。
    例如如果对添加了volatile的变量执行i++操作,这样是不能保证原子性的,因为i++编译后会变成多条cpu指令。

    ①将变量从主内存读取到工作内存(即缓存中)
    ②将变量从缓存放入寄存器中计算,并再寄存器中保存中间结果
    ③将寄存器中的结果写入缓存中


    mesi不能原子性的原因

    说明:比如: cpu0 从内存里读取了一个volatile变量 counter = 0, 然后将其从L1缓存中将变量加载到寄存器进行计算. 计算完写回到L1 缓存,。此时, 变量的状态是修改, 然后通知bus总线, 所有的cpu都会监测到counter变量已经被修改, 丢弃自己现有的变量. 比如 cpu1 此时会丢弃counter = 0, 但是如果counter已经被读取到寄存器进行计算了. 即使在L1内存中的数据被丢弃, 获取到了新的counter值, 当寄存器计算完以后, 会重新回写到L1缓存, 此时会覆盖刚刚读取到的counter=1, 将自己计算的counter=1写入内存中.

    L1缓存中的变量有两种赋值方式, 一种是从内存加载进来, 另一种是从寄存器回写过来的.

    解决方法:

    ①可以使用原子类解决,原子类是java解决变量原子操作的一种方式。原子类使用的是操作系统的cmpxchg指令,只不过z合格指令不是原子性的,在多cpu的情况下,cmpxchg指令前会加上lock前缀(volatile修饰导致,单独的cmpxchg指令不保证原子性),Atomic类循环使用compareAndSwapInt(对应操作系统的cas指令)方法直至成功。

    新版本:
    public final int getAndAddInt(Object var1, long var2, int var4) {               
       int var5;
       do {
            var5 = this.getIntVolatile(var1, var2);
          } while(!this.compareAndSwapInt(var1, var2, var5, var5 + >var4));
         return var5;
    }
    老版本:
    public final int incrementAndGet() {
        for (;;) {
           int current = get();
           int next = current + 1;
           if (compareAndSet(current, next))
              return next;
           }
       }
    

    至于为什么cas机制能保证原子性:一种说是使用了北桥信号锁(一种比总线锁轻量的锁机制,保证同一时刻只有一个cas在执行)。下面是我在百度中找到的比较满意的答案(利用mesi与总线嗅探技术):

    cas过程
    但是java中的Automic原子类也有些问题,比如ABA问题以及单变量问题,解决方法是AtomicStampedReference类,该类使用了版本号解决ABA问题,原子引用类解决单变量问题(链接“什么是CAS机制”会有详细解释)。而且大量使用volatile是导致cpu不停的嗅探是否有关联数据被读写,可能会造成总线风暴。
    总结: CAS 的指令允许算法执行读-修改-写操作,而无需害怕其他线程同时修改变量,因为如果其他线程修改变量,那么 CAS 会检测它(并失败),算法可以对该操作重新计算。CAS(cpu 硬件同步原语(compare and swap))_百度百科 (baidu.com)除此之外cas还有可能由于获取不到锁而导致自旋时间过长,这就需要慎重选择自旋锁和互斥锁了。如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升,pause 指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline), 使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起 CPU 流水线被清空(CPU pipeline flush),从而提高 CPU 的执行效率。PAUSE指令

    ②使用synchronized关键字,因为线程进入加锁代码前会清空工作内存,然后从主内存拷贝数据,释放锁后,线程会同步工作内存中数据到主内存,因为不存在缓存不一致的问题。synchronized关键字可以参照操作系统锁分析中synchronized锁分析

    volatile与synchronized都可以解决可见性问题,但是volatile不能解决原子性问题,如果只有共享变量的赋值或者读取(因为赋值和读取都是原子操作,计算则涉及多个步骤),而没有变量的计算则都可以使用volatile这个轻量版的解决方案。

    有序性

    操作系统锁分析中我们得知cpu会根据性能重新指定指令的执行顺序,而且由于失效队列和存储缓存的延迟性导致了重排序问题,重排序问题再但线程中是没有问题的,但是在多线程中就可能不会按照我们理想的状态执行。

    value = 3;
    void exeToCPUA(){
     value = 10;
     isFinsh = true;
    }
    void exeToCPUB(){
     if(isFinsh){
       assert value == 10;//value一定等于10?!
     }
    }
    

    CPU A保存着finished在E(独享)状态,而value并没有保存在它的缓存中(例如它被多线程修改,导致cpuA中的状态是不合法的,但此时可能,修改value的线程可能还在存储缓存中还没有同步到缓存中)。在这种情况下,完全有可能CPU B读取finished的值为true,而value的值不等于10。即isFinsh的赋值在value赋值之前。注意,这不意味着你的指令的位置被恶意(或者好意)地更改。它只是意味着其他的CPU会读到跟程序中写入的顺序不一样的结果。

    解决方法:可以使用volatile修饰。内存屏障分为读写屏障:写屏障是一条告诉处理器在执行这之后的指令之前,应用所有已经在存储缓存(store buffer)中的保存的指令。
    读屏障是一条告诉处理器在执行任何的加载前,先应用所有已经在失效队列中的失效操作的指令。

    note:

    关于volatile与mesi的关系可以理解为:
    ①.volatile是java建立在操作系统上的抽象关键字,只是为了实现可见性与有序性,而操作系统如何实现则是操作系统内部的事情,可能使用mesi或者其他的缓存一致性协议,或者总线锁。
    ②.在开启mesi协议的操作系统中,为了提高效率而引入了store buffer与失效队列,这些内容可以在操作系统原子性与锁中看到。不过我们知道由于mesi引入了“store buffer与失效队列”,操作系统的一致性实现的就不够彻底,所以volatile关键字将store buffer中的内容立刻刷新到缓存,失效队列中的内容立刻执行,这样mesi就能够完全触发了。当然可见性与有序性也就不是问题了。

    volatile将store buffer内容立刻刷新到缓存,失效队列中的内容立刻执行,解决了可见性问题。

    关于有序性问题
    读屏障本质是拉取(acquire,即拉取别人的版本)别的CPU的修改,让当前CPU缓存里该变量成为最新值,让当前CPU看到的是最新版本。实质是将invalidateQueue中的无效化请求应用到缓存行。

    写屏障本质是将自己storeBuffer中的修改刷入(release,即发布自己的版本)缓存,其实就是让变量的修改对其他CPU可见。

    对volatile变量的读会在读之前加上一个FULL_BARRIAR,可以理解成是读+写屏障。目的是先看下invalidateQueue是不是有对该变量的无效请求,如果有则将该缓存行变成无效状态,这样一来如果storeBuffer里有对该变量的修改,则会因为缓存行无效,而去发送 read-invalidate 请求到总线上,把最新版本拉过来,然后在此基础上修改,这时候该缓存行的版本是M状态,所以当前CPU可读取自己的这份最新修改。
    如果storeBuffer里没有对该变量的修改,那么在读的时候,发现缓存行是无效状态,就会去拉去最新版本。

    以下是引用其他文章的原话:Java的CAS会使用现代处理器上提供的高效机器级别原子指令,这些原子指令以原子方式对内存执行读-改-写操作,这是在多处理器中实现同步的关键(从本质上来说,能够支持原子性读-改-写指令的计算机器,是顺序计算图灵机的异步等价机器,因此任何现代的多处理器都会去支持某种能对内存执行原子性读-改-写操作的原子指令)。同时,volatile变量的读/写和CAS可以实现线程之间的通信。把这些特性整合在一起,就形成了整个concurrent包得以实现的基石。如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式:
    ①. 首先,声明共享变量为volatile;  
    ②. 然后,使用CAS的原子条件更新来实现线程之间的同步;
    ③. 同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。

    cas与volatile构建的juc
    当我们new一个新对象时JVM至少需要:查找空闲列表、分配内存、修改空闲列表等等,这些步骤在多线程下是不安全的。解决并发时的安全问题也有两种策略:
    ①. CAS:实际上虚拟机采用CAS配合上失败重试的方式保证更新操作的原子性,原理和上面讲的一样。
    ②. TLAB:如果使用CAS其实对性能还是会有影响的,所以JVM又提出了一种更高级的优化策略:每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲区(TLAB),线程内部需要分配内存时直接在TLAB上分配就行,避免了线程冲突。只有当缓冲区的内存用光需要重新分配内存的时候才会进行CAS操作分配更大的内存空间。

    参照:什么是CAS机制
    CAS你以为你真的懂? - 知乎 (zhihu.com)
    CPU缓存一致性协议MESi与内存屏障 - 博客园 (cnblogs.com)
    Volatile以及原子性 - SegmentFault 思否

    相关文章

      网友评论

        本文标题:volatile的原子性、可见性,有序性问题

        本文链接:https://www.haomeiwen.com/subject/txebjltx.html