美文网首页
深入理解 volatile 关键字原理

深入理解 volatile 关键字原理

作者: Java旅行者 | 来源:发表于2017-07-26 21:21 被阅读0次

    1. 基本概念

    volatile 关键字,具有两个特性:1. 内存的可见性, 2. 禁止指令重排序优化。

    内存可见性

    被 volatile 关键字修饰的变量,当线程要对这个变量执行的写操作,都不会写入本地缓存,而是直接刷入主内存中。当线程读取被 volatile 关键字修饰的变量时,也是直接从主内存中读取。

    注意:volatile 不能保证原子性。很多时候都会误用。

    下面是问题代码:

    public class VolatileDemo {
    
        static volatile int count = 0;
    
        public static void main(String[] args) {
            for (int i = 0; i < 1000; i++) {
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        VolatileDemo.inc();
                    }
                }).start();
            }
            System.out.println(VolatileDemo.count);
        }
    
        static void inc() {
            count++;
        }
    }
    

    很多人以为加上了 volatile 关键字就能够实现对 int 变量的原子操作,事实并非这样。上面代码每次运行的结果都不相同。看望上面这些基本概念,下面就开始深入理解下 volatile 这个关键字吧。

    2. JVM 内存模型

    2.1 可见性

    可见性,是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果。另一个线程马上就能看到。比如:用 volatile 修饰的变量,就会具有可见性。volatile 修饰的变量不允许线程内部缓存和重排序,即直接修改内存。所以对其他线程是可见的。但是这里需要注意一个问题,volatile 只能让被他修饰内容具有可见性,但不能保证它具有原子性。

    2.2 原子性

    原子是世界上的最小单位,具有不可分割性。比如 a=0;(a 非 long 和 double 类型) 这个操作是不可分割的,那么我们说这个操作时原子操作。再比如:a++; 这个操作实际是 a = a + 1;是可分割的,所以他不是一个原子操作。java 的 concurrent 包下提供了一些原子类,我们可以通过阅读 API 来了解这些原子类的用法。比如:AtomicInteger、AtomicLong、AtomicReference等。

    2.3 有序性

    Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 是因为其本身包含“禁止指令重排序”的语义,synchronized 是由“一个变量在同一个时刻只允许一条线程对其进行 lock 操作”这条规则获得的,此规则决定了持有同一个对象锁的两个同步块只能串行执行。

    3. volatile 原理

    当一个变量定义为 volatile 之后,将具备两种特性:

    1. 保证此变量对所有的线程的可见性,这里的“可见性”,如本文开头所述,当一个线程修改了这个变量的值,volatile 保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。但普通变量做不到这点,普通变量的值在线程间传递均需要通过主内存来完成。(随着虚拟机的优化,普通变量也可以具有可见性了,下面来看一个代码)
    public class VolatileDemo extends Thread {
    
        static boolean flag = false;
    
        @Override
        public void run() {
            while (!flag) {}
        }
    
        public static void main(String[] args) throws InterruptedException {
            new VolatileDemo().start();
            // 等一段时间,目的是为了能够让线程启动并进入到 run 方法里
            TimeUnit.MILLISECONDS.sleep(300);
            flag = true;
        }
    }
    

    上面这段代码永远不会结束,因为对 flag 的修改是在 main 线程的本地工作内存中的,flag 的值对其他线程不可见。对 flag 加上 volatile 修饰符在做测试,程序能够正常结束退出。

    public class VolatileDemo extends Thread {
    
        static volatile boolean flag = false;
    
        @Override
        public void run() {
            while (!flag) {}
        }
    
        public static void main(String[] args) throws InterruptedException {
            new VolatileDemo().start();
            // 等一段时间,目的是为了能够让线程启动并进入到 run 方法里
            TimeUnit.MILLISECONDS.sleep(300);
            flag = true;
        }
    }
    

    但是对上面这段代码在稍作修改,发现其实也可以不用 volatile 关键字,普通变量照样能够实现内存可见性,程序也能够正常退出。代码如下:

    public class VolatileDemo extends Thread {
    
        static boolean flag = false;
    
        @Override
        public void run() {
            while (!flag) { System.out.println(1); }
        }
    
        public static void main(String[] args) throws InterruptedException {
            new VolatileDemo().start();
            // 等一段时间,目的是为了能够让线程启动并进入到 run 方法里
            TimeUnit.MILLISECONDS.sleep(300);
            flag = true;
        }
    }
    

    这是什么原因呢?原来只有在对变量读取频率很高的情况下,虚拟机才不会及时回写主内存,而当频率没有达到虚拟机认为的高频率时,普通变量和volatile是同样的处理逻辑。如在每个循环中执行System.out.println(1)加大了读取变量的时间间隔,使虚拟机认为读取频率并不那么高,所以实现了和volatile的效果。

    1. 禁止指令重排序优化。有volatile修饰的变量,赋值后多执行了一个 “load addl $0x0, (%esp)” 操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个 CPU 访问内存时,并不需要内存屏障

    4. 深入理解指令重排序和内存屏障

    4.1 as-if-serial 语义

    as-if-serial 的语义是:不管怎么重排序,单线程程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守“as-if-serial”语义。

    为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。

    但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

    拿个简单例子来说:

    public void execute(){
        int a=0;
        int b=1;
        int c=a+b;
    }
    

    这里a=0,b=1两句可以随便排序,不影响程序逻辑结果,但c=a+b这句必须在前两句的后面执行。

    as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器、runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial 语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

    4.2 指令重排序(happens-before)

    重排序的规则

    ★1. 程序次序规则(Program Order Rule):在一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说应该是控制流顺序而不是代码顺序,因为要考虑分支、循环等结构。

    ★2. 监视器锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个对象锁的lock操作。这里强调的是同一个锁,而“后面”指的是时间上的先后顺序,如发生在其他线程中的lock操作。

    ★3. volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作发生于后面对这个变量的读操作,这里的“后面”也指的是时间上的先后顺序。

    1. 线程启动规则(Thread Start Rule):Thread独享的start()方法先行于此线程的每一个动作。

    2. 线程终止规则(Thread Termination Rule):线程中的每个操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值检测到线程已经终止执行。

    3. 线程中断规则(Thread Interruption Rule):对线程interrupte()方法的调用优先于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否已中断。

    4. 对象终结原则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

    ★8. 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

    如果我们的多线程程序依赖于代码书写顺序,那么就要考虑是否符合以上规则,如果不符合就要通过一些机制使其符合,最常用的就是synchronized、Lock以及volatile修饰符。

    值得注意的是:两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行! happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。

    举一个例子来理解重排序,看下面的代码:

    public class SimpleHappenBefore {
        /** 这是一个验证结果的变量 */
        private static int a=0;
        /** 这是一个标志位 */
        private static boolean flag=false;
        public static void main(String[] args) throws InterruptedException {
            //由于多线程情况下未必会试出重排序的结论,所以多试一些次
            for(int i=0;i<1000;i++){
                ThreadA threadA=new ThreadA();
                ThreadB threadB=new ThreadB();
                threadA.start();
                threadB.start();
                //这里等待线程结束后,重置共享变量,以使验证结果的工作变得简单些.
                threadA.join();
                threadB.join();
                a=0;
                flag=false;
            }
        }
        static class ThreadA extends Thread{
            public void run(){
                a=1;            //1
                flag=true;      //2
            }
        }
        static class ThreadB extends Thread{
            public void run(){
                if(flag){       //3
                    a=a*1;      //4
                }
                if(a==0){
                    System.out.println("ha,a==0");
                }
            }
        }
    }
    

    flag 变量是个标记,用来标识变量 a 是否已被写入。这里假设有两个线程 A 和 B,A 首先执行 writer() 方法,随后 B 线程接着执行 reader() 方法。线程 B 在执行操作 4 时,能否看到线程 A 在操作 1 对共享变量 a 的写入?

    答案是:不一定能看到。

    由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。让我们先来看看,当操作1和操作2重排序时,可能会产生什么效果?请看下面的程序执行时序图:


    image

    如上图所示,操作1和操作2做了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还根本没有被线程A写入,在这里多线程程序的语义被重排序破坏了!

    ※注:本文统一用红色的虚箭线表示错误的读操作,用绿色的虚箭线表示正确的读操作。

    下面再让我们看看,当操作3和操作4重排序时会产生什么效果(借助这个重排序,可以顺便说明控制依赖性)。下面是操作3和操作4重排序后,程序的执行时序图:

    image

    在程序中,操作3和操作4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作3的条件判断为真时,就把该计算结果写入变量i中。

    从图中我们可以看出,猜测执行实质上对操作3和4做了重排序。重排序在这里破坏了多线程程序的语义!

    除此之外,Java内存模型对volatile和final的语义做了扩展。对volatile语义的扩展保证了volatile变量在一些情况下不会重排序,volatile的64位变量double和long的读取和赋值操作都是原子的。对final语义的扩展保证一个对象的构建方法结束前,所有final成员变量都必须完成初始化(的前提是没有this引用溢出)。

    Java内存模型关于重排序的规定,总结后如下表所示。

    imageimage

    表中“第二项操作”的含义是指,第一项操作之后的所有指定操作。如,普通读不能与其之后的所有volatile写重排序。另外,JMM也规定了上述volatile和同步块的规则尽适用于存在多线程访问的情景。例如,若编译器(这里的编译器也包括JIT,下同)证明了一个volatile变量只能被单线程访问,那么就可能会把它做为普通变量来处理。

    留白的单元格代表允许在不违反Java基本语义的情况下重排序。例如,编译器不会对对同一内存地址的读和写操作重排序,但是允许对不同地址的读和写操作重排序。

    除此之外,为了保证final的新增语义。JSR-133对于final变量的重排序也做了限制。

    • 构建方法内部的final成员变量的存储,并且,假如final成员变量本身是一个引用的话,这个final成员变量可以引用到的一切存储操作,都不能与构建方法外的将当期构建对象赋值于多线程共享变量的存储操作重排序。

      例如对于如下语句
      x.finalField = v; ... ;构建方法边界sharedRef = x;

      v.afield = 1; x.finalField = v; ... ; 构建方法边界sharedRef = x;

      这两条语句中,构建方法边界前后的指令都不能重排序。

    • 初始读取共享对象与初始读取该共享对象的final成员变量之间不能重排序。

      例如对于如下语句

      x = sharedRef; ... ; i = x.finalField;
      前后两句语句之间不会发生重排序。

      由于这两句语句有数据依赖关系,编译器本身就不会对它们重排序,但确实有一些处理器会对这种情况重排序,因此特别制定了这一规则。

    4.3 内存屏障

    内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。

    内存屏障可以被分为以下几种类型

    1. LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。

    2. StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。

    3. LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。

    4. StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。 在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

    有的处理器的重排序规则较严,无需内存屏障也能很好的工作,Java编译器会在这种情况下不放置内存屏障。

    为了实现上一章中讨论的JSR-133的规定,Java编译器会这样使用内存屏障。

    imageimage

    为了保证final字段的特殊语义,也会在下面的语句加入内存屏障。

    x.finalField = v; StoreStore; sharedRef = x;

    相关文章

      网友评论

          本文标题:深入理解 volatile 关键字原理

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