Java并发编程实战——彻底理解volatile

2023-10-26

volatile作用

  1. 防止重排序。
  2. 实现可见性。
  3. 保证单次操作的原子性。

归根结底就是volatile关键字可以让线程的修改立刻通知其他的线程,从而达到数据一致的作用。

注: i++是两次操作,读和写。
注:在32位的机器上,共享的long和double变量的为什么要用volatile? 因为long和double两种数据类型的操作可分为高32位和低32位两部分,因此普通的long或double类型读/写可能不是原子的。因此,鼓励大家将共享的long和double变量设置为volatile类型,这样能保证任何情况下对long和double的单次读/写操作都具有原子性。 在64位机器上是原子的

volatile实现原理

在学习本文之前我们知道,被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象。

那么volatile是怎样实现的?比如一个很简单的Java代码:

instance = new Instancce() //instance是volatile变量

在生成汇编代码时会在volatile修饰的共享变量进行写操作的时候会多出Lock前缀的指令(具体的大家可以使用一些工具去看一下,这里我就只把结果说出来)。我们想这个Lock指令肯定有神奇的地方,那么Lock前缀的指令在多核处理器下会发现什么事情了?主要有这两个方面的影响:

  • 将当前处理器缓存行的数据写回系统内存;
  • 这个写回内存的操作会使得其他CPU里缓存了该内存地址的数据无效

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

这样针对volatile变量通过这样的机制就使得每个线程都能获得该变量的最新值。

volatile的happens-before关系

在六条happens-before规则中有一条是:volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。下面我们结合具体的代码,我们利用这条规则推导下:

public class VolatileExample {
    private int a = 0;
    private volatile boolean flag = false;
    public void writer(){
        a = 1;          //1
        flag = true;   //2
    }
    public void reader(){
        if(flag){      //3
            int i = a; //4
        }
    }
}

上面的实例代码对应的happens-before关系如下图所示:
在这里插入图片描述

volatile的内存语义

还是以上面的代码为例,假设线程A先执行writer方法,线程B随后执行reader方法,初始时线程的本地内存中flag和a都是初始状态,下图是线程A执行volatile写后的状态图。
在这里插入图片描述
当volatile变量写后,线程中本地内存中共享变量就会置为失效的状态,因此线程B再需要读取从主内存中去读取该变量的最新值。

在这里插入图片描述
从横向来看,线程A和线程B之间进行了一次通信,线程A在写volatile变量时,实际上就像是给B发送了一个消息告诉线程B你现在的值都是旧的了,然后线程B读这个volatile变量时就像是接收了线程A刚刚发送的消息。既然是旧的了,那线程B该怎么办了?自然而然就只能去主内存去取啦。

volatile重排序与JMM内存屏障

我们都知道,为了性能优化,JMM在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序,那如果想阻止重排序要怎么办了?答案是可以添加内存屏障。

内存屏障

JMM内存屏障分为四类见下图,
在这里插入图片描述
内存屏障分类表
java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器制定volatile重排序规则表:
在这里插入图片描述

“NO”表示禁止重排序。为了实现volatile内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM采取了保守策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障;
  • 在每个volatile写操作的后面插入一个StoreLoad屏障;
  • 在每个volatile读操作的后面插入一个LoadLoad屏障;
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

需要注意的是:volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障。这些屏障的作用如下:

  • StoreStore屏障:禁止上面的普通写和下面的volatile写重排序;
  • StoreLoad屏障:防止上面的volatile写与下面可能有的volatile读/写重排序
  • LoadLoad屏障:禁止下面所有的普通读操作和上面的volatile读重排序
  • LoadStore屏障:禁止下面所有的普通写操作和上面的volatile读重排序

通过屏障,volatile 变量不会像锁那样造成线程阻塞,volatile 变量可以提供优于锁的性能优势。

volatile的使用误区

把代码块声明为 synchronized,有两个重要结果,通常是指该代码具有 原子性(atomicity)和 可见性(visibility)

但是,把变量声明成为volatile,变量只具有 synchronized 的可见性特性,但是不具备原子性。

Volatile 变量可用于提供线程安全,但是只能应用于非常有限的一组用例:多个变量之间或者某个变量的当前值与修改后值之间没有约束。因此,单独使用 volatile 还不足以实现计数器、互斥锁或任何具有与多个变量相关的不变式(Invariants)的类。

简而言之,volatile的使用条件如下:

  1. 对变量的写操作不依赖于当前值。
  2. 该变量没有包含在具有其他变量的不变式中。

第一个条件的限制使 volatile 变量不能用作线程安全计数器。虽然增量操作(i++)看上去类似一个单独操作,实际上它是一个由(读取-修改-写入)操作序列组成的组合操作,必须以原子方式执行,而 volatile 不能提供必须的原子特性。

关于第二个条件,我们用实际代码来解释:
下面是一个非线程安全的数值范围类。它包含了一个不变式 —— 下界总是小于或等于上界。

@NotThreadSafe 
public class NumberRange {
    private volatile int lower, upper;
 
    public int getLower() { return lower; }
    public int getUpper() { return upper; }
 
    public void setLower(int value) { 
        if (value > upper) //1
            throw new IllegalArgumentException(...);
        lower = value;//2
    }
 
    public void setUpper(int value) { 
        if (value < lower) //3
            throw new IllegalArgumentException(...);
        upper = value;//4
    }
}

将 lower 和 upper 字段定义为 volatile 类型不能够充分实现类的线程安全;而仍然需要使用同步——使 setLower() 和 setUpper() 操作原子化。

否则,如果凑巧两个线程在同一时间使用不一致的值执行 setLower 和 setUpper 的话,则会使范围处于不一致的状态。例如,如果初始状态是(0, 5),同一时间内,线程 A 调用setLower(4) 并且线程 B 调用setUpper(3),显然这两个操作交叉存入的值是不符合条件的。但是如果A线程执行通过了//1而B线程也通过了//3的语句,那么实际上最后能够通过执行,使得最后的范围值是(4, 3) —— 一个无效值。

这实际上也是(读+写)。

volatile的适用场景


  • 模式 #1:状态标志

也许实现 volatile 变量的规范使用仅仅是使用一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机。

volatile boolean shutdownRequested;
 
...
 
public void shutdown() { 
    shutdownRequested = true; 
}
 
public void doWork() { 
    while (!shutdownRequested) { 
        // do stuff
    }
}

线程1执行doWork()的过程中,可能有另外的线程2调用了shutdown,所以boolean变量必须是volatile。

而如果使用 synchronized 块编写循环要比使用 volatile 状态标志编写麻烦很多。由于 volatile 简化了编码,并且状态标志并不依赖于程序内任何其他状态,因此此处非常适合使用 volatile。

这种类型的状态标记的一个公共特性是:通常只有一种状态转换;shutdownRequested 标志从false 转换为true,然后程序停止


  • 模式 #2:一次性安全发布(one-time safe publication)

在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。

这就是造成著名的**双重检查锁定(double-checked-locking)**问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象。参见:单例模式(以及多线程、无序写入、volatile对单例的影响)

//注意volatile!!!!!!!!!!!!!!!!!  
private volatile static Singleton instace;   
  
public static Singleton getInstance(){   
    //第一次null检查     
    if(instance == null){            
        synchronized(Singleton.class) {    //1     
            //第二次null检查       
            if(instance == null){          //2  
                instance = new Singleton();//3  
            }  
        }           
    }  
    return instance;        

如果不用volatile,则因为内存模型允许所谓的“无序写入”,可能导致失败。——某个线程可能会获得一个未完全初始化的实例。

考察上述代码中的 //3 行。此行代码创建了一个 Singleton 对象并初始化变量 instance 来引用此对象。这行代码的问题是:在Singleton 构造函数体执行之前,变量instance 可能成为非 null 的!
什么?这一说法可能让您始料未及,但事实确实如此。

在解释这个现象如何发生前,请先暂时接受这一事实,我们先来考察一下双重检查锁定是如何被破坏的。假设上述代码执行以下事件序列:

线程 1 进入 getInstance() 方法。
由于 instance 为 null,线程 1 在 //1 处进入synchronized 块。
线程 1 前进到 //3 处,但在构造函数执行之前,使实例成为非null。
线程 1 被线程 2 预占。
线程 2 检查实例是否为 null。因为实例不为 null,线程 2 将instance 引用返回,返回一个构造完整但部分初始化了的Singleton 对象。
线程 2 被线程 1 预占。
线程 1 通过运行 Singleton 对象的构造函数并将引用返回给它,来完成对该对象的初始化。


  • 模式 #3:独立观察(independent observation)

安全使用 volatile 的另一种简单模式是:定期 “发布” 观察结果供程序内部使用。【例如】假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。

使用该模式的另一种应用程序就是收集程序的统计信息。【例】如下代码展示了身份验证机制如何记忆最近一次登录的用户的名字。将反复使用lastUser 引用来发布值,以供程序的其他部分使用。

public class UserManager {
    public volatile String lastUser; //发布的信息
 
    public boolean authenticate(String user, String password) {
        boolean valid = passwordIsValid(user, password);
        if (valid) {
            User u = new User();
            activeUsers.add(u);
            lastUser = user;
        }
        return valid;
    }
} 

  • 模式 #4:“volatile bean” 模式

volatile bean 模式的基本原理是:很多框架为易变数据的持有者(例如 HttpSession)提供了容器,但是放入这些容器中的对象必须是线程安全的。

在 volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 getter 和 setter 方法必须非常普通——即不包含约束!

@ThreadSafe
public class Person {
    private volatile String firstName;
    private volatile String lastName;
    private volatile int age;
 
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public int getAge() { return age; }
 
    public void setFirstName(String firstName) { 
        this.firstName = firstName;
    }
 
    public void setLastName(String lastName) { 
        this.lastName = lastName;
    }
 
    public void setAge(int age) { 
        this.age = age;
    }
}

  • 模式 #5:开销较低的“读-写锁”策略

如果读操作远远超过写操作,您可以结合使用内部锁和 volatile 变量来减少公共代码路径的开销。

如下显示的线程安全的计数器,使用 synchronized 确保增量操作是原子的,并使用 volatile 保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销。

@ThreadSafe
public class CheesyCounter {
    // Employs the cheap read-write lock trick
    // All mutative operations MUST be done with the 'this' lock held
    @GuardedBy("this") private volatile int value;
 
    //读操作,没有synchronized,提高性能
    public int getValue() { 
        return value; 
    } 
 
    //写操作,必须synchronized。因为x++不是原子操作
    public synchronized int increment() {
        return value++;
    }

使用锁进行所有变化的操作,使用 volatile 进行只读操作。
其中,锁一次只允许一个线程访问值,volatile 允许多个线程执行读操作

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

Java并发编程实战——彻底理解volatile 的相关文章

  • MPI测试程序

    include
  • 源码分析【ReentrantLock】原理

    ReentrackLock底层原理 ReentrackLock介绍 非公平锁VS公平 非公平锁 公平锁 可打断VS不可打断 不可打断 默认 可打断模式 锁超时 条件变量 如何在synchronized和ReentrantLock之间进行选择
  • 并发编程系列之线程简介

    前言 前几天我们把Java内存模型介绍了下 大家对JMM也有所认识了 从今天我们就开始走进一个我们天天挂在嘴边 听在耳边的东西 线程 对于线程相信大家都不会陌生 当然也有很多小伙伴在开发中或多或少的使用到线程 即使你没有使用过 但是并不代表
  • Java架构直通车——深入理解B+树

    文章目录 引入 AVL树和B树 AVL树 红黑树 B树 B 树 数据库为什么不使用二叉树 为什么使用B 树 与B树的区别 引入 AVL树和B树 AVL树 平衡二叉搜索树是基于二分法的策略提高数据的查找速度的二叉树的数据结构 平衡二叉搜索树的
  • 线程通信基础示例(synchronized 与 Lock + Condition实现线程通信)

    目录 一 synchronized 实现线程通讯 代码示例 二 Lock Condition 实现线程通讯 代码示例 Lock Condition 实现线程通讯的优点 一 synchronized 实现线程通讯 什么是线程通讯 可以将线程分
  • Callable和Future原理解析

    首先进行分析前 我们需要了解到的概念 Callable是一个接口 是用于创建线程执行里面有一个call方法 用于线程执行的内容 由业务自己定义 Future也是一个接口 可以异步的通过get方法获取到call返回的内容 比较常见的使用场景
  • C++ std::thread多线程详解

    c 多线程详解 一 std thread线程创建 1 函数指针 2 Lambda函数 3 functor Funciton Object 4 非静态成员函数 5 静态成员函数 二 std thread线程停止 1 join函数 2 deta
  • java如何正常关闭一个线程

    如何关闭一个线程 调用stop方法 该方法存在一个问题 JDK官方不推荐使用 该方法在关闭线程时可能不会释放掉monitor的锁 所以建议不要使用该方法结束线程 正常关闭 2 1 线程正常结束生命周期 线程运行结束 完成自己的使命之后 就会
  • Callable接口详解

    Callable接口详解 Callable 返回结果并且可能抛出异常的任务 优点 可以获得任务执行返回值 通过与Future的结合 可以实现利用Future来跟踪异步计算的结果 Runnable和Callable的区别 1 Callable
  • C/C++基于线程的并发编程(二):线程安全和线程锁

    线程安全 所谓线程安全不是指线程的安全 而是指内存的安全 线程是由进程所承载 所有线程均可访问进程的上下文 意味着所有线程均可访问在进程中的内存空间 这也是线程之间造成问题的潜在原因 当多个线程读取同一片内存空间 变量 对象等 时 不会引起
  • Java并发编程-第二章

    以下内容来自 Java并发编程 书籍第二章 补充 1 volatile的有序性 volatile通过内存屏障实现禁止指令重排序保证有序性 硬件层面的内存屏障分为Load Barrier 和 Store Barrier即读屏障和写屏障 2 同
  • Java中数字的应用

    Java中数字的应用 在java中经常会遇到比较大的数 甚至超过了long型 那么该如何处理这些 大数据 呢 在java中有两个类BigInteger和BigDecimal分别表示大整数类和大浮点数类 从原则上是可以表示 天文单位 一样大的
  • 深入浅出 Java Concurrency (J.U.C)

    深入浅出 Java Concurrency J U C 转载 1 http www blogjava net xylz archive 2010 06 30 324915 html http www blogjava net xylz ar
  • Lock锁

    Lock实现提供比使用synchronized方法和语句可以获得的更广泛的锁定操作 它们允许更灵活的结构化 可能具有完全不同的属性 并且可以支持多个相关联的对象Condition 1 传统的synchronized package cn d
  • 并发编程系列之CountDownLatch对战Cyclicbarrier

    前言 前面我们介绍了并发容器和队列 今天我们来介绍几个非常有用的并发工具类 今天主要讲CountDownLatch和Cyclicbarrier这两个工具类 通过讲解并对比两个类的区别 OK 让我们开始今天的并发之旅吧 什么是CountDow
  • 进程、线程、管程、纤程、协程概念以及区别

    进程 进程是指在操作系统中能独立运行并作为资源分配的基本单位 由一组机器指令 数据和堆栈等组成的能独立运行的活动实体 进程在运行是需要一定的资源 如CPU 存储空间和I O设备等 进程是资源分配的基本单位 进程的调度涉及到的内容比较多 存储
  • 面试准备:MySQL建立索引的原则

    文章目录 建立索引 1 和in可以乱序 2 最左前缀匹配原则 3 尽量选择区分度高的列作为索引 4 索引列不能参与计算 5 尽量的扩展索引 不要新建索引 6 为经常需要排序 分组和联合操作的字段建立索引 7 为常作为查询条件的字段建立索引
  • JUC编程

    1 JUC JUC就是java util concurrent工具包的简称 这是一个处理线程的工具包 JDK 1 5开始出现的 1 传统的synchronized public class Synchronized public stati
  • 多线程实现事务回滚

    多线程实现事务回滚 特别说明CountDownLatch CountDownLatch的用法 CountDownLatch num 简单说明 主线程 mainThreadLatch await 和mainThreadLatch countD
  • brpc源码解析(十七)—— bthread上的类futex同步组件butex详解

    文章目录 一 futex简介 二 butex源码解析 2 1 butex相关数据结构 2 2 butex主要机制 2 2 1 butex wait 2 2 2 butex wake 我们知道在linux 下 锁和其他一些同步机制都会用到fu

随机推荐

  • 区块链应用的开发

    经过前面两篇文章 适合小白 区块链之我用可视化的方式部署Webase 区块链之我用可视化Webase开发智能合约 的洗礼 相信大家都对区块链这块多少有点了解了 在本章节小编将带大家演示一下区块链应用的开发 首先需要导出刚才编译部署的智能合约
  • 毕设(一):正则化极限学习机(RELM)、在线学习的极限学习机(OS-ELM)、带遗忘机制的在线学习极限学习机(FOS-ELM)

    前言 终于要毕业了 毕业设计也做完了 我的毕设是 极限学习机和强化学习在单一资产交易中的应用 本质上用以极限学习机为值函数逼近器的一类强化学习算法去对一个资产进行交易 既然毕设也做完了 大学生涯也要结束了 那在去工作之前将毕设的东西好好总结
  • 线性回归(Linear Regression)

    线性回归 Linear Regression 一 假设函数 h x
  • Linux 强行终止

    kill 9 pid pid是进程号 9 代表的是数字 INT 2 这个就是你在bash下面用Ctrl C 来结束一个程序时 bash会向进程发送这个信号 默认的 进程收到这个程序会结束 你可以用 kill INT pid 来发这个信号 Q
  • ORA-28547 连接服务器失败

    1 找到Oracle安装路径 找到Oracle安装路径 app product 11 2 0 dbhome 1 NETWORK ADMIN listener ora 2 在listener ora文件中找到 PROGRAM extproc
  • OpenAI使用条款、使用策略和支持的地区汇总:必读指南,避免OpenAI API被封禁

    最近 一些群友反馈他们的OpenAI API被限制 其中包括试用金用户以及绑定了信用卡的用户 当他们调用API时 会收到以下报错信息 Your access was terminated due to violation of our po
  • 第一章:认识Scratch 第一课 什么是编程,什么是计算机语言?

    程序员的高薪已经成为一个公开的秘密 北上广的一个普通的刚毕业的程序员 怎么说也要万元的起薪 工作几年之后 说起来月薪都是几万 那些高级的资深程序员甚至于达到了年薪百万的待遇 程序员的工作就是编程 那么到底什么是编程呢 关注公众号 少儿编程S
  • python3.6安装包下载_下载 - CPython v3.8.5 官方安装包,离线安装程序,绿色便携版

    CPython v3 8 5 官方安装包 for Digitser 基于 C 语言的 Python 实现 系统 Microsoft Windows Vista 7 8 10 x86 amd64 CPython2 7 原定于 2020 年 0
  • android cmd命令行删除文件夹,文件

    android cmd命令行删除文件夹 文件 adb root adb remount adb shell su cd system sd data 进入系统内指定文件夹 ls 列表显示当前文件夹内容 rm r xxx 删除名字为xxx的文
  • Angular Tracy 小笔记 数据绑定,指令

    数据绑定 数据绑定的本质 就是我们的通讯操作 左边的业务逻辑 ts 想传递数据给模板显示 html 可以通过 插件表达式 data 属性绑定 property data 插值表达式 data 变量调用 html 里写 p tracyName
  • hyper-v克隆win10虚拟机后无法联网的解决方案

    克隆的虚拟机mac地址是不变的 所以要修改mac地址才行 现在有个更简单的办法 就是直接删除网络适配器 然后重新添加一个网络适配器即可 第一步 先删除原来的网卡 第二步 添加新的网卡 然后确定保存 立即生效
  • vue3.2结合element-plus实现一个全局分页组件

    最近开始学习vue3 0的api语法 通使用vue3 0 element plus搭建一个模板 把常用的组件封装一下 常用的分页组件 通过封装之后 粘贴复制 开箱即用 首先安装vue3 2版本和element plus 分页组件
  • Python-OpenCv-答题卡识别

    前言 用OpenCv进行答题卡的扫描获取信息 其中用到平滑处理 边缘检测 透视变换 坐标点处理 一 轮廓检测 import cv2 import numpy as np def cv show name img cv2 imshow nam
  • 在linux-CentOS7.9中搭建DHCP服务器

    目录 dhcp协议 dhcp分配的过程 在linux系统里搭建一个dhcp服务 给其他机器分配ip地址 具体步骤 1 安装dhcp相关的软件包 2 拷贝样例文件到 etc dhcp目录下 3 编辑配置文件 4 启动dhcp服务器 5 查看d
  • 深入了解golang 的channel

    文章目录 1 channel 是什么 channel的特点 2 channel 的数据结构 hchan 等待队列和发送队列的类型包装 sudog 3 channel 分类 有缓冲channel 无缓冲channel 4 channel 的创
  • STOCHRSI 指标理解

    STOCHRSI 指标理解 这几天帮一个朋友解决一个关于指标的问题 这个指标就是 STOCHRSI 在网上查了很多资料 中文的真是甚少 而且仅有的也不是讲的很清楚 对于我这样的 交易小白 简直是天书 不过只要研究多少会有点收获的 下面分享下
  • 7月7日下午!GLM大模型技术前沿与应用探索

    点击蓝字 关注我们 AI TIME欢迎每一位AI爱好者的加入 随着AIGC时代的到来 大型语言模型逐渐成为学术界和工业界的关注焦点 近期 各种大语言模型的涌现给自然语言处理领域的研究带来了诸多挑战 也逐渐对计算机视觉和计算机生物等领域产生了
  • 解析CAN的J1939协议PDU报文

    PF用来确定PDU格式 0 239表示PDU1格式 240 255表示格式2 PDU1格式报文表示向特定或全局地址发送 PDU2格式报文表示向全局地址发送 PS由PF决定其含义 DA表示报文要发送的目标地址 GE表示PS在PDU2中与PF的
  • 面试官问你为什么选择做客服_在线客户服务-您的选择

    面试官问你为什么选择做客服 On the Web news travels fast and a good customer testimonial is worth its weight in gold If a client feels
  • Java并发编程实战——彻底理解volatile

    文章目录 volatile作用 volatile实现原理 volatile的happens before关系 volatile的内存语义 volatile重排序与JMM内存屏障 volatile的使用误区 volatile的适用场景 vol