下载APP

和我来一起来揭开 volatile 关键字的神秘面纱

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 4 天,点击查看活动详情

引言

提起volatile不少同学在面试时可能被问到过, 很多面试官以volatile为切入点考察求职者对基础知识的掌握程度。那么,这个关键字的作用到底是什么呢?今天我们就来详细分析一下。

volatile关键字

volatile关键字是 JAVA 语言的高级特性,但要弄清楚其工作原理,最好是先弄懂 JAVA内存模型,JAVA内存模型是基础。有想了解的小伙伴可以看下我的上一篇 关于JAVA内存模型的介绍,本文的一些知识点可以在这一篇里找到。

缓存一致性问题

JAVA多线程操作 中,对于某个共享变量,每个线程的 工作内存中都缓存一个该变量的 副本。当一个线程更新其副本时,其它的线程可能没有及时发现,进而产生 缓存一致性问题

我们来跑个代码来瞧一瞧。

int age = 1;

@Test
public void testVolatile() throws InterruptedException {
    new Thread(() -> {
        while (age == 1) {
        }
        System.out.println("new Thread线程打印年龄");
    }).start();
    Thread.sleep(1000);
    age = 2;
    Thread.sleep(1000);
    System.out.println("主线程结束");
}

执行结果只有孤零零的一句话。

主线程结束

这里的共享变量是age,两条线程一条是主线程,一条是new Thread线程,修改共享变量age的值为2,并睡眠一秒后(这里睡眠时间加长也是这个结果),new Thread线程愣是没从while循环里走出来(如果循环结束会打印sout的日志),说明主线程对共享变量的修改其他线程未及时发现。

本文的主角volatile关键字就可以解决这类问题。接下来我们只需将代码加一个volatile关键字。

volatile int age = 1;

其他代码一点没动,看一下执行结果。

new Thread线程打印年龄
主线程结束

根据执行结果我们知道,主线程的修改被另外的线程读取到了。

这正是volatile关键字的 语义之一:保证可见性

保证可见性

可见性 是JAVA内存模型三大特性之一。可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得到这个修改(JAVA内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的)。

内存屏障

内存屏障(Memory Barrier),也叫 内存栅栏(Memory Fence) ,是为了解决处理器、高速缓存、主存出现后,引发的 有序性可见性 问题。

  • 确保一些特定操作的执行顺序,这个往下会讲到。
  • 影响一些数据的可见性。

JAVA内存屏障

Java内存屏障主要有 Load(读屏障) 和 Store(写屏障) 两类。

  • LoadLoad Barriers:像这样的序列:Load1, Loadload, Load2,,确保 Load1 要读取的数据在 Load2 读取操作前读取完毕。
  • StoreStore Barriers:像这样的序列:Store1,StoreStore,Store2,确保 Stroe1 写入的数据在 Store2 之前对其他处理器可见。
  • LoadStore Barriers:像这样的序列:Load,LoadStore,Store,确保 Load 的数据在 Store 写入之前读取完毕。
  • StoreLoad Barriers:像这样的序列:Store,StoreLoad,Load,确保 Store 写入的数据在 Load 之前对其他处理器可见。

volatile关键字就是 在每个写操作前插入 StoreStore屏障,在写操作后插入 StoreLoad屏障,在每个读操作前插入 LoadLoad屏障 ,在读操作后插入 LoadStore屏障 ,这就保证了可见性。

禁止指令重排序

为了使处理器内部的运算单元尽量被充分利用,处理器可能会对输入代码进行 乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序的一致,因此如果存在一个计算任务依赖另一个计算任务的中间结果,那么其顺序性并不能靠代码的顺序来保证。

与处理器的乱序优化类似, JAVA虚拟机的即时编译器中也有指令重排序(Instruction Reorder)优化 。

举一个简单的例子。

int age1 = 1;
int age2 = 2

它的执行顺序可能是这样的。

int age2 = 2
int age1 = 1;

volatile关键字的 第二个语义:禁止指令重排序优化

内存屏障禁止指令重排

上边提到了 JAVA内存屏障 对可见性的影响,还有一个作用就是: 确保一些特定操作的执行顺序

如果插入一条 Memory Barrier 则会告诉编译器和CPU,不管什么指令都不能和这条 Memory Barrier 指令重排序。简单理解 Memory Barrier 前边的指令执行完才能执行 Memory BarrierMemory Barrier 后边的指令要等 Memory Barrier 指令执行完毕才能执行,从而确保了有序性。

先行发生(Hpppens-Before)原则

仔细想想我们在编写代码的时候并没有察觉到指令重排序这个问题,这是为什么呢?

这其实是因为JAVA语言中有一个 先行发生(Hpppens-Before)原则。它是判断数据是否存在竞争,线程是否安全的非常有用的手段。这个原则可以一揽子 解决并发环境下两个操作之间是否可能存在冲突的所有可能,而不需要陷入JAVA定义模型苦涩难懂的定义之中。

同学们,这可真是好兄弟啊!

来来来,我们抓紧来看一下。JAVA内存模型下的一些 先行发生关系

  • 程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序(这里并不是代码的线顺序,因为要考虑循环、分支等结构因素),书写在前面的操作先行发生于书写在后面的操作。
  • 管程锁定规则(Monitor Lock Rule):一个 unlock 操作先行发生于后边对同一个锁的 lock 操作。
  • volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作。
  • 线程启动规则(Thread Start Rule):Thread对象的 start() 方法先行发生与此线程的每一个动作。
  • 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测。
  • 线程中断规则(Thread Interruption Rule):对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
  • 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行)先行发生于它的finalize()方法的开始。
  • 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

原子性

JAVA内存模型的三大特性中的 可见性有序性volatile都实现了,那剩下的 原子性 volatile解决了没?

我们跑一段代码验证一下呢。

volatile int age = 0;

@Test
public void testVolatile() throws InterruptedException {
    new Thread(() -> {
        for (int i = 0; i < 20000; i++) {
            age++;
        }
    }).start();
    new Thread(() -> {
        for (int i = 0; i < 20000; i++) {
            age++;
        }
    }).start();
    // 睡眠一会,让两条new Thread跑完
    Thread.sleep(10000);
    System.out.println("age最终数值是:" + age);
}

我这里执行的结果是。

age最终数值是:39719

我们的预期应该是40000,这里的结果会小于40000(这里可以让循环次数加大,将会更加明显)。看样子 volatile关键字没有解决原子性

因为age++这个操作有三个步骤,读取,自增,写入。

这个问题可以通过synchronized关键字来解决,后期我会出一篇synchronized关键字的文章。

总结

  • volatile关键字保证可见性。
  • volatile关键字禁止指令重排序。
  • volatile关键字不能保证原子性。
  • volatile是通过内存屏障实现的。
  • 先行发生(Hpppens-Before)原则是我们的好兄弟!
在线举报