美文网首页java技术Java开发那些事技术干货
java volatile关键字只需知道这点就行

java volatile关键字只需知道这点就行

作者: 杨文杰 | 来源:发表于2017-03-05 16:39 被阅读206次

    Volatile 这个关键字可能很多朋友都听说过,但是可能不敢用,毕竟这个关键字非常不好控制,干脆不用为好。Volatile在一般的多线程编程里面算是比较尴尬的关键字了。本人也不想说(抄)太多的底层原理,相信很多人也不愿意看,只想知道怎么用,但是大概简单的了解也是必须的,这使得我们很容易的理解,并正确的使用。

    一.Java内存模型

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

    图片.png

    现在假设本地内存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()>1)  //保证前面的线程都执行完
                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。

    三.使用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;
        boolean volat flag = false;
        
        public void writer(){
            a = 1;  //1
            flag = true; //2
        }
    
        public void reader(){
            if (flag) {
                int i = a;
                //other
            }
        }
    }
    

    相关文章

      网友评论

      • xzygra:对于test.stopMe();的那个例子不是很明白,线程2改变的值早晚是会写入主存的,为什么线程1的循环能一直循环?

      本文标题:java volatile关键字只需知道这点就行

      本文链接:https://www.haomeiwen.com/subject/mstegttx.html