JAVA并发编程的艺术(7)根据JMM分析volatile

in java并发 with 0 comment

当声明变量为volatile后,对这个变量的读/写将会很特别。 volatile是轻量级的synchronized。如果一个变量使用volatile,则它比使用synchronized的成本更加低,因为它不会引起线程上下文的切换和调度。

Java内存模型

在并发编程中我们一般都会遇到这三个基本概念:原子性、可见性、有序性。

原子性

原子性即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。就像数据库里面的事务一样,他们是一个团队,同生共死。 举例子

例子是否原子性解释
i=1在Java中,对基本数据类型的变量和赋值操作都是原子性操作
j=i包含了两个操作:读取i,将i值赋值给j
i++包含了三个操作:读取i值、i + 1 、将+1结果赋值给i
j=i+1包含了三个操作:读取i的值、i+1、将+1结果赋值给j

在单线程环境下我们可以认为整个步骤都是原子性操作,但是在多线程环境下则不同,Java只保证了基本数据类型的变量和赋值操作才是原子性的。 要想在多线程环境下保证原子性,则可以通过锁、synchronized来确保(volatile是无法保证复合操作的原子性)。

可见性

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

在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。 Java提供了volatile来保证可见性。 当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,当其他线程读取共享变量时,它会直接从主内存中读取。synchronize和锁都可以保证可见性。

有序性

有序性即程序执行的顺序按照代码的先后顺序执行。

在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,重排序它不会影响单线程的运行结果,但是对多线程会有影响。 Java提供volatile来保证一定的有序性。最著名的例子就是单例模式里面的DCL(双重检查锁)。

volatile原理

volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。

所以,关于volatile的特性可以总结为

  1. 保证可见性、不保证原子性(仅仅只能保证对单个volatile变量对读/写具有原子性)
  2. 禁止指令重排序
  3. 底层volatile采用“内存屏障”实现

volatile与happens-before

happens-before是用来判断是否存数据竞争、线程是否安全的主要依据,它保证了多线程环境下的可见性。

happens-before关于对volatile的规则

volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。(当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存上。当读一个volatile变量时,JMM会把该线程对应的本地缓存置为无效,从主内存中重新读取变量。)

从内存语义的角度说,volatile的写-读与锁的释放-获取有相同的内存效果:volatile写和锁的释放有相同的内存语义;volatile读与锁的获取有相同的内存语义

示例代码:

    int a = 0;
    volatile boolean flag = false;

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

    //Thread B
    public void reader(){
        if(flag){           //3
            int i = a;      //4
        }
    }

依据happens-before原则,就上面程序得到如下关系:

  1. 依据happens-before程序顺序原则:1 happens-before 2、3 happens-before 4;
  2. 根据happens-before的volatile原则:2 happens-before 3;
  3. 根据happens-before的传递性:1 happens-before 4

操作1、操作4存在happens-before关系,那么1一定是对4可见的。此时,1和2将不会发生重排序,因为现在的flag被volatile修饰,volatile会禁止重排序。所以A线程在写volatile变量之前所有可见的共享变量,在线程B读同一个volatile变量后,将立即变得对线程B可见。

volatile内存语义

在JMM中,线程之间的通信采用共享内存来实现的。当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新到主内存中。 当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量

所以volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取。 对于一般的变量则会被重排序,而对于volatile则不能,这样会影响其内存语义,所以为了实现volatile的内存语义JMM会限制重排序。

volatile写-读内存语义

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新到主内存中。 当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量

volatile写内存语义

以上面实例代码为例,假设线程A首先执行writer()方法,随后线程B执行reader()方法,初始时,两个线程的本地内存中的flag和a都是初始状态。下图是线程A执行volatile写后,共享变量的状态示意图。

image.png

线程A在写flag变量后,本地内存A中被线程A更新过的两个变量的值被刷新到主内存中,此时,本地内存A和主内存中的共享变量的值是一致的。

volatile读内存语义

继续执行线程B的reader()方法,下图是线程B执行volatile读的共享变量示意图。

image.png

如图所示,在读flag变量后,本地内存B包含的值已经比置为无效。此时,线程B必须从主内存中读取共享变量。线程B的读取操作将导致本地内存B与主内存的共享变量的值变成一致。

如果把volatile写和volatile读这两个步骤综合起来看的话,在线程B读一个volatile变量后,写线程A在写这个volatile变量之前所有可见的共享变量的值都将立即变得对读线程B可见

volatile读-写内存语义总结

volatile重排序规则

volatile重排序规则表:

是否能重排序第二个操作第二个操作第二个操作
第一个操作普通读/写volatile读volatile写
普通读/写 no
volatile读nonono
volatile写nono
  1. 如果第一个操作为volatile读,则不管第二个操作是啥,都不能重排序。这个操作确保volatile读之后的操作不会被编译器重排序到volatile读之前;
  2. 当第二个操作为volatile写时,则不管第一个操作是啥,都不能重排序。这个操作确保volatile写之前的操作不会被编译器重排序到volatile写之后;
  3. 当第一个操作volatile写,第二操作为volatile读时,不能重排序。

volatile底层实现

volatile的底层实现是通过插入内存屏障。

上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意对程序中都能得到正确对volatile内存语义。

volatile写内存屏障

volatile写插入内存屏障指令顺序图:

image.png

StoreStore屏障可以保证在volatile写之前,其前面所有普通写操作已经对任意处理器可见了,这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。 StoreLoad的作用是避免volatile写与后面可能有的volatile读/写操作重排序。这是因为编译器常常无法准确判断在一个volatile写后面是否需要插入一个StoreLoad屏障,比如一个volarile写之后方法立即return。为了保证能正确实现volatile的内存语义,JMM在采取了保守策略:在每一个volatile写的后面或者在每一个volatile读的前面插入一个StoreLoad屏障。当读线程大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率。

volatile读内存屏障

volatile读插入内存屏障指令顺序图:

image.png

LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序 LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序

上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。

volatile底层实现原理

这是之前在网上听公开课学到的知识。

volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile,java线程内存模型确保所有线程看到的这个变量的值是一致的。

通过查看Java的汇编指令,查看Java代码最真实的运行细节。

image.png

标有volatile的变量在进行写操作时,会在前面加上lock质量前缀,何为Lock前缀 它的作用是使得本CPU的Cache写入了内存,该写入动作也会引起别的CPU invalidate其Cache。所以通过这样一个空操作,可让前面volatile变量的修改对其他CPU立即可见。 所以它的作用可以认为有以下三个

  1. 锁住主存
  2. 任何读必须在写完成之后再执行
  3. 使其它线程这个值的栈缓存失效

JMM和CPU分析volatile原理

继续分析这串代码

    int a = 0;
    volatile boolean flag = false;

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

    //Thread B
    public void reader(){
        if(flag){           //3
            int i = a;      //4
        }
    }

其用JMM和CPU分析图如下 image.png

  1. read旧值,通过总线获得值
  2. load旧值,将旧值load入工作内存
  3. use旧值,cpu开始use工作内存中的的旧值
  4. assign新值,cpu把新值assign入工作内存中
  5. store新值,工作内存将新值Store,此时会向处理器发送Lock前缀指令
  6. write新值,将新值写入主内存中,写入后unlock。 (注:这里的新值和旧值是相对而言的)

在volatile写操作,jvm就会向处理器发送一条Lock前缀的指令,Lock前缀指令会引起处理器缓存会写到内存。Lock信号确保在声言该信号期间,处理其可以独占任何共享内存。但是Lock信号一般不锁总线,而是锁缓存,因为锁总线的开销比总线的开销大。 接着,一个处理器的缓存回写到内存会导致其他处理器的缓存无效,根据MESI控制协议去维护内部缓存和其他处理器的缓存一致性。即,根据上面的代码,此时线程B的工作内存的缓存被刷新,会重新去内存中读取。 就是这样,保证了volatile的可见性。

总结

volatile保证了有序性和可见性。

在不改变volatile写-读的内存语义情况下,编译器可以根据具体情况省略不必要的屏障。