06 volatile关键字如何发挥内存屏障优势的
Diego38 104 1

1. 前言

上节我们学习使用 final 关键字来保证对不可变对象的可见性,但对于可变对象我们如何才能做到多线程修改对其他线程可见呢?

按照传统做法,对该可变对象进行加锁访问,无论是修改和读取,加锁可以保证变量能被正确读取,但锁相对较重,在获取锁的过程中需要线程排队。

我们今天就学习一个轻量级的锁 - volatile,它可以实现锁的部分特性,并且可以借助 volatile 在合适的位置插入内存屏障,保证变量可见性。本节就带大家一起揭开 volatile 的面纱。

2. volatile 的定义与使用

volatile 是 Java 的一个关键字,用于修饰变量,当变量被 volatile 修饰以后,可以保证该变量能被多线程可见。

我们看一段代码:

public class VolatileTest {
    int a = 0;
    boolean flag = false;

    public void write() {
        a = 1;       //1. 赋值为1
        flag = true; //2. 赋值flag. 1和2操作有可能发生指令重排序
    }

    public void read() {
        if (flag) {     //3. 读取flag的值,有可能一直为false
            System.out.println(a);  //4. 读取a,由于1和2会发生重排,有可能输出0。
        }
    }
}

这段代码似曾相识,在讲 final 语义时出现过,只不过构造器方法我们换成了 write。假设 write 方法被线程 A 执行执行完 flag=true,紧接着 read 方法被线程 B 执行,会出现两种可能的执行结果。

  1. flag 读取正常值为 true,操作 1 和 2 未发生执行重排,输出 a 的结果为 1
  2. flag 读取正常值为 true,操作 1 和 2 发生了指令重排,flag=true 先执行,a=1 后执行,这时 4 输出 a 的结果为 0

那我们如何避免这种情况发生呢?有两种方式一种是对 flag 的读取和写入操作进行加锁,另一种相对轻量的方式就是对 flag 进行 volatile 修饰。

代码如下:

public class VolatileTest {
    int a = 0;
    volatile boolean flag = false;

    public void write() {
        a = 1;       //1. 赋值为1
        flag = true; //2. 赋值flag. 由于volatile的作用,1和2操作不会发生指令重排序
    }

    public void read() {
        if (flag) {     //3. 读取flag的值, 这里读到正确的值true
            System.out.println(a);  //4. 读取a,这里只能输出1
        }
    }
}

对 flag 进行 volatile 修饰后,1 和 2 操作不会发生指令重排序,因此 4 中输出的结果是正确的值,这得益于 JMM (Java 内存模型) 对 volatile 遵守的内存语义保证。

对一个 volatile 变量进行写操作时,JVM 会把该线程中所有的共享变量全部从 CPU 缓存刷入内存中。对一个 volatile 变量的读,任意线程总是能看到对这个 volatile 变量最后的写入值。

注意这里是 "所有共享变量"(变量 flag 和变量 a), 在上例子中,刷入内存的不仅仅是共享变量 flag 还包括共享 a 变量,我们看到 flag 是能被正常读取的,同时变量 a 也能被正常读取,那它是怎么做到的呢?我们看下 volatile 的语义实现。

3. volatile 语义实现

volatile 是如何通过插入内存屏障指令来保证重排序的呢?我们从 volatile 变量写和 volatile 变量读这两个操作做一下分析

3.1 volatile 变量写

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

(对内存屏障指令陌生的小伙伴可以回忆上节我们讲的内存屏障指令) image

在本章第二节我们讲过,StoreLoad 内存屏障指令是个全能型指令,它会将当前线程所有 CPU 缓存中的数据都刷到内存中,StoreLoad 之前的任何写操作一定先于 StoreLoad 后续的 任何读写操作。在 VolatileTest 代码块中,内存屏障指令是这样插入的:

  . . .

        a = 1;       //1. 赋值为1
        //插入了StoreStore
        flag = true; //2. a =1 不会在之后执行
        //插入了StoreLoad,后续的读写操作不会在flag=true之前执行

  .. .

3.2 volatile 变量读

我们看一下 volatile变量读插入了哪些内存屏障指令?

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

image volatile 读保证了任何后续的读和写都不会先于 volatile 读之前执行,将 volatile 读和 volatile 写两张图合并起来就能发现,volatile 写之前的写操作一定先于 volatile 读之后的读操作执行,这意味着 volatile 语义不仅能够保证被修饰的变量的安全访问,还能保证 volatile 写之前的所有变量,在 volatile 变量读之后能够被安全的访问。

我们看一个例子来说明

public class VolatileTest2 {
    int a = 0;
    int b = 1;
    volatile boolean flag = false;

    public void write() {
        b ++;        //1. 执行b++
        a = 1;       //2. 赋值为1
        flag = true; //3. 赋值flag, 禁止volatile写之前的所有写操作在volatile写之后执行 由于volatile的语义保证,1和2不会在3之后执行
    }

    public void read() {
        if (flag) {     //4. 读取flag的值,禁止下面的读写操作与当前volatile读重排序
            System.out.println(a);  //5. 读取a,这里只能输出1, 并且不会在4之前执行
        }
        System.out.println(b);//6 读取b, 这里输出正确值2, 并且不会在4之前执行
    }
}

我们只对 flag 做了 volatile 修饰,即使我们没有对 a 和 b 进行修饰,read 输出的 a 和 b 依然是保证可见的。我们对这个例子用图形表示就是

image 这是因为在 volatile 写时紧接着会插入一个 StoreLoad 屏障,StoreLoad 是个全能型的屏障,它可以保证当前线程的所有写 (包括对变量 a 和 b 的写入) 能立即执行并被刷入内存,任意线程都能读到最新的值。

3.3 volatile 与锁的区别

volatile 能保证可见性,那它能替代锁吗?

volatile 能保证变量可见性,是一种轻量级的锁,在执行性能上,volatile 比锁有优势,但它不能像锁一样保证操作的原子性,锁的互斥执行特性可以确保对整个代码块的执行具备原子性。

3.4 volatile 对 64 位操作的原子保证

如果没有 volatile 或加锁访问,多线程对一个共享变量的读可能会导致一个失效值,但这个失效值不是一个随机值,至少是之前某个时机线程修改后的值。但对于 64 位的 double 或 long 会是一个例外,因为 JVM 允许将 64 位操作分解成两个 32 位操作,导致的结果就是线程读取到某个值的高 32 位和另一个值的低 32 位。

long 类型变量在 Java 中占 8 个字节,即 64bit,左起数 32bit 称作高 32 位,右起数 32bit 称作低 32 位

4. 总结

volatile 语义保证了被 volatile 修饰的变量在所线程中的可见性,并且保证 volatile 写之前的写入变量被 volatile 读之后读操作正常读取。基于 volatile 的这种内存语义,volatile 写 - 读被纳入 happens-before 法则 (后续章节我们会介绍)。 image

参考资料

  1. 《Java 并发编程实战》
预览图
评论区

索引目录