Java并发编程之 volatile

x33g5p2x  于2021-09-25 转载在 Java  
字(2.9k)|赞(0)|评价(0)|浏览(300)

并发编程三大特性

原子性、有序性、可见性

volatile可以保证有序性和可见性,不能保证原子性

happens-before原则

在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

  • 程序顺序规则:一个线程中,按照程序顺序,前面的操作 happens-before 于后续的任意操作
  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C
  • start()规则:主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作
  • join()规则:如果在线程A中调用线程B的join()方法(没有出现异常),那么线程B中的所有操作happens-before于线程A调用线程B的join()方法后的操作

as-if-serial原则

不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变

编译器和处理器不会对存在数据依赖关系的操作做重排序,对不存在数据依赖关系的操作则可能进行重排序。

volatile读/写的内存语义

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

volatile如何保证可见性

有如下这段代码:

public class Main {
    private static volatile boolean initFlag = false;

    public static void main(String[] args) throws Exception {
        new Thread(() -> {
            System.out.println("进入死循环,直到数据被修改");
            while (!initFlag) {
            }
            System.out.println("成功检测到数据被修改,退出循环");
        }).start();
        Thread.sleep(2000);
        new Thread(Main::prepareData).start();
    }

    public static void prepareData() {
        System.out.println("准备修改数据");
        initFlag = true;
        System.out.println("修改数据完成");
    }
}

-Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,/*Main.prepareData

由volatile修饰的共享变量进行写操作的时候多出一条带lock前缀的汇编指令,如下所示:


lock前缀的指令在多核处理器下会:

  1. 将当前处理器缓存行的数据立即写回到系统内存
  2. 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效(缓存一致性协议总线嗅探机制
  3. 提供内存屏障功能,使lock前后的指令不能重排序(即写后加写屏障将高速缓存的值写回到主存中,读前加读屏障使高速缓存的值失效进而从主存中读取)

对应JMM来说就是:

  • 将线程工作内存中的值写回主内存中
  • 通过缓存一致性协议,令其他线程工作内存中的该共享变量值失效
  • 其他线程会重新从主内存中获取最新的值

volatile如何保证有序性

为了性能优化,JVM会在不改变数据依赖性的情况下,允许编译器和处理器对指令序列进行重排序(指令重排),而有序性问题指的就是程序代码执行的顺序与程序员编写程序的顺序不一致,导致程序结果不正确的问题。而加了volatile修饰的共享变量,则通过内存屏障解决了多线程下有序性问题

指令重排:在不影响单线程程序的执行结果的前提下,计算机为了最大限度发挥机器性能,会对指令进行重新排序(重排序也会遵循happens-beforeas-if-serial原则)

内存屏障:内存屏障分为以下4类:


StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。

volatile内存语义的实现

  • 在每个volatile写操作的前面插入一个StoreStore屏障

  • 在每个volatile写操作的后面插入一个StoreLoad屏障

  • 在每个volatile读操作的后面插入一个LoadLoad屏障

  • 在每个volatile读操作的后面插入一个LoadStore屏障

不同CPU对于内存屏障规范实现的指令不一样,以Intel为例:

  • lfence:Load Barrier 读屏障,实现LoadLoad屏障
  • sfence:Store Barrier 写屏障,实现StoreStore屏障
  • mfence:全能屏障,具备lfence和mfence的能力,可以实现所有屏障

JVM底层对内存屏障的近似实现:
通过lock指令。他不是内存屏障,但是他能完成类似内存屏障的功能

volatile解决DCL懒汉式单例的对象半初始化问题

《阿里Java开发手册》为什么这样规定?

public class DCL {
    public static /*volatile*/ DCL instance;

    private DCL() {
    }

    public static DCL getInstance() {
        if (instance == null) {
            synchronized (DCL.class) {
                if (instance == null) {
                    instance = new DCL(); //问题就出在这里
                }
            }
        }
        return instance;
    }
}

一个对象的创建可以分为3步:

  1. 为对象分配内存空间
  2. 调用构造器进行初始化
  3. 将对象的引用指向刚刚分配的内存地址

上面的2、3是有可能被重排序的,因为在单线程中对2、3进行重排序并不会影响最终的结果(intra-thread semantics 保证重排序不会改变单线程内的程序执行结果):


但是在多线程的钱情况下则可能发生问题:

这里A2和A3虽然重排序了,但Java内存模型的intra-thread semantics将确保A2一定会排在A4前面执行。因此,线程A的intra-thread semantics没有改变,但A2和A3的重排序,将导致线程B在B1处判断出instance不为空,线程B接下来将访问instance引用的对象。此时,线程B将会访问到一个还未初始化的对象:


对instance实例加上volatile关键字禁止指令重排以后则不会发生这样的问题了:

相关文章