浅析volatile关键字


前言:

volatile是java多线程中的一个重要关键字,本文呢简单分析volatile的特性以及线程安全相关的知识点,对于学习java的多线程能够有所帮助。


一.Java内存模型

在java中,线程之间的共享变量是存储在主内存中的,每个线程都有一个属于自己的私有的本地内存,其中存放着主内存中所有线程共享的变量的值的拷贝。内存模型图如下

内存模型

现在假设本地内存A和本地内存B存着主内存中的共享变量x的副本。假设初始化这三个内存中的x值都是0。现在线程A和线程B同时执行 x=x+1;那么我们希望两个线程执行完之后x的值变为2。但是事实会是这样吗?
可能存在下面一种情况:初始时,两个线程分别读取x的值存入各自的本地内存当中,然后线程A进行加1操作,然后把x的最新值1写入到内存。此时线程B的本地内存中还是0,读取x=0后进行加1操作之后,x的值为1,然后线程B把x的值写入内存。最终的结果x=1;这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。

1.原子性

在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。上面一句话虽然看起来简单,但是理解起来并不是那么容易。看下面一个例子,请分析以下哪些操作是原子性操作:

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

咋一看,有些朋友可能会说上面的4个语句中的操作都是原子性操作。其实只有语句1是原子性操作,其他三个语句都不是原子性操作。

  • 语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。
  • 语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入本地内存,虽然读取x的值以及 将x的值写入本地内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
  • 同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。

所以上面4个语句只有语句1的操作具备原子性。也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。

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

2.可见性

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

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

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

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

3.有序性

有序性:即程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下面这段代码

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

从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢? 看下面的代码

public class ReorderExample {
    int a = 0;
    boolean flag = false;

    public void writer(){
        a = 1;  //1
        flag = true; //2
    }

    public void reader(){
        if (flag) {
            int i = a;
            //other
        }
    }
}

线程A先执行writer方法,1,2语句重排序了,先执行了语句2,被阻塞了,语句1还没执行,这时候线程B执行reader()方法,看到的a=0;
所以在这里多线程的语义被重排序破坏了。


二.深入剖析volatile关键字

1.声明为 volatile 变量有以下保证:
  • 其他线程对volatile变量的修改,可以即时反应到当前线程中
  • 确保当前线程对volatile变量的修改,能即时写回主内存中,并对其他线程可见
  • 使用 volatile 声明的变量,编译器会保证其有序性

看以下代码:

public class VolatileTest extends Thread{
    private  boolean stop = false;

    public void stopMe(){
        stop = true;
    }

    @Override
    public void run() {
        int i = 0;
        while (!stop) {
            i++;
        }
        System.out.println("Thread Stop");
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileTest  test = new VolatileTest();
        test.start();
        Thread.sleep(1000);
        test.stopMe();
        Thread.sleep(1000);
    }
}

这是很典型的一段代码,上面的线程会被停止吗?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程,这是有隐藏bug的代码。

在前面已经解释过,每个线程在运行过程中都有自己的本地内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的本地内存当中。
 那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。
 但是用volatile修饰之后就变得不一样了:
 第一:使用volatile关键字会强制将修改的值立即写入主存;
 第二:使用volatile关键字的话,当线程2进行修改时,会导致线程1的本地内存中缓存变量stop的缓存行无效;
 第三:由于线程1的本地内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。
 那么在线程2修改stop值时(当然这里包括2个操作,修改线程2工作内存中的值,然后将修改后的值写入内存),会使得线程1的本地内存中缓存变量stop的缓存行无效,然后线程1读取时,发现自己的缓存行无效,它会等待缓存行对应的主内存地址被更新之后,然后去对应的主内存读取最新的值。
 那么线程1读取到的就是最新的正确的值。这个线程就能确保一定能停下来。

再来看重排序的问题,看回这段代码:

public class ReorderExample {
    int a = 0;
    boolean flag = false;

    public void writer(){
        a = 1;  //1
        flag = true; //2
    }

    public void reader(){
        if (flag) {
            int i = a;
            //other
        }
    }
}

线程A先执行writer方法,1,2语句重排序了,先执行了语句2,被阻塞了,语句1还没执行,这时候线程B执行reader()方法,看到的a=0;所以在这里多线程的语义被重排序破坏了。

但是当用volatile声明flag变量的时候:

  • 线程A写一个volatile变量的时候,会把写之前对共享变量所做的修改写到主内存中,并且对其他线程可见,并通知线程B去主内存中读数据。那么就不会出现上面那种由于重排序破坏了多线程的语义。并且volatile会保证有序性。
2.volatile 能保证原子性吗

以下有个经典的例子:

public class Test {
    public volatile int inc = 0;

    public void increase() {
        inc++;
    }

    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }

        while(Thread.activeCount()>2)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

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

假如某个时刻变量inc的值为10,

线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被阻塞了;然后线程2对变量进行自增操作,线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的本地内存中缓存变量inc的缓存行无效,所以线程2会直接去主内存读取inc的值,发现inc的值时10,然后进行加1操作,并把11写入本地内存,最后写入主内存。然后线程1接着进行加1操作,由于之前已经读取了inc的值,注意此时在线程1的工作本地中inc的值仍然为10(但是已经被标识无效,下一次读的时候就会去主内存中读),所以线程1对inc进行加1操作后inc的值为11,然后将11写入本地内存,最后写入主内存。

那么两个线程分别进行了一次自增操作后,inc就只增加了1。这时可能就更需要synchronized或Lock上锁保证线程的安全,来保证操作的原子性,也可通过封装好的AtomicInteger来实现。


三.volatile的实现原理

处理器为了提高处理速度,不直接和内存进行通讯,而是将系统内部的数据读到内部缓存后在进行操作,但操作完之后不知道什么时候会写入内存。

​ 如果对声明了volatile变量进行写操作时,JVM会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写会到系统内存。 这一步确保了如果有其他线程对声明了volatile变量进行修改,则立即更新主内存中数据。

​ 但这时候其他处理器的缓存还是旧的,所以在多处理器环境下,为了保证各个处理器缓存一致,每个处理会通过嗅探在总线上传播的数据来检查 自己的缓存是否过期,当处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作时,会强制重新从系统内存把数据读到处理器缓存里。 这一步确保了其他线程获得的声明了volatile变量都是从主内存中获取最新的。

​ Lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。


四.使用volatile关键字的场景

synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:
 1)对变量的写操作不依赖于当前值
 2)该变量没有包含在具有其他变量的不变式中

  • 状态标记量
public class VolatileTest extends Thread{
    private volatile  boolean stop = false;

    public void stopMe(){
        stop = true;
    }

    @Override
    public void run() {
        int i = 0;
        while (!stop) {
            i++;
        }
        System.out.println("Thread Stop");
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileTest  test = new VolatileTest();
        test.start();
        Thread.sleep(1000);
        test.stopMe();
        Thread.sleep(1000);
    }
}
  • 防止重排序对多线程语义破坏
public class ReorderExample {
    int a = 0;
    private volatile  boolean flag = false;

    public void writer(){
        a = 1;  //1
        flag = true; //2
    }

    public void reader(){
        if (flag) {
            int i = a;
            //other
        }
    }
}

文章作者: jackey
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 jackey !
评论
  目录