JUC并发编程第八章之Volatile(读写内存屏障保证有序性/可见性)

发布时间 2023-04-07 16:40:02作者: 爱吃糖的靓仔

1、Volatile简介

Volatile是Java中的一个关键字,用于修饰变量。当一个变量被声明为volatile时,它的值可能会被多个线程同时访问和修改。

2、Volatile的特性

2.1、 可见性(重点)

可见性 : 当一个线程修改了volatile变量的值,其他线程可以立即看到这个变量的最新值。

2.1.1、没有可见性的代码案例

public class interruputDemo1 {
    static boolean flag = false;
 
    public static void main(String[] args) throws InterruptedException {
 
        new Thread(() -> {
            System.out.println("我开始运行了");
            while (true) {
                if (flag) {
                    System.out.println("我检测到编程true");
                    break;
                }
            }
        }, "t1").start();
        Thread.sleep(1000);
        flag = true;
        System.out.println("主线程执行完毕");
    }
}

分析

  • 主线程修改了flag之后没有立马将其刷新到主内存,所以t1线程看不到
  • 主线程修改了flag之后没有立马将其刷新到主内存,所以t1线程看不到

2.1.2、可见性读取规则

在这里插入图片描述
总结

  • 当第一个操作为volatle读时,不论第二个操作是什么,都不能重排序。这个操作保证了volatile读之后的操作不会被重排到volatile读之前。
  • 当第二个操作为volatile写时,不论第一个操作是什么,都不能重排序。这个操作保证了volatile写之前的操作不会被重排到volatle写之后
  • 当第一个操作为volatile写时,第二个操作为volatile读时,不能重排。

2.2、 禁止指令重排序

  • 重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段,有时候会改变程序语句的先后顺序
  • 不存在数据依赖关系,可以重排序 (有例外,看 1.1.2.1章节);
  • 存在数据依赖关系,禁止重排序
  • 但重排后的指令绝对不能改变原有的串行语义!这点在并发设计中必须要重点考虑

2.2.1、 不存在依赖,代码顺序就可以随便重排吗?

public class VolatileTest {
    int i = 0;
    volatile boolean flag = false;

    public void write() {
        i = 2;
        flag = true;
    }

    public void read() {
        if (flag) {
            System.out.println("------" + i);
        }
    }
}

解析:

在下面的代码中虽然i=2 和 flag=true 没有严格的依赖关系, 但是如果2者的顺序发生了改变,先执行flag=true , 此时另一个线程突然访问read(),flag=true生效,但是i=2还没有赋值完成,就会导致数据错误, 此时就需要给flag加入volatile禁止指令重排

2.3、 不保证原子性

对于复合操作,例如i++,volatile变量并不能保证原子性,因为i++操作实际上是由三个操作组成的:读取i的值、对i的值进行加1操作、将结果写回i。如果多个线程同时执行i++操作,会出现竞争条件,需要使用同步机制来保证原子性

2.3.1、 不保证原子性代码案例

class MyNumber{
    volatile  int number;

    public  void add(){
        number++;
    }
    public int getNumber() {
        return number;
    }
}

public class InterruputDemo1 {

    public static void main(String[] args) throws InterruptedException {
        MyNumber myNumber = new MyNumber();

        for (int i = 0; i < 10; i++) {
             new Thread(()->{
                 for (int j = 0; j < 1000; j++) {
                     myNumber.add();
                 }
             },String.valueOf(i)).start();
        }
        Thread.sleep(2000);
        System.out.println(myNumber.getNumber());
    }
}

输出 9556

2.3.2、 不保证原子性原因

  • 假如A,B两个线程同时从主内存中获取值为1 , 此时两个线程分别进行+1的操作;
  • 这个时候A线程先完成了+1的操作2,把2写道了主内存,
  • 但是因此加了volatile,B线程感知到主线程的内容发生了修改了;
  • 所以B线程会中断这次的+1的操作,重新进行获取值,相当于这些写的操作直接丢失了

2.3.3、 如何能保证原子性

参考下一章节CAS

3、Volatile的内存语义(最核心的一句话)

  • 当写一个volatile变量的时候,JMM会把线程对应本地内存中共享变量值 立即刷新回主内存
  • 当读volatitle的时候,JMM会把该线程对应的本地内存值设置为无效,重新回到主内存中读取最新的共享变量

4、Volatile的原理-内存屏障

4.1、 内存屏障概述

volatile 的底层实现原理是内存屏障,也称内存栅栏,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作,避免代码重排序

4.2、 类型

类型 指令示例
写屏障(Store Memory Barrier) 对 volatile 变量的写指令后会加入写屏障;在写指令之后插入写屏障,强制把写缓冲区的数据刷回到主内存中 在写指令之后插入写屏障,强制把写缓冲区的数据刷回到主内存中
读屏障(Load Memory Barrier) 对 volatile 变量的读指令前会加入读屏障;在读指令之前插入读屏障,让工作肉存或CPU高速缓存当中的缓存数据失效,重新回到主内存中获取最新数据

细分

类型 指令示例 指令说明
LoadLoad Load1;LoadLoad;Load2 保证Load1的读取操作在load2及后续读取操作之前执行
StoreStore Store1;StoreStore;Store2 在store2及其后的写操作执行前,保证store1的写操作已刷新到主内存
LoadStore Store1;StoreStore;Store2 在store2及其后的写操作执行前,保证了load1的读操作已经读取结束
StoreLoad Store1;StoreLoad;Load2 保证了Store1的写操作已经刷新到了主内存之后,load2及其后的读操作才能执行

4.3、 屏障的插入策略

volatile读插入内存屏障生成的指令序列示意图
在这里插入图片描述

  • 在每个volatile读操作的后面插入一个LoadLoad屏障 ;
    禁止处理器把上面的volatile读与下面的普通读重排序。
  • 在每个volatile读操作的后面插入一个LoadStore屏障 ;
    禁止处理器把上面的volatile读与下面的普通写重排序。

volatile写插入内存屏障生成的指令序列示意图
在这里插入图片描述

  • 在每个volatie写操作的前面插入一个 StoreStore屏障;
    可以保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中。
  • 在每个volatile 写操作的后面插入一个 StoreLoad屏障
    作用是避免volatile写与后面可能有的volatile读/写操作重排序

5、 volatile的正确使用姿势

  • 单一赋值可以,but含复合运算赋值不可以(i++之类)
  • 状态标志,判断业务是否结束
  • 开销较低的读,写锁策略
  • DCL双端锁的发布

5.1 DCL双端检索

5.1.1、多线程环境下单例模式出现的问题

package com.bwie.demo;

import java.time.temporal.ValueRange;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * 不能保证原子性
 */
public class SingletonDemo  {

   public static SingletonDemo instance = null;


    public SingletonDemo() {
        System.out.println("我是构造方法");
    }

    private  static SingletonDemo getInstance(){
          if (instance==null){
              instance = new SingletonDemo ();
          }
          return instance;
   }

    public static void main(String[] args) {
        SingletonDemo instance = SingletonDemo.getInstance();
        SingletonDemo instance1 = SingletonDemo.getInstance();
        System.out.println(instance.equals(instance1));
    }
}

通过上面的案例,我们可以知道因为是单例模式,所以构造方法只会输出一次,但是多线程的环境下,则不然啦
多线程的环境下

public class SingletonDemo {

    public static SingletonDemo instance = null;


    private SingletonDemo() {
        System.out.println("我是构造方法");
    }

    private static  SingletonDemo getInstance() {
        if (instance == null) {
            instance = new SingletonDemo();
        }
        return instance;
    }

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 10; i++) {
            executorService.submit(() -> {
                instance =  SingletonDemo.getInstance();
            });
        }
        executorService.shutdown();
    }
}

输出结果

我是构造方法
我是构造方法
我是构造方法

5.1.2、双端检索机制解决办法

双端检索机制

public class SingletonDemo {

    public static SingletonDemo instance = null;


    private SingletonDemo() {
        System.out.println("我是构造方法");
    }

    private static  SingletonDemo getInstance() {
        if (instance == null) {
            synchronized (SingletonDemo.class) {
               if (instance==null){
                   instance = new SingletonDemo();
               }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 10; i++) {
            executorService.submit(() -> {
                instance =  SingletonDemo.getInstance();
            });
        }
        executorService.shutdown();
    }
}

5.1.3、双端检索机制的隐患

从上面的结果我们可以看出来,好像加了双端检索机制貌似就没有出现问题啦, 而且运行检验的时候,他的确是只输出了一次构造方法吗?
但是问题真的就这么解决了吗?

DCL(双端检索机制)机制不一定安全,因为有指令重排的存在,加入voliate则可以禁止指令重排
原因是在某个线程执行到第一次检测,读取到instance为null的时候,instance对象没有完成对象的初始化,而
instance = new SingletonDemo();又可以分为三步
memory = allocate() 分配内存空间 语句1
instance(memory ) 初始化对象 语句2
instance = memory 实例指向内空间 语句3

因为语句2和语句3没有数据依赖性,所以 他们的顺序可以指令重排, 如果为132顺序的话.在对象还没有完成对象的初始化的时候,直接把null的对象指向内存空间,会导致出现null的结果

5.1.4、解决双端检索机制的隐患

加上voliate

public class SingletonDemo {

    public static volatile SingletonDemo instance = null;


    private SingletonDemo() {
        System.out.println("我是构造方法");
    }

    private static  SingletonDemo getInstance() {
        if (instance == null) {
            synchronized (SingletonDemo.class) {
               if (instance==null){
                   instance = new SingletonDemo();
               }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 10; i++) {
            executorService.submit(() -> {
                instance =  SingletonDemo.getInstance();
            });
        }
        executorService.shutdown();
    }
}