volatile 关键字原理

1、volatile含义

volatile是一个类型修饰符(type specifier)。volatile的作用是作为指令关键字,确保本条指令不会因编译器的优化而省略,且要求每次直接从主存读值。volatile的本身含义即为易变的、不稳定的,也就是说被它修饰的变量可能会被意想不到地改变。

2、相关知识点

2.1 CPU 缓存

CPU缓存(Cache Memory)位于CPU与内存之间的临时存储器,它的容量比内存小但交换速度快。在缓存中的数据是内存中的一小部分,但这一小部分是短时间内CPU即将访问的,当CPU调用大量数据时,就可避开内存直接从缓存中调用,从而加快读取速度。
在CPU中加入缓存是一种高效的解决方案,这样整个内存储器(缓存+内存)就变成了既有缓存的高速度,又有内存的大容量的存储系统了。缓存对CPU的性能影响很大,主要是因为CPU的数据交换顺序和CPU与缓存间的带宽引起的。
在这里插入图片描述
在这里插入图片描述
以上为CPU Cache模型,缓存分为三级L1/L2/L3,由于指令和数据的行为和热点分布差异很大,因此将L1按照用途划分为L1i(instruction)和L1d(data)。在多核CPU的结构中,L1和L2是CPU私有的,L3则是所有CPU共享的。

2.2 Java内存模型

在这里插入图片描述

  1. 主存中的数据所有线程都可以访问(共享数据)
  2. 每个线程都有自己的工作空间,(本地内存)(私有数据)
  3. 工作空间数据:局部变量、内存的副本
  4. 线程不能直接修改内存中的数据,只能读到工作空间来修改,修改完成后刷新到内存

这就引发了CPU缓存的一致性问题。
解决方案:
1)总线加锁(粒度太大),锁总线的开销比较大,锁总线期间其他 CPU 没法访问内存。
2)缓存一致性协议(MESI)
a. 读操作:不做任何事情,把Cache中的数据读到寄存器
b. 写操作:发出信号通知其他的CPU讲改变量的Cache line置为无效,其他的CPU要访问这个变量的时候,只能从内存中获取。
MESI协议将Cache Line的状态分成以下四种:
  M-modify(修改):当前CPU Cache拥有最新数据(最新的Cache Line),其他CPU拥有失效数据(Cache Line的状态是invalid),虽然当前CPU中的数据和主存是不一致的,但是以当前CPU的数据为准;
  E-exclusive(独占):只有当前CPU Cache中有数据,其他CPU Cache中没有改数据,当前CPU的数据和主存中的数据是一致的;
  S-shared(共享):当前CPU Cache和其他CPU Cache中都有共同数据,并且和主存中的数据一致;
  I-invalid(失效):当前CPU Cache中的数据失效,数据应该从主存中获取,其他CPU Cache中可能有数据也可能无数据,当前CPU Cache中的数据和主存被认为是不一致的,对于invalid而言,在MESI协议中采取的是写失效(Write invalidate)。

2.3 happens-before规则

JSR-133定义了如下happens-before规则:
  1) 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。(通俗的说:单线程中前面的动作发生在后面的动作之前)
  2) 监视器锁规则:对于一个锁的解锁,happens-before于随后对这个锁的加锁。(通俗的说:解锁操作发生在加锁操作之后)
  3) volatile变量规则:对于一个volatile域的写,happens-before于任意后续对这个volatile域的读。(通俗的说:对volatile变量的写发生在读之前)
  4) 传递性规则:如果A happens-before B,且B happens-before C,那么A happens-before C.
  5) start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作返回成功。
  6) join()规则:如果线程A执行操作Thread.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join操作成功返回。

2.4 volatile 的特性

  • 可见性(最重要的特性)。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
  • 有序性。对于存在指令重排序的情况,volatile会禁止部分指令重排序。

3、volatile 的内存语义

为了实现volatile的内存语义,JMM会分别限制这两种类型的重排序。
  当写一个volatile变量时,JMM会立即将本地变量中对应的共享变量值刷新到主内存中。
  当读一个volatile变量时,JMM会将线程本地变量存储的值,置为无效值,线程接下来将从主内存中读取共享变量。
为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。下面是基于保守策略的 JMM 内存屏障插入策略:

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障(禁止前面的写与volatile写重排序)。
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障(禁止volatile写与后面可能有的读和写重排序)。
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障(禁止volatile读与后面的读操作重排序)。
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障(禁止volatile读与后面的写操作重排序)。

上述内存屏障插入策略非常保守,但它可以保证在任意处理平台,任意的程序中都能得到正确的volatile语义。下面是保守策略(为什么说保守呢,因为有些在实际的场景是可省略的)下,volatile 读写操作 插入内存屏障后生成的指令序列示意图:
在这里插入图片描述在这里插入图片描述

4、volatile 的使用场景

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

public class ShutDowsnDemmo extends Thread{
    private volatile boolean started=false;

    @Override
    public void run() {
        while(started){
            dowork();
        }
    }
    public void shutdown(){
        started=false;
    }
}

2)双重检查锁定(double-checked-locking)
单例模式的一种实现方式,但很多人会忽略 volatile 关键字,因为没有该关键字,程序也可以很好的运行,只不过代码的稳定性总不是 100%,说不定在未来的某个时刻,隐藏的 bug 就出来了。

public class Singleton {
    private volatile static Singleton instance;
    public static Singleton getInstance(){
        if(instance==null){
            synchronized (Singleton.class){
                instance=new Singleton();
            }
        }
        return instance;
    }
}

5、volatile与synchronized的区别

(1)使用上的区别
Volatile只能修饰变量;synchronized只能修饰方法和语句块。
(2)对原子性的保证
synchronized可以保证原子性;Volatile不能保证原子性。
(3)对可见性的保证
都可以保证可见性,但实现原理不同。Volatile对变量加了lock;synchronized使用monitorEnter和monitorexit monitor。
(4)对有序性的保证
Volatile能保证有序;synchronized可以保证有序性,但是代价(重量级)并发退化到串行
(5)阻塞
synchronized引起阻塞;Volatile不会引起阻塞。

发布了105 篇原创文章 · 获赞 7 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/qq_43792385/article/details/101198226