volatile关键字的理解.md

概述:

在Java并发编程中,要想使并发程序能够正确的执行,就要满足三个性质:原子性,可见性,有序性。只要一条性质不满足,。就有可能导致程序出现错误,当然这种错误有时候是随即发生的,但是不发生不代表不存在。在寒假之间就已经接触volatile关键字了,只是有了一个初步的了解,今天做一个总结。Java内存模型中使用volatile关键字来保证可见性,来解决缓存一致性问题,一旦一个共享变量被volatile关键字修饰,就具备了两个含义:内存可见性和禁止指令重排,这样之间的双重检查锁失效问题也就迎刃而解,将变量定义为:private volatile static Singleton instance = null; 。

由于volatile关键字和内存模型紧密相关,下面分别介绍内存模型和Java内存模型,注意二者之间的区分,内存模型指的是计算机的内存模型,Java内存模型表示的JVM内部的内存模型结构。


一.内存模型的相关概念

在计算机的内存模型中,指令是在CPU中执行的,在执行指令的过程中,不可避免的要进行数据的读入或者写出,而程序运行过程中,临时数据都是存放于主存之中的,这样就产生了一个问题,高速的CPU执行速度和进行主存IO之间速度的不匹配,每次CPU都要空闲下来等待对主存的IO操作,效率极低。随后引入了高速缓存,也就是寄存器。

,在程序运行过程中,会将运算需要的数据从主存复制一份到 CPU 的高速缓存当中,那么, CPU 进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。举个简单的例子,比如下面的这段代码:

i = i + 1;

  当线程执行这个语句时,会先从主存当中读取 i 的值,然后复制一份到高速缓存当中,然后CPU执行指令对 i 进行加1操作,然后将数据写入高速缓存,最后将高速缓存中 i 最新的值刷新到主存当中。

  这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题了。在多核 CPU 中,每个线程可能运行于不同的 CPU 中,因此每个线程运行时有自己的高速缓存(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。本文我们以多核CPU为例。

  比如,同时有两个线程执行这段代码,假如初始时 i 的值为 0,那么我们希望两个线程执行完之后 i 的值变为 2。但是事实会是这样吗?

  可能存在下面一种情况:初始时,两个线程分别读取 i 的值存入各自所在的 CPU 的高速缓存当中,然后线程1 进行加 1 操作,然后把 i 的最新值 1 写入到内存。此时线程 2 的高速缓存当中 i 的值还是 0,进行加 1 操作之后,i 的值为 1,然后线程 2 把 i 的值写入内存。

  最终结果 i 的值是 1,而不是 2 。这就是著名的 缓存一致性问题 。通常称这种被多个线程访问的变量为 共享变量 。

  也就是说,如果一个变量在多个 CPU 中都存在缓存(一般在多线程编程时才会出现),那么就可能存在 缓存不一致 的问题。
 为了解决缓存不一致性问题,在 硬件层面 上通常来说有以下两种解决方法:

  1)通过在 总线加 LOCK# 锁 的方式 (在软件层面,效果等价于使用 synchronized 关键字);

  2)通过 缓存一致性协议 (在软件层面,效果等价于使用 volatile 关键字)。

  在早期的 CPU 当中,是通过在总线上加 LOCK# 锁的形式来解决缓存不一致的问题。因为 CPU 和其他部件进行通信都是通过总线来进行的,如果对总线加 LOCK# 锁的话,也就是说阻塞了其他 CPU 对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。比如上面例子中, 如果一个线程在执行 i = i + 1,如果在执行这段代码的过程中,在总线上发出了 LCOK# 锁的信号,那么只有等待这段代码完全执行完毕之后,其他 CPU 才能从变量 i 所在的内存读取变量,然后进行相应的操作,这样就解决了缓存不一致的问题。但是上面的方式会有一个问题,由于在锁住总线期间,其他 CPU 无法访问内存,导致效率低下。

  所以,就出现了 缓存一致性协议 ,其中最出名的就是 Intel 的 MESI 协议。MESI 协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是: 当 CPU 写数据时,如果发现操作的变量是共享变量,即在其他 CPU 中也存在该变量的副本,会发出信号通知其他 CPU 将该变量的缓存行置为无效状态。因此,当其他 CPU 需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
image-20200308104156290

二.并发编程的三个概念

1.原子性

一个或者多个操作,要么全部执行并且在执行的过程中不被打断,要么就不执行

可以使用数据库中的事务的特征来理解

2.可见性

可见性是指当多个线程同时访问同一个共享变量的时候,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值

举个例子:

//线程1执行的代码
int i = 0;
i = 10;


//线程2执行的代码
j = i;

假若执行 线程1 的是 CPU1,执行 线程2 的是 CPU2。由上面的分析可知,当 线程1 执行 i = 10 这句时,会先把 i 的初始值加载到 CPU1 的高速缓存中,然后赋值为10,那么在 CPU1 的高速缓存当中 i 的值变为 10 了,却没有立即写入到主存当中。此时,线程2 执行 j = i,它会先去主存读取 i 的值并加载到 CPU2 的缓存当中,注意此时内存当中 i 的值还是 0,那么就会使得 j 的值为 0,而不是 10。

这就是可见性问题,线程1 对变量 i 修改了之后,线程2 没有立即看到 线程1 修改后的值。

3.有序性

程序的执行顺序按照代码的先后顺序执行

举个例子:

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

这里涉及到的是指令重排问题,前面已经提到过,volatile关键字一个作用是确保可见性,另外就是禁止指令重排。你别看语句1在语句2前面,但是实际的执行顺序,二者谁先执行还真不一定,CPU也是有乱序执行的特点。

下面是指令重排的理解,CPU为了提高程序执行效率,可能回对输入的代码进行优化,程序实际的执行顺序可能就不是严格的按照代码中的实际顺序了,但是即使这样做,他会保证代码的最终结果和顺序执行的结果是相同的(单线程情况下

例如:

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

语句4是不可能在语句3之前执行的,处理器进行指令重排时并不是没有底线的,他要考虑到指令之间的依赖性。

但是这只是在单线程情况下, 在多线程情况下,就会出现问题:

//线程1:
context = loadContext();   //语句1
inited = true;             //语句2


//线程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

语句1和语句2之间没有依赖性,如果在执行1之前,2已经执行,那么这是在线程2中,会跳出while循环,执行doSomethingwithconfig(context);但是此时context还没进行初始化,程序执行就会出现问题。同样的情况还有著名的双重检查锁失效问题,对象虽然已经分配到内存空间,但是还没有进行初始化就返回对象的地址,造成不可预料的错误。

三.Java内存模型

Java内存模型为了良好的性能,并没有摒弃处理器的缓存和乱序执行功能,这样就不可避免的要解决缓存一致性和指令重排问题。

Java内存模型 规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作,并且每个线程不能访问其他线程的工作内存。

那么,Java语言本身对原子性、可见性以及有序性 提供了哪些保证呢?

1.原子性

在Java中,对基本数据类型的读取和赋值都是原子性操作

x = 10;         //语句1
y = x;         //语句2
x++;           //语句3
x = x + 1;     //语句4

别看这四个语句很简单,但是只有第一条语句是原子性操作,语句2先要进行读取x的值,然后再写入到工作内存中,同理语句3,4也是。也就是说,只有简单的读取,赋值才是原子操作(将具体的数值赋值给变量),变量之间的相互赋值不是原子操作。

从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过 synchronized 和 Lock 来实现。由于 synchronized 和 Lock 能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

2.可见性

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

  当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

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

​ 但是显然后者就比较笨重

3.有序性

 在 Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

  在 Java 中,可以通过 volatile 关键字来保证一定的“有序性”(具体原理在下一节讲述)。另外,我们千万不能想当然地认为,可以通过synchronized 和 Lock 来保证有序性,也就是说,不能由于 synchronized 和 Lock 可以让线程串行执行同步代码,就说它们可以保证指令不会发生重排序,这根本不是一个粒度的问题。

四.深入理解volatile

先看一段代码,假如 线程1 先执行,线程2 后执行:

//线程1
boolean stop = false;
while(!stop){
    doSomething();
}


//线程2
stop = true;

image-20200308110831262

下面解释一下这段代码为何有可能导致无法中断线程。在前面已经解释过,如上图所示,每个线程在运行过程中都有自己的工作内存,那么 线程1 在运行的时候,会将 stop 变量的值拷贝一份放在自己的工作内存当中。那么,当 线程2 更改了 stop变量 的值之后,可能会出现以下两种情形:

线程2 对变量的修改没有立即刷入到主存当中;

即使 线程2 对变量的修改立即反映到主存中,线程1 也可能由于没有立即知道 线程2 对stop变量的更新而一直循环下去。

这两种情形都会导致 线程1 处于死循环。但是,用 volatile关键字 修饰后就变得不一样了,如下图所示:

  ① 使用 volatile 关键字会强制将修改的值立即写入主存;

  ② 使用 volatile 关键字的话,当 线程2 进行修改时,会导致 线程1 的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的 L1 或者 L2 缓存中对应的缓存行无效);

  ③ 由于 线程1 的工作内存中缓存变量stop的缓存行无效,所以,线程1 再次读取变量stop的值时会去主存读取。

volatile能保证原子性嘛

之前在印象笔记里面思考过这个问题:

image-20200308110238832

但是显然是不对的,现在做一个矫正

下面看一个例子:

//线程类
class MyThread extends Thread {
    // volatile 共享静态变量,类成员
    public volatile static int count;

    private static void addCount() {
        for (int i = 0; i < 100; i++) {
            count++;
        }
        System.out.println("count=" + count);
    }

    @Override
    public void run() {
        addCount();
    }
}

//测试类
public class Run {
    public static void main(String[] args) {
        //创建 100个线程并启动
        MyThread[] mythreadArray = new MyThread[100];
        for (int i = 0; i < 100; i++) {
            mythreadArray[i] = new MyThread();
        }

        for (int i = 0; i < 100; i++) {
            mythreadArray[i].start();
        }
    }
}/* Output(循环): 
       ... ...
       count=9835
 *///:~

大家想一下这段程序的输出结果是多少?也许有些朋友认为是 10000。但是事实上运行它会发现每次运行结果都不一致,都是一个 小于 10000 的数字。可能有的朋友就会有疑问,不对啊,上面是对变量 count 进行自增操作,由于 volatile 保证了可见性,那么在每个线程中对 count 自增完之后,在其他线程中都能看到修改后的值啊,所以有 100个 线程分别进行了 100 次操作,那么最终 count 的值应该是 100*100=10000。
这里面就有一个误区了,volatile 关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是 volatile 没办法保证对变量的操作的原子性。在前面已经提到过,自增操作是不具备原子性的,它包括 读取变量的原始值、进行加1操作 和 写入工作内存 三个原子操作。那么就是说,这三个子操作可能会分割开执行,所以就有可能导致下面这种情况出现:

  假如某个时刻 变量count 的值为 10,线程1 对变量进行自增操作,线程1 先读取了 变量count 的原始值,然后 线程1 被阻塞了;然后,线程2 对变量进行自增操作,线程2 也去读取 变量count 的原始值,由于 线程1 只是对 变量count 进行读取操作,而没有对变量进行修改操作,所以不会导致 线程2 的工作内存中缓存变量 count 的缓存行无效,所以 线程2 会直接去主存读取 count的值 ,发现 count 的值是 10,然后进行加 1 操作。注意,此时 线程2 只是执行了 count + 1 操作,还没将其值写到 线程2 的工作内存中去!此时线程2 被阻塞,线程1 进行加 1 操作时,注意操作数count仍然是 10!然后,线程2 把 11 写入工作内存并刷到主内存。虽然此时 线程1 能感受到 线程2 对count的修改,但由于线程1只剩下对count的写操作了,而不必对count进行读操作了,所以此时 线程2 对count的修改并不能影响到 线程1。于是,线程1 也将 11 写入工作内存并刷到主内存。也就是说,两个线程分别进行了一次自增操作后,count 只增加了 1。

进一步地,将上述代码修改成下面示例的样子以后,这个问题就迎刃而解:

//线程类
class MyThread extends Thread {
    // 既然使用 synchronized关键字 ,就没必要使用 volatile关键字了
    public static int count;

    //注意必须添加 static 关键字,这样synchronized 与 static 锁的就是 Mythread.class 对象了,
    //也就达到同步效果了
    private synchronized static void addCount() {
        for (int i = 0; i < 100; i++) {
            count++;
        }
        System.out.println("count=" + count);
    }

    @Override
    public void run() {
        addCount();
    }
}

//测试类
public class Run {
    public static void main(String[] args) {
        //创建 100个线程并启动
        MyThread[] mythreadArray = new MyThread[100];
        for (int i = 0; i < 100; i++) {
            mythreadArray[i] = new MyThread();
        }

        for (int i = 0; i < 100; i++) {
            mythreadArray[i].start();
        }
    }
}

五.使用volatile关键字的场景

synchronized 关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率;而 volatile 关键字在某些情况下性能要优于 synchronized,但是要注意 volatile 关键字是无法替代 synchronized 关键字的,因为 volatile 关键字无法保证操作的原子性。通常来说,使用 volatile 必须具备以下两个条件:

  1)对变量的写操作不依赖于当前值;

  2)该变量没有包含在具有其他变量的不变式中。

  实际上,这些条件表明,可以被写入 volatile 变量的这些有效值 独立于任何程序的状态,包括变量的当前状态。事实上,上面的两个条件就是保证对 该volatile变量 的操作是原子操作,这样才能保证使用 volatile关键字 的程序在并发时能够正确执行。

特别地,关键字 volatile 主要使用的场合是:

  在多线程环境下及时感知共享变量的修改,并使得其他线程可以立即得到变量的最新值。
1.状态标记量

// 示例 1
volatile boolean flag = false;

while(!flag){
    doSomething();
}

public void setFlag() {
    flag = true;
}
// 示例 2
volatile boolean inited = false;


//线程1:
context = loadContext();  
inited = true;            


//线程2:
while(!inited ){
    sleep()
}
doSomethingwithconfig(context);

2.双重检查

class Singleton{
    private volatile static Singleton instance = null;

    private Singleton() {

    }

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

六.小结

image-20200308110831262

七.总结

、本博客主要内容均参照博客https://blog.csdn.net/justloveyou_/article/details/53672005#comments,这里只是做一个梳理,感谢原博主对volatile关键字教科书似的总结!

Copyright: 采用 知识共享署名4.0 国际许可协议进行许可

Links: https://hadoo666.top/archives/volatile关键字的理解md