四:深入理解 volatile 关键字

本文详细解释了volatile关键字在Java中的用法、作用,包括禁止指令重排以保持可见性,以及在内存屏障和happens-before规则中的应用。特别讨论了volatile在状态标记变量和单例模式(双重检查锁)中的重要性。

1、volatile 的用法 & 作用

volatile:变量修饰符,只能用来修饰变量。无法修饰方法及代码块等。

volatile 关键字有两个作用:

  • 可见性
  • 禁止指令重排

2、volatile 与可见性

背景:在这篇文章 二:深入理解 JAVA 内存模型 JMM 中说过:为了提高处理器的执行速度,在处理器和内存之间增加了多级缓存来提升。但是由于引入了多级缓存,就存在缓存数据不一致问题

2.1、实现原理

实现原理:当对 volatile 变量进行写操作的时候,JVM 会向处理器发送一条 lock 前缀的指令,将这个缓存中的变量回写到系统主存中。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议

缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里

所以,如果一个变量被 volatile 所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个 volatile 在并发编程中,其值在多个缓存中是可见的

2.2、happens-before 规则

JSR-333 规范:JDK 5定义的内存模型规范:

在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系

happens-before 定义:

  1. 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  2. 两个操作之间存在 Happens-Before 关系,并不意味着一定要按照 Happens-Before 原则制定的顺序来执行。如果重排序之后的执行结果与按照 Happens-Before 关系来执行的结果一致,那么这种重排序并不非法

happens-before 规则:

  1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  2. 管程锁定规则:一个 unLock 操作先行发生于后面对同一个锁的 lock 操作
  3. volatile 变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  4. 线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每个一个动作
  5. 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  6. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join() 方法结束、 Thread.isAlive() 的返回值手段检测到线程已经终止执行
  7. 对象终结规则:一个对象的初始化完成先行发生于他的 finalize() 方法的开始
  8. 传递性:如果操作 A 先行发生于操作 B,而操作 B 又先行发生于操作 C,则可以得出操作 A 先行发生于操作 C;

3、volatile 与有序性

禁止指令重排:为了提高程序性能,编译器和处理器可能会对输入代码进行优化:他们可以不按照代码中的顺序执行语句。但是,volatile 关键字告诉编译器和处理器,不要对标记为 volatile 的变量进行这样的优化。这样可以确保代码的执行顺序与程序员的期望一致

volatile 通过禁止指令重排来保证有序性。

实现原理:volatile 是通过内存屏障来禁止指令重排的

内存屏障:一类同步屏障指令,是 CPU 或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。

下表描述了和 volatile 有关的指令重排禁止行为:

在这里插入图片描述

  1. 当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。
  2. 当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。
  3. 当第一个操作是 volatile 写,第二个操作是 volatile 读时,不能重排序

具体实现方式:Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序,保证共享变量操作的有序性

4、内存屏障指令

内存屏障指令:写操作的会让线程本地的共享内存变量写完强制刷新到主存。读操作让本地线程变量无效,强制从主内存读取,保证了共享内存变量的可见性

JVM 中提供了四类内存屏障指令:

| 屏障类型 | 指令示例 | 说明 |
|--|--|--|
|  |  | |

JSR-133 定义的相应的内存屏障,在第一步操作(列)和第二步操作(行)之间需要的内存屏障指令如下:

在这里插入图片描述

下面是基于保守策略的 JMM 内存屏障插入策略:

  1. 在每个 volatile 操作的面插入一个 StoreStore 屏障
  2. 在每个 volatile 操作的面插入一个 StoreLoad 屏障
  3. 在每个 volatile 操作的面插入一个 LoadLoad 屏障
  4. 在每个 volatile 操作的面插入一个 LoadStore 屏障

volatile 例子:

在这里插入图片描述

所以,volatile 通过在 volatile 变量的操作前后插入内存屏障的方式,来禁止指令重排,进而保证多线程情况下对共享变量的有序性。

其实,volatile 对于可见性的实现,内存屏障也起着至关重要的作用。因为内存屏障相当于一个数据同步点,他要保证在这个同步点之后的读写操作必须在这个点之前的读写操作都执行完之后才可以执行。并且在遇到内存屏障的时候,缓存数据会和主存进行同步,或者把缓存数据写入主存、或者从主存把数据读取到缓存。

5、volatile 应用场景

使用 volatile 必须满足如下两个条件:

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

volatile 经常用于两个场景:

  • 状态标记变量
  • 单例模式 —— Double Check (双重检查锁)

5.1、状态标记变量

public class MsgHolder {

    private /*volatile*/ boolean shutdown = true;

    public void shutdown() {
        shutdown = false;
    }

    public void doWork() {
        System.out.println("m start");
        while (shutdown) {
        };
        System.out.println("m end");
    }

    public static void main(String[] args) throws InterruptedException {
        MsgHolder msgHolder = new MsgHolder();
        new Thread(() -> msgHolder.doWork(), "threadTest").start();
        TimeUnit.SECONDS.sleep(2);
        msgHolder.shutdown();
    }
}

执行之后,发现 threadTest 线程会一直卡在 while 死循环中,shutdown 的变化,threadTest 线程竟然像看不见一样。放开 shutdown 变量的 volatile 修饰,再次执行,会看到代码如预期那样 threadTest 线程跳出了死循环,shutdown 的变化在线程间可见了。

5.2、单例模式 —— Double Check (双重检查锁)

public class Singleton {

    // volatile:禁止指令重排;保证变量可见性
    private static volatile Singleton instance;

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

    private Singleton() {
    }
}

问题一:

为什么使用 volatile 修饰了 singleton 引用还用 synchronized 锁?

volatile 只保证了共享变量 singleton可见性,但是 singleton = new Singleton(); 这个操作不是原子的,可以分为三步:

  1. 在堆内存申请一块内存空间
  2. 初始化对象(属性赋值)
  3. 将内存空间的地址赋值给引用 singleton

所以,singleton = new Singleton(); 一个由三步操作组成的复合操作。

假如,没有 synchronized 锁:

在这里插入图片描述

多线程环境下 A 线程执行了第一步、第二步之后发生线程切换,B 线程开始执行第一步、第二步、第三步(因为 A 线程 singleton 是还没有赋值的),所以为了保障这三个步骤不可中断,可以使用synchronized 在这段代码块上加锁

问题二:

第一次检查 singleton 为空后为什么内部还进行第二次检查?

在这里插入图片描述

A 线程进行判空检查之后开始执行 synchronized 代码块时发生线程切换(线程切换可能发生在任何时候),B 线程也进行判空检查,B 线程检查 singleton == null 结果为 true,也开始执行 synchronized 代码块,虽然 synchronized 会让二个线程串行执行,如果 synchronized 代码块内部不进行二次判空检查,singleton 可能会初始化二次

问题三:

volatile 除了内存可见性,还有别的作用吗?

volatile 修饰的变量除了可见性,还能防止指令重排序。

在这里插入图片描述

singleton = new Singleton(); 由三步操作组合而成,如果不使用 volatile 修饰,可能发生指令重排序。步骤3 在步骤2 之前执行,singleton 引用的是还没有被初始化的内存空间,此时,singleton 引用是不为 null 的,线程 B 调用单例的方法之后,就直接返回 instance,就会引发未被初始化的错误

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值