Volatile 详解图文结合

博主主页:爪哇贡尘拾Miraitow
创作时间:2022年2月20日 15:41
内容介绍: Volatile 详解
参考资料:黑马程序员JUC
⏳简言以励:列位看官,且将新火试新茶,诗酒趁年华
内容较多有问题希望能够不吝赐教
欢迎点赞 收藏 ⭐留言

Volatile 详解图文结合_第1张图片

Volatile 详解

    • Java 内存模型
    • 原子性案例(银行账户转账问题)
    • 可见性案例(退不出的循环)
    • 有序性(诡异的过程)
    • volatile (保证可见性)
    • volatile (保证有序性)
    • happens-before (先行发生原则)
    • 使用 volatile 关键字的场景
    • double-checked locking 关于指令重排的解决


Java 内存模型

JMM 即 Java Memory Model,它从java层面定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等。
Volatile 详解图文结合_第2张图片
从抽象角度来看:

  • 线程之间的共享变量存储在主内存(Main Memory)中
  • 每个线程都有一个私有的本地内存(Local Memory),本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。本地内存中存储了该线程以读/写共享变量的拷贝副本。
  • 从更低的层次来说,主内存就是硬件的内存,而为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。
  • Java内存模型中的线程的工作内存(working memory)是cpu的寄存器和高速缓存的抽象描述。而JVM的静态内存储模型(JVM内存模型)只是一种对内存的物理划分而已,它只局限在内存,而且只局限在JVM的内存。

Volatile 详解图文结合_第3张图片

JMM 体现在以下几个方面

  1. 原子性 - 保证指令不会受到线程上下文切换的影响(即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
  2. 可见性 - 保证指令不会受 cpu 缓存的影响(即可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
  3. 有序性 - 保证指令不会受 cpu 指令并行优化的影响(即程序执行的顺序按照代码的先后顺序执行

原子性案例(银行账户转账问题)

原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

一个很经典的例子就是银行账户转账问题:

比如从账户 A 向账户 B 转 1000 元,那么必然包括 2 个操作:从账户 A 减去 1000 元,往账户 B 加上 1000 元。

试想一下,如果这 2 个操作不具备原子性,会造成什么样的后果。假如从账户 A 减去 1000 元之后,操作突然中止。然后又从 B 取出了 500 元,取出 500 元之后,再执行往账户 B 加上 1000 元 的操作。这样就会导致账户 A虽然减去了 1000 元,但是账户 B 没有收到这个转过来的 1000 元。

所以这 2 个操作必须要具备原子性才能保证不出现一些意外的问题。

可见性案例(退不出的循环)

先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止
Test1.java:
Volatile 详解图文结合_第4张图片

为什么呢?分析一下:

  1. 初始状态, t线程刚开始从主内存(成员变量), 因为主线程sleep(1)秒, 这时候t1线程循环了好多次run的值, 超过了一定的阈值, JIT就会将主存中的run值读取到工作内存 (相当于缓存了一份, 不会去主存中读run的值了)。

Volatile 详解图文结合_第5张图片

  1. 因为t1线程频繁地从主存中读取run的值,JIT即时编译器会将run的值缓存至自己工作内存中的高速缓存中,减少对主存中run的访问以提高效率

Volatile 详解图文结合_第6张图片

  1. 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值(true),结果永远是旧值

Volatile 详解图文结合_第7张图片

有序性(诡异的过程)

int i = 0;              
boolean flag = false;
i = 1;                //语句1  
flag = true;          //语句2
}

上面代码定义了一个 int 型变量,定义了一个 boolean 类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句 1 是在语句 2 前面的,那么 JVM 在真正执行这段代码的时候会保证语句 1 一定会在语句 2 前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)

下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

比如上面的代码中,语句 1 和语句 2 谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句 2 先执行而语句 1 后执行。

但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:

int a = 10;    //语句1
int r = 2;    //语句2
a = a + 3;    //语句3
r = a*a;     //语句4

这段代码有4个语句,那么可能的一个执行顺序是:
在这里插入图片描述
那么可不可能是这个执行顺序呢:语句2 语句1 语句4 语句3

不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令 Instruction 2 必须用到Instruction 1的结果,那么处理器会保证 Instruction 1会在 Instruction 2 之前执行。

虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面
看一个例子:

//线程1:
context = loadContext();   //语句1
inited = true;             //语句2
 
//线程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

上面代码中,由于语句 1 和语句 2 没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程 1 执行过程中先执行语句 2,而此时线程 2 会以为初始化工作已经完成,那么就会跳出 while 循环,去执行doSomethingwithconfig(context)方法,而此时 context 并没有被初始化,就会导致程序出错。

从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。

也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

Volatile 详解图文结合_第8张图片

重排序也需要遵守一定规则:

①重排序操作不会对存在数据依赖关系的操作进行重排序。比如:a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。
②.重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变。比如:a=1;b=2;c=a+b这三个操作,第一步(a=1)和第二步(b=2)由于不存在数据依赖关系,所以可能会发生重排序,但是c=a+b这个操作是不会被重排序的,因为需要保证最终的结果一定是c=a+b=3。

重排序在单线程模式下是一定会保证最终结果的正确性,但是在多线程环境下,问题就出来了。解决方法:volatile修饰的变量,可以禁用指令重排

ps:
使用synchronized并不能解决有序性问题,但是如果是该变量整个都在synchronized代码块的保护范围内,那么变量就不会被多个线程同时操作,也不用考虑有序性问题!在这种情况下相当于解决了重排序问题!

volatile (保证可见性)

volatile 的底层实现原理是内存屏障Memory Barrier(Memory Fence)

  1. 对 volatile 变量的写指令后会加入写屏障
  2. 对 volatile 变量的读指令前会加入读屏障

如何保证可见性

  1. 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
public void actor2(I_Result r) {
     num = 2;
     ready = true; // ready是被volatile修饰的 ,赋值带写屏障
     // 写屏障
}

  1. 读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
 public void actor1(I_Result r) {
  // 读屏障
  //  ready是被volatile修饰的 ,读取值带读屏障
  if(ready) {
  	r.r1 = num + num;
  } else {
  	r.r1 = 1;
  }
 }

总结:

  • 对于可见性,Java提供了volatile关键字来保证可见性。

  • 当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值

  • 普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

  • 另外,通过synchronizedLock也能够保证可见性,synchronizedLock 能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

volatile (保证有序性)

  1. 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
 public void actor2(I_Result r) {
  num = 2;
  ready = true; //  ready是被volatile修饰的 , 赋值带写屏障
  // 写屏障
 }
  1. 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
  public void actor1(I_Result r) {
   // 读屏障
   //  ready是被volatile修饰的 ,读取值带读屏障
   if(ready) {
   	r.r1 = num + num;
   } else {
   	r.r1 = 1;
   }
  }

happens-before (先行发生原则)

Java 内存模型具备一些先天的有序性,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为happens-before原则。如果两个操作的执行次序无法从 happens-before 原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

下面就来具体介绍下 happens-before原则(先行发生原则)

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  • 锁定规则:一个 unLock 操作先行发生于后面对同一个锁lock 操作
  • volatile 变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  • 传递规则:如果操作 A 先行发生于操作 B,而操作 B 又先行发生于操作 C,则可以得出操作 A 先行发生于操作C
  • 线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每个一个动作
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

下面我们来用代码解释一下几条规则:

线程中断规则
线程 start() 前对变量的写,对该线程开始后对该变量的读可见
线程还没启动时, 修改变量的值, 在启动线程后, 获取的变量值, 肯定是修改过的

  static int x;
  x = 10;
  
  new Thread(()->{
   	System.out.println(x);
  },"t2").start();

线程终结规则
线程结束前 对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)
主线程获取的x值, 是线程执行完对x的写操作之后的值。

  static int x;
  
  Thread t1 = new Thread(()->{
   	x = 10;
  },"t1");
  t1.start();
  
  t1.join();
  System.out.println(x);

线程中断规则

  static int x;
  public static void main(String[] args) {
      Thread t2 = new Thread(()->{
          while(true) {
              if(Thread.currentThread().isInterrupted()) {
                  System.out.println(x); // 10, 打断了, 读取的也是打断前修改的值
                  break;
              }
          }
      },"t2");
      t2.start(); 
      
      new Thread(()->{
          sleep(1);
          x = 10;
          t2.interrupt();
      },"t1").start();
      
      while(!t2.isInterrupted()) {
          Thread.yield();
      }
      System.out.println(x);	// 10
  }

Volatile 详解图文结合_第9张图片

使用 volatile 关键字的场景

synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而 volatile关键字在某些情况下性能要优于 synchronized,但是要注意 volatile 关键字是无法替代 synchronized 关键字的,因为 volatile 关键字无法保证操作的原子性。

ps:
synchronized可以保证它的临界区的资源是 原子性、可见性、有序性的, 有序性的前提是, 在synchronized代码块中的共享变量, 不会在代码块外使用到, 否则有序性不能被保证, 只能使用volatile来保证有序性
下面代码的第二个双重检查单例, 就出现了这个问题(在synchronized外使用到了INSTANCE), 此时synchronized就不能防止指令重排, 确保不了指令的有序性.

著名的double-checked locking(双重检查锁) 单例模式为例,这是volatile最常使用的地方。

// 最开始的单例模式是这样的
public final class Singleton {
    private Singleton() { }
    private static Singleton INSTANCE = null;
    public static Singleton getInstance() {
	    /*
	      多线程同时调用getInstance(), 如果不加synchronized锁, 此时两个线程同时
	      判断INSTANCE为空, 此时都会new Singleton(), 此时就破坏单例了.所以要加锁,
	      防止多线程操作共享资源,造成的安全问题
	     */
	    synchronized(Singleton.class) {
	    	if (INSTANCE == null) { // t1
	    		INSTANCE = new Singleton();
	        }
	    }
        return INSTANCE;
    }
}

上面代码的效率是有问题的, 因为当我们创建了一个单例对象后, 又来一个线程获取到锁了,还是会加锁, 严重影响性能,再次判断INSTANCE==null 吗, 此时肯定不为null, 然后就返回刚才创建的INSTANCE,这样导致了很多不必要的判断;

所以要双重检查, 在第一次线程调用getInstance(), 直接在synchronized外,判断instance对象是否存在了,如果不存在, 才会去获取锁,然后创建单例对象,并返回; 第二个线程调用getInstance(), 会进行if(instance==null)的判断, 如果已经有单例对象, 此时就不会再去同步块中获取锁了提高效率

public final class Singleton {
    private Singleton() { }
    private static Singleton INSTANCE = null;
    public static Singleton getInstance() {
        if(INSTANCE == null) { // t2
            // 首次访问会同步,而之后的使用没有 synchronized
            synchronized(Singleton.class) {
                if (INSTANCE == null) { // t1
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

但是上面的if(INSTANCE == null)判断代码没有在同步代码块synchronized中,不能享有synchronized保证的原子性、可见性、以及有序性。所以可能会导致指令重排

double-checked locking 关于指令重排的解决

  • 加volatile
public final class Singleton {
    private Singleton() { }
    private static volatile Singleton INSTANCE = null;
    public static Singleton getInstance() {
        // 实例没创建,才会进入内部的 synchronized代码块
        if (INSTANCE == null) {
            synchronized (Singleton.class) { // t2
                // 也许有其它线程已经创建实例,所以再判断一次
                if (INSTANCE == null) { // t1
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}
  • 可见性
  1. 写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
  2. 读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据
  • 有序性
  1. 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
  2. 读屏障 会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

Volatile 详解图文结合_第10张图片

参考链接:
周日氪金之:彻底搞懂多线程中的volatile!
黑马程序员JUC

你可能感兴趣的:(JUC,java,Volatile,原子性,可见性,JMM)