volatile如何解决可见性与指令重排序?

何为可见性

引入可见性例子

一个线程修改数据 另外一个线程实时可见
public class VolatileDemo {
    public static boolean stop=false;
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
            int i=0;
            while(!stop){
                i++;
            }
        });
        t1.start();
        System.out.println("begin start thread");
        Thread.sleep(1000);
        stop=true;
    }
}

  正常看起来这段代码是没问题的。但是结果main线程修改的变量 对thread1不可见,程序并未结束

2.如何解决?

public volatile static boolean stop=false;

  如果加个volatile就可以完成 解决可见性问题

为何他就停止不了呢?

在JVM层面有一个深度优化(活性失败),对于t1这个线程,其实stop=true就是一个常量所以他停止不了。具体为何可以看下面的分析。
那么是否还有别的方法可以结束线程呢?
1.JVM参数:-Djava.compiler=NONE //禁止深度优化
2.在while循环里面,让t1线程sleep 0秒
3.在t1 线程里面 输出一下i的值
那么为什么呢?那就接下去往下看
 

2.1.前提

在整个计算机的发展历程中,除了CPU、内存以及I/O设备不断迭代升级来提升计算机处理性能之外,还有一个非常核心的矛盾点,就是这三者在处理速度的差异
CPU的计算速度是非常快的,其次是内存、最后是IO设备(比如磁盘),也就是CPU的计算速度远远高于内存以及磁盘设备的I/O速度。
计算机是利用CPU进行数据运算的,但是CPU只能对内存中的数据进行运算,对于磁盘中的数据,必须要先读取到内存,CPU才能进行运算,也就是CPU和内存之间无法避免的出现了IO操作。而cpu的运算速度远远高于内存的IO速度。
所以!可见性的本质就是cpu资源的利用性问题
那么为了提供cpu的使用率如何解决的呢?
  • cpu的高速缓存
  • 操作系统中增加了进程、线程。他是通过cpu的时间片的切换,提升cpu的利用率
  • 编译器(JVM的深度优化->刚才就是导致的活性失效)

2.2.CPU高速缓存

内存的读取速度还2是比较慢的。这个时候就需要引入cpu的高速缓存
可以自己打开任务管理器看看
0
也就是下面这张图
读取顺序:计算机先从L1去加载->没有就去L2->然后L3->实在没有就去内存中去拿
0
由于增加了cpu的高速缓存 他的交互方法发生了改变 就导致了缓存的一致性问题
简单解释一下:
从图上可以看出内存中的 一个字段的数据是false 分别由2个进程读取,cpu0将其改成了true cpu1并不知道.然后各自做各自的事情 就导致了上诉那个代码的问题
0
cpu的高速缓存呢 是由缓存行决定的,cpu每次加载的时候 都会加载一段数据,在x86的架构里面 每个缓存行的为64个字节
64字节的理由 为空间局部性原理。为了避免多次读取。减少cpu和内存的交互)
然后引入高速缓存之后 就导致了多个问题
引入缓存行 无非就是为了减少交互 尽可能在自己的内存中解决问题,那么问题又来了。往下接着看

2.1.1.伪共享

  1. 比如线程0要使用x 这个数据因为他要加载的是缓存行所以会把xyz全部加载到高速缓存中来
  2. 这个时候线程1要使用y这个数据因为他要加载的是缓存行所以会把xyz全部加载到 高速缓存中来
  3. 然后线程0要对x变量进行修改。由于线程1也有x这个变量就会触发cpu的缓存一致性协议(MESI)(详情见下面)使得线程1的缓存行失效然后cpu1就会重新去内存中拿
  4. 这个时候线程1拿到最新的之后对y进行修改这个时候同理线程0是不是也要过期重新去内存中获取。这样一来就到了伪共享的问题
这样一来不仅仅缓存失效 还浪费cpu资源,那么如何解决呢?
如何解决?
这个时候就可以利用对其填充,缓存行是64位的、那么一段缓存行中如果只有一个x剩余空间是空的(空间换时间概念)另外一个缓存行中只有一个y那么他们线程直接就不会发生竞争也就不会伪共享
代码演示一下
public class CacheLineExample implements Runnable{
    public final static long ITERATIONS = 500L * 1000L * 100L;
    private int arrayIndex = 0;
    private static ValueNoPadding[] longs;
    public CacheLineExample(final int arrayIndex) {
        this.arrayIndex = arrayIndex;
    }
    public static void main(final String[] args) throws Exception {
        for(int i = 1; i < 10; i++){
            System.gc();
            final long start = System.currentTimeMillis();
            runTest(i);
            System.out.println(i + " Threads, duration = " +
                    (System.currentTimeMillis() - start));
        }
    }
    private static void runTest(int NUM_THREADS) throws InterruptedException {
        Thread[] threads = new Thread[NUM_THREADS];
        longs = new ValueNoPadding[NUM_THREADS];
        for (int i = 0; i < longs.length; i++) {
            longs[i] = new ValueNoPadding();
        }
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new CacheLineExample(i));
        }
        for (Thread t : threads) {
            t.start();
        }
        for (Thread t : threads) {
            t.join();
        }
    }
    @Override
    public void run() {
        long i = ITERATIONS + 1;
        while (0 != --i) {
            longs[arrayIndex].value = 0L;
        }
    }
    //实现对齐填充
    public final static class ValuePadding {
        protected long p1, p2, p3, p4, p5, p6, p7;
        protected volatile long value = 0L;
        protected long p9, p10, p11, p12, p13, p14;
        protected long p15;
    }
    //未实现对其填充  
    public final static class ValueNoPadding {
        // protected long p1, p2, p3, p4, p5, p6, p7; //8字节
        protected volatile long value = 0L;
        // protected long p9, p10, p11, p12, p13, p14, p15;
    }

}

配置一下JVM参数 -XX:-RestrictContended 开启对其填充

然后搭配这个注解来实现对其填充 @Contended
可以贴入代码 查看一下 ValuePadding和ValueNoPadding的运行加载速度查看一下
 

2.1.2.缓存一致性

2.1.2.1.缓存一致性问题

在多线程环境中,当多个线程并行执行加载同一块内存数据时,由于每个CPU都有自己独立的L1、L2缓存,所以每个CPU的这部分缓存空间都会缓存到相同的数据,并且每个CPU执行相关指令时,彼此之间不可见,就会导致缓存的一致性问题
 
上面说了那么多 怎么解决可见性问题 那就是在缓存里 加一个缓存锁 (或者总线锁 如果cpu不支持缓存锁的话才会使用总线锁) 让缓存保持一致性 ,就可以解决上面这个问题。
如果加上总线锁的话(就是在bus上加)那么其他的所有线程都得等着。那么如何改善一下呢?
那就看看下面的缓存一致性协议

2.1.2.2.缓存一致性协议

为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有MSI,MESI,MOSI等。最常见的就是MESI协议。
MESI表示缓存行的四种状态,分别是
0
  1. M(Modify[ˈmɒdɪfaɪ]) 表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
  2. E(Exclusive[ɪkˈskluːsɪv]) 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
  3. S(Shared[ʃerd]) 表示数据可能被多个CPU缓存共享,并且各个缓存中的数据和主内存数据一致
  4. I(Invalid[ˈɪnvəlɪd]) 表示缓存已经失效
所以在CPU的缓存行中,每一个Cache一定会处于以下三种状态之一 Shared / Exclusive / Invalid
0
图解:
每个进程中都会有一个snoopy 去监听总线上的事件。用来修改cpu高速缓存的状态
总线上发布一条M 也就是数据失效的指令。snoopy就会监听到总线上的指令。然后让cpu上的高速缓存进行失效
总结
0
缓存锁就是基于MESI协议、总线锁就在加上一个互斥锁
如何使用这两种锁由cpu去实现,能支持缓存锁就用缓存锁不支持的cpu就会去用总线锁
?那么这两种锁该怎么加呢?
对我们来说就是加上一个volatile 其实也会在汇编层变成lock指令生成锁。使其只能一个cpu使用 其他只能等待
 

有序性

1.指令重排序

1.1.例子引入

public class SeqExample {
    private volatile static int x = 0, y = 0;
    private volatile static int a = 0, b = 0;
    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for (; ; ) {
            i++;
            x = 0;
            y = 0;
            a = 0;
            b = 0;
            Thread t1 = new Thread(() -> {
                a = 1;
                x = b;
                /*其实的顺序是
                x = b;
                a = 1;*/
            });
            Thread t2 = new Thread(() -> {
                b = 1;
                y = a;
               /* 其实的顺序是
                y = a;
                b = 1;*/
            });
            /**
             * 可能的结果:
             * 1和1
             * 0和1
             * 1和0
             * ----
             * 0和0
             */
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            String result = "第" + i + "次(" + x + "," + y + ")";
            if (x == 0 && y == 0) {
                System.out.println(result);
                break;
            } else {}}}} 

1.2.如何导致

其实是:cpu/JVM层面的优化执行问题,看一下这个图
0
图解:
由于需要保持 缓存一致性 cpu0 需要发送指令给cpu1 让他的缓存属于失效状态。等他处理好了 再回来操作 最后保存到内存 这个阶段是出于阻塞状态
那么肯定不会光让cpu等着阻塞啊。那是怎么处理的呢?

1.2.1.引入store buffer

0
最大利用cpu使用率 不让其阻塞 引入了store buffer(把他理解成mq) 使用了异步的设计思想
cpu0 会先将数据先写入到storeBuffer里面 发生指令让cpu1失效 ,然后cpu0 继续执行代码,这样就会导致cpu不断的去执行后面的代码导致了cpu指令的重排序
接着看下面这个详细一点的例子
0
过程解释:
  1. cpu0先把a=1写入到storeBuffer (这个时候你可以看cpu1的cache里面a=0是shared共享状态)
  2. 这个时候发送指令让cpu1的指令处于失效状态处理好了,回来这个时候cpu0就会把a=0放在高速缓存中,变成了E独占状态了
  3. 这个时候接着往下走,然后从内存中把b加载到高速缓存中,这个时候缓存中的a是0那么b计算出来也就是1
  4. 操作结束完成了,storeBffer的数据也该回显回来了再把a的值覆盖, a也就有变成了1 这个时候a=1 b=1
这样其实也是为了不浪费cpu资源,不让a这个操作耽误整个流程。让流程接着往下走。
那么直接从storeBuffer里面记载数据呢?

1.2.2.storeForwarding

针对这个问题引入了storeForwarding,其实这个时候是有问题的
假设有2个线程 2个方法同时执行,以下为一种假设的例子(就是这么巧)
0
过程解释:
  1. 主内存中a=0 b=0,线程开始执行->cpu0读取到b[E:独占] cpu1读取到a[E:独占] cpu1需要知道b的值就去cpu0中去读
  2. cpu0需要a=1,将其放入stroeBuffer,并且发送失效指令给cpu1,因为异步这个先放着继续往下走
  3. cpu0赋值b=1,此时b[M:修改]
  4. cpu0修改完成了,b[S:共享],写入主存,返回给cpu1
  5. cpu1收到了b=1,b==1 b为[S:共享] 这个时候b==1为true
  6. cpu1原本主存中a=0[E:独占] 因为这个时候还没收到cpu0中a更改的消息所以a==1为false
  7. cpu1收到cpu0中stroebuffer中的消息通知,将a=0,设置成失效[I],
  8. cpu0中的stroebuffer回显到主存上,a=1[M:修改]
通过这个过程可以发现 虽然可以让storebuffer直接将值给别的线程。但是由于是异步的操作执行的顺序无法保证。即使直接读取stroebuffer中的数据,准确性依旧无法得到保证。
stroebuffer容量也并不是无限的不可能存储大容量的数据。那么可以看看下面的失效队列

1.2.3.失效队列

这个时候引入一个失效队列,所有的读写操作返回的时候都放在失效队列。也就是在处理mesi协议之前都需要看看失效队列里面有没有失效的消息没有处理
步骤解释:
  1. cpu0 先去执行a=1 因为要修改a 所以把a放到storebuffer 然后cpu1 把a丢到失效队列里面
  2. cpu1 需要b 就去cpu0里面去读 ( cpu0 里面的b 就变成修改状态 再变成分享状态)
  3. cpu0 把cpu1的a拿了过来 直接从storebuffer 写入到缓存行
  4. cpu1也拿到了b=1的值 判断正确
  5. 这个时候 cpu1的缓存还是a=0 因为是异步的 所以这个最后才会失效队列去处理 a=0的失效

总结一下

其实就是CPU优化之路结合上面的一套操作总结了一下 
  1. 先是io设备之前的运行速度问题引入了高速缓存 导致了缓存一致性问题
  2. 然后给他们加上锁 导致了堵塞的问题
  3. 再给他们加个存储空间 异步处理用来解决 堵塞的问题 这也就导致了指令重排序
  • storeBuffer 就是一个小的存储空间 把需要更新的操作放在里面 在去执行下面的指令 但是需要等待别人处理完失效了之后再来返回
  • StoreForwarding 直接去storeBuffer里面去读取数据 如果本地里面有的话
最后为了解决上面的问题如何解决这个问题就交给了开发者,引入了内存屏障其实就是在字段上加上volatile 也对应了开头
 

内存屏障

cpu层面不知道什么时候需要优化和不优化 所以就把选择权交给了用户
所以他提供了内存屏障,分为以下几点:
  • 读屏障 sfence:load 读屏障的指令必须在这个指令之前完成
  • 写屏障 lfence:save 写屏障的指令必须在这个指令之前完成
  • 全屏障 mfence:mix 同理
在Linux系统中,将这三种指令分别封装成了, smp_wmb-写屏障 、 smp_rmb-读屏障 、 smp_mb-读写屏障 三个方法

 

 这就是volatile关键字如何实现内存屏障 解决缓存一致性的问题

这样看起来:volatile既能解决:缓存锁/总线锁 又能内存屏障问题
 
但是呢不同的cpu架构 实现内存屏障的指令是不一样的,这个时候就引入了一个JMM

JMM

我们都知道Java程序是运行在Java虚拟机上的,同时我们也知道,JVM是一个跨语言跨平台的实现,也就是Write Once、Run Anywhere。
那么JVM如何实现在不同平台上都能达到线程安全的目的呢?所以这个时候JMM出来了,Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范
在不同的CPU架构中,为了避免因为指令重排序、或者缓存一致性问题,都提供了不同的内存屏障指令。同时,在不同的操作系统中,也都会实现封装一个内存屏障的实现。 那么,我们写的Java线程,如何能够在不同的硬件、不同操作系统下,仍然能够保证线程安全性呢?这就要引出JMM(Java 内存模型),它就是为了屏蔽操作系统和硬件的差异,让一套代码在不同平台下都能达到线程安全的访问目的。
 
在上面这个图可以看出 针对不同平台 不同架构的 不同的内存屏障指令 JMM有效的在跨系统跨架构上做到了保证线程安全
 
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了这个线程中用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。 不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行,流程图如下:
这个和cpu的高速缓存不一样 这个只是定义的一个模型 一个规范而已 一个访问规范
JMM定义了共享内存中多线程程序读写操作的行为规范:在虚拟机中把共享变量存储到内存以及从内存中取出共享变量的底层实现细节。
目的是解决由于多线程通过共享内存进行通信时,解决本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题
本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。
给到用户的就是volatile final synchronized的关键字
说到底他就是做了一条策略,针对不同的系统和架构用不同的内存屏障指令
0
 
总结
结论
  • volatile关键字会基于MESI协议保证缓存的一致性【可见性】
  • volatile关键字也会提供内存屏障来解决指令重排序的问题【有序性】
并不是所有的程序指令都会存在可见性或者指令重排序问题。
 
 

其他:

1.volatile重排序规则表

0
volatile规则表图解:
  • 第一个参数【没加volatile】普通读写操作
    • 那么第二个参数【没加volatile】普通读写操作 可以重排序
    • 那么第二个参数【加volatile】 读操作 可以重排序
    • 那么第二个参数【加volatile】 写操作 禁止重排序
  • 第一个参数【加volatile】读操作
    • 那么第二个参数【没加volatile】普通读写操作 禁止重排序
    • 那么第二个参数【加volatile】 读操作 禁止重排序
    • 那么第二个参数【加volatile】 写操作 禁止重排序
  • 第一个参数【加volatile】写操作
    • 那么第二个参数【没加volatile】普通读写操作 可以重排序
    • 那么第二个参数【加volatile】 读操作 禁止重排序
    • 那么第二个参数【加volatile】 写操作 禁止重排序
来个例子看看:
volatile int a = 0; int b = 1;
有2个变量。一个加了一个没加。先读取a,在读b,按照规则是不允许重排序的
那如何不使用关键字也能保证 多线程下面的可见性 有序性呢?
 

2.Happens-Before模型

其实就是为了告诉你 哪些场景不会出现可见性问题
前面说了这么多,都是为了讲解清楚,到底是什么原因导致了在多线程环境下的可见性和有序性问题。 并且也了解了volatile解决可见性问题的本质。 那么有没有哪些情况是,不需要通过增加volatile关键字,也能保证在多线程环境下的可见性和有序性的呢?
从JDK1.5开始,引入了一个happens-before的概念来阐述多个线程操作共享变量的可见性问题。所以 我们可以认为在JMM中,如果一个操作执行的结果需要对另一个操作课件,那么这两个操作必须要存在 happens-before关系。这两个操作可以是同一个线程,也可以是不同的线程。

1.程序顺序型规则(as-if-serial)

不管怎么重排序,单线程的程序的执行结果不能改变。
int a=2; //A int b=2; //B int c=a*b; //C
A和B允许重排序,但是C是不允许重排,因为存在依赖关系
一个线程中的每个操作,happens-before这个线程中的任意后续操作,可以简单认为是as-if-serial。 as-if-serial的意思是,不管怎么重排序,单线程的程序的执行结果不能改变
1. 处理器不能对存在依赖关系的操作进行重排序,因为重排序会改变程序的执行结果。
2. 对于没有依赖关系的指令,即便是重排序,也不会改变在单线程环境下的执行结果。

2.传递性规则

A happens-before B。 B happens-before C。 那么 A happens-before C。 成立
例子
int a=2; //A int b=2; //B int c=a*b; //C
这三个happens-before关系,就是根据happens-before的传递性推导出来的。不是说,A和B之间允许重排序吗?那是不是A happens-before B不一定存在,也可能是B可以重排序在A之前执行呢?
没错,确实是这样,JMM不要求A一定要在B之前执行,但是他要求的是前一个操作的执行结果对后一 个操作可见。这里操作A的执行结果不需要对操作B可见,并且重排序操作A和操作B后的执行结果与A happens-before B顺序执行的结果一直,这种情况下,是允许重排序的

3.volatile变量规则

对于volatile修饰的变量的写操作,一定happens-before后续对于volatile变量的读操作,这个是因为 volatile底层通过内存屏障机制防止了指令重排,这个规则前面已经分析得很透彻了,所以没什么问题,我们再来观察如下代码,基于前面两种规则再结合volatile规则来分析下面这个代码的执行顺序, 假设两个线程A和B,分别访问writer方法和reader方法,那么它将会出现以下可见性规则
public class VolatileExample { int a=0; volatile boolean flag=false; public void writer(){ a=1; //1 flag=true; //2 } public void reader(){ if(flag){ //3 int i=a; //4 } } }
  • 1 happens before 2、 3 happens before 4, 这个是程序顺序规则
  • 2 happens before 3、 是由volatile规则产生的,对一个volatile变量的读,总能看到任意线程对这个volatile变量的写入。
  • 1 happens before 4, 基于传递性规则以及volatile的内存屏障策略共同保证。
那么最终结论是,如果在线程B执行reader方法时,如果flag为true,那么意味着 i=1成立。
0
流程图
0

4.监视器锁规则

如果线程1获得了锁 执行完成后释放了锁 那么线程2 获得锁之后 那么读取到的数据 一定是线程1 修改了之后的 线程1 对变量的写操作 对线程2 是可见的
int x=10; synchronized (this) { // 此处自动加锁 // x 是共享变量, 初始值 =10 if (this.x < 12) { this.x = 12; } } //此处自动解锁
一个线程对于一个锁的释放锁操作,一定happens-before与后续线程对这个锁的加锁操作
如果一个线程1获得了锁 完成后释放了锁。后续线程2获得锁之后。读取了数据 一定是修改之后的值

5.start规则

如果线程A执行操作ThreadB.start(),那么线程A的ThreadB.start()之前的操作happens-before线程B中的任意操作。
public StartDemo { int x = 0; Thread t1 = new Thread(() -> { // 主线程调用 t1.start() 之前 // 所有对共享变量的修改,此处皆可见 // 此例中,x==10 }); // 此处对共享变量 x修改 x = 10; // 主线程启动子线程 t1.start(); }
最后线程1 读取的值 一定是10

6.Join规则

join规则,如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功的返回
0
join会等待执行结束获得值 如果没结束就会阻塞线程等待获取值
总结
Happens-Before的规则本质上就是描述可见性的问题
 
 

3.JMM内存屏障深入理解

实际上,不难发现JMM的整个模型实际上和CPU高速缓存和内存交互的模型是一致的,因为不管软件怎么设计,最终还是由硬件来执行。而这个抽象模型的意义就在于,它可以针对不同平台来保证并发场景下的可见性问题。
(可以理解相当于把cpu的模型抽象到jvm层面 jvm在对过程进行处理)
0
查看一下源码 JVM提供的4种封装 在关键字的前后操作 去提供屏障
     inline void OrderAccess::loadload() { acquire(); }
     inline void OrderAccess::storestore() { release(); }
     inline void OrderAccess::loadstore() { acquire(); }
     inline void OrderAccess::storeload() { fence(); }
lock锁 cc表示寄存器 memory表示内存 用来通知编译器内存发生变化 从而生成指令
orderAccess_linux_sparc.inline
0
OrderAccess::storeload(); ACC_VOLATILE
bool is_volatile () const { return (_flags & JVM_ACC_VOLATILE ) != 0; }
0
打开字节码可以发现在stop这个成员属性中多了个 ACC_VOLATILE
0
在hotspot虚拟机中会发现如果有voliate关键字修饰的。就会调用stroeload()建立内存屏障
再回头看看表。
0

4.DCL问题

例子:
0
为何需要加上双重锁
虽然给DCLExample加上了锁 但是这个创建对象的过程不是原子性的
//instance=new DCLExample(); 为对象分配内存 初始化对象 把内存空间的地址复制给对象的引用 指令重排序后 为对象分配内存 把内存空间的地址复制给对象的引用 初始化对象(还没有执行的时候。 造成不完整对象
 
posted @ 2022-05-14 16:36  Nuti  阅读(39)  评论(0编辑  收藏  举报