并发编程 volatile关键字详解

  • Post author:
  • Post category:其他


一、CPU 缓存模型

要了解volatile是干什么的,我们得先了解CPU 缓存模型

类比我们开发网站后台系统使用的缓存(比如 Redis)是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。 CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。

在这里插入图片描述

这种多级缓存的结构下,会有什么问题呢?最经典的就是可见性的问题,可以简单的理解为,一个线程修改的值对其他线程可能不可见。比如两个CPU读取了一个缓存行,缓存行里有两个变量,一个x一个y。第一颗CPU修改了x的数据,还没有刷回主存,此时第二颗CPU,从主存中读取了未修改的缓存行,而此时第一颗CPU修改的数据刷回主存,这时就出现,第二颗CPU读取的数据和主存不一致的情况。为了解决数据不一致的问题,很多厂商提出了自己的解决方案,比如英特尔的MESI协议。

除了增加高速缓存之外,为了使处理器内部的运算单元尽量被充分利用。处理器可能会对输入的代码进行乱序执行,优化处理器会在计算机之后将乱序执行的结构进行重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句的先后执行顺序与输入输入代码中的顺序一致。因此如果存在一个计算任务,依赖于另外一个依赖任务的中间,结果那么顺序性不能靠代码的先后顺序来保证,Java虚拟机的即时编译器中也有指令重排的优化。(下面有指令重排的例子)

二、JMM(Java 内存模型)

Java 内存模型是 Java Memory Model(JMM),本身是一种抽象的概念,实际上并不存在,描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式

JMM 作用:

  • 屏蔽各种硬件和操作系统的内存访问差异,实现让 Java 程序在各种平台下都能达到一致的内存访问效果
  • 规定了线程和内存之间的一些关系

根据 JMM 的设计,系统存在一个主内存(Main Memory),Java 中所有变量都存储在主存中,对于所有线程都是共享的;每条线程都有自己的工作内存(Working Memory),工作内存中保存的是主存中某些变量的拷贝,线程对所有变量的操作都是先对变量进行拷贝,然后在工作内存中进行,不能直接操作主内存中的变量;线程之间无法相互直接访问,线程间的通信(传递)必须通过主内存来完成

在这里插入图片描述

  • 主内存 :所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量)
  • 本地内存 :每个线程都有一个私有的本地内存来存储共享变量的副本,并且,每个线程只能访问自己的本地内存,无法访问其他线程的本地内存。本地内存是 JMM 抽象出来的一个概念,存储了主内存中的共享变量副本。

三、并发编程的三个重要特性

1)原子性

一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。synchronized 可以保证代码片段的原子性。

2)可见性

当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。volatile 关键字可以保证共享变量的可见性。

存在不可见问题的根本原因是由于缓存的存在,线程持有的是共享变量的副本,无法感知其他线程对于共享变量的更改,导致读取的值不是最新的。但是 final 修饰的变量是不可变的,就算有缓存,也不会存在不可见的问题

例如main 线程对 run 变量的修改对于 t1 线程不可见,导致了 t1 线程无法停止:

public class VolatileTest {
    static boolean run = true;

    public static void main(String[] args) {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                while(run){

                }
            }
        });
        t1.start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        run = false;
    }
}

原因:

  • 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存
  • 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率
  • 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
    在这里插入图片描述
    我们可以在run变量上加上volatile关键字解决这个问题

3)有序性

代码在执行的过程中的先后顺序,Java 在编译器以及运行期间的优化,代码的执行顺序未必就是编写代码时候的顺序。volatile 关键字可以禁止指令进行重排序优化。

四、volatile

了解完上面的知识后,我们可以进一步理解volatile的作用

同步机制

volatile 是 Java 虚拟机提供的轻量级的同步机制(三大特性)

  • 保证可见性
  • 不保证原子性
  • 保证有序性(禁止指令重排)

性能:volatile 修饰的变量进行读操作与普通变量几乎没什么差别,但是写操作相对慢一些,因为需要在本地代码中插入很多内存屏障来保证指令不会发生乱序执行,但是开销比锁要小

synchronized 无法禁止指令重排和处理器优化,为什么可以保证有序性可见性

  • 加了锁之后,只能有一个线程获得到了锁,获得不到锁的线程就要阻塞,所以同一时间只有一个线程执行,相当于单线程,由于数据依赖性的存在,单线程的指令重排是没有问题的
  • 线程加锁前,将清空工作内存中共享变量的值,使用共享变量时需要从主内存中重新读取最新的值;线程解锁前,必须把共享变量的最新值刷新到主内存

指令重排

指令重排序是指源码顺序和程序顺序不一样,或者说程序顺序和执行的顺序不一致,重排序的对象是指令。指令重排序是编译器处于性能考虑,在不影响程序(单线程程序)正确性的条件下进行重新排序。指令重排序不是必然发生的,指令重排序会导致线程安全问题。指令重排序也被称为处理器的乱序执行,在这种情况下尽管指令的执行顺序可能没有完全按照程序顺序执行,但是由于指令的执行结果的提交(反应到寄存器和内存中),仍然是按照程序顺序来的,因此处理器的指令重排序并不会对单线程的正确性产生影响。指令重排序不会对单线程程序的正确性产生影响,但他可能导致多线程程序出现非预期结果。

volatile 修饰的变量,可以禁用指令重排

指令重排样例1:

int num = 0;
boolean ready = false;
// 线程1 执行此方法
public void actor1(I_Result r) {
    if(ready) {
    	r.r1 = num + num;
    } else {
    	r.r1 = 1;
    }
}
// 线程2 执行此方法
public void actor2(I_Result r) {
	num = 2;
	ready = true;
}
  • 情况一:线程 1 先执行,ready = false,结果为 r.r1 = 1

  • 情况二:线程 2 先执行 num = 2,但还没执行 ready = true,线程 1 执行,结果为 r.r1 = 1

  • 情况三:线程 2 先执行 ready = true,线程 1 执行,进入 if 分支结果为 r.r1 = 4

  • 情况四:线程 2 执行 ready = true,切换到线程 1,进入 if 分支为 r.r1 = 0,再切回线程 2 执行 num = 2,发生指令重排

指令重排样例2:

public class Test {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; ; i++) {
            x = 0; y = 0;
            a = 0; b = 0;
            Thread one = new Thread(() -> {
                a = 1;
                x = b;
            });
            Thread other = new Thread(() -> {
                b = 1;
                y = a;
            });
            one.start(); other.start();;
            one.join(); other.join();
            if (x == 0 && y == 0) {
                String result = "第" + i + "次(" + x + ", " + y + ")";
                System.out.println(result);
            }
        }
    }

}

因为线程one中,a和x并不存在依赖关系,因此可能会先执行x=b;而这个时候,b=0。因此x会被赋值为0,而a=1这条语句还没有被执行的时候,线程other先执行了y=a这条语句,这个时候a还是a=0;因此y被赋值为了0。所以存在情况x=0;y=0。这就是指令重排导致的多线程问题。

volatile原理

如何保证可见性

  • 写屏障保证在该屏障之前的,对共享变量的改动,都同步到主存当中;写屏障是加在volatile修饰的变量的赋值之后的
public void actor2(I_Result r) {
    num = 2; //这个num也会同步到主存
    ready = true; // ready是volatile修饰的变量 ,对该变量进行修改是带写屏障的
    // 这里加写屏障  【写屏障之前的代码】是不会指令重排序的,并且写屏障之前的变量即便没有加volatile关键字修饰,那也是会强制从主存中刷新过来的
}
  • 读屏障保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
 public void actor1(I_Result r) {
     // 这里加读屏障  读屏障后面的变量都是从主存中读取的
     // ready 是 volatile修饰的变量   读取该值是带读屏障
     if(ready) {
         r.r1 = num + num;
     } else {
         r.r1 = 1;
     }
 }

在这里插入图片描述

如何保证有序性

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

不能解决指令交错(可以解决指令重排,利用读、写屏障):

  • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证其他线程的读跑到写屏障之前

  • 有序性的保证也只是保证了本线程内相关代码不被重排序

volatile i = 0;
new Thread(() -> {i++});
new Thread(() -> {i--});

在这里插入图片描述


版权声明:本文为qq_45702539原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。