《重新学习多线程》-- volatile有序性及其原理

256 阅读5分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第13天,点击查看活动详情

有序性

JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码

static int i;

static int j;

// 在某个线程内执行如下赋值操作

i = ...;

j = ...;

可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。所以,上面代码真正执行时,既可以是

i = ...;

j = ...;

也可以是

j = ...;

i = ...;

这种特性称之为『指令重排』,多线程下『指令重排』会影响正确性。

例子

@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {

    int num = 0;
    boolean ready = false;
    @Actor
    public void actor1(I_Result r) {
        if(ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }

    @Actor
    public void actor2(I_Result r) {
        num = 2;
        ready = true;
    }

}

I_Result 是一个对象,有一个属性 r1 用来保存结果,问,可能的结果有几种?

有同学这么分析

情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1

情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1

情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)

但我告诉你,结果还有可能是 0 😁😁😁,信不信吧!

这种情况下是:线程2 执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行 num = 2

相信很多人已经晕了 😵😵😵

这种现象叫做指令重排,是 JIT 编译器在运行时的一些优化,这个现象需要通过大量测试才能复现:

借助 java 并发压测工具 jcstress wiki.openjdk.java.net/display/Cod…

打包执行

执行结束之后会生成 index.html

image.png 可以发现存在结果为0的情况 禁止指令重排序只需要
volatile boolean ready = false;

volatile底层原理

volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)

  • 对 volatile 变量的写指令后会加入写屏障

  • 对 volatile 变量的读指令前会加入读屏障

1. 如何保证可见性

写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中

public void actor2(I_Result r) {

    num = 2;

    ready = true; // ready 是 volatile 赋值带写屏障

    // 写屏障

}

而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据

public void actor1(I_Result r) {

    // 读屏障
    // ready 是 volatile 读取值带读屏障
    if(ready) {
        r.r1 = num + num;
    } else {
        r.r1 = 1;
    }

}

image.png

2. 如何保证有序性

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
public void actor2(I_Result r) {

    num = 2;

    ready = true; // ready 是 volatile 赋值带写屏障

    // 写屏障

}
  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
Version:0.9 StartHTML:0000000105 EndHTML:0000004005 StartFragment:0000000141 EndFragment:0000003965

public void actor1(I_Result r) {

    // 读屏障

    // ready 是 volatile 读取值带读屏障
    if(ready) {
        r.r1 = num + num;
    } else {
        r.r1 = 1;
    }
}

image.png

还是那句话,不能解决指令交错:

  • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去

  • 而有序性的保证也只是保证了本线程内相关代码不被重排序

image.png

3. double-checked locking 问题

以著名的 double-checked locking 单例模式为例

public final class Singleton {

    private Singleton() { }
    private static Singleton INSTANCE = null;
    public static Singleton getInstance() {
        if(INSTANCE == null) { // t2
        // 首次访问会同步,而之后的使用没有 synchronized
        synchronized(Singleton.class) {
            if (INSTANCE == null) { // t1
                INSTANCE = new Singleton();
            }
            }
        }
    return INSTANCE;
    }
}

以上的实现特点是:

  • 懒惰实例化

  • 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁

  • 有隐含的,但很关键的一点:第一个 if 使用了 INSTANCE 变量,是在同步块之外

但在多线程环境下,上面的代码是有问题的,getInstance 方法对应的字节码为:

image.png

其中

  • 17 表示创建对象,将对象引用入栈 // new Singleton

  • 20 表示复制一份对象引用 // 引用地址

  • 21 表示利用一个对象引用,调用构造方法

  • 24 表示利用一个对象引用,赋值给 static INSTANCE

也许 jvm 会优化为:先执行 24,再执行 21。如果两个线程 t1,t2 按如下时间序列执行:

image.png

关键在于 0: getstatic 这行代码在 monitor 控制之外,它就像之前举例中不守规则的人,可以越过 monitor 读取INSTANCE 变量的值

这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例

对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效

4. double-checked locking 解决

public final class Singleton {

    private Singleton() { }
    private static volatile Singleton INSTANCE = null;
    public static Singleton getInstance() {
        // 实例没创建,才会进入内部的 synchronized代码块
        if(INSTANCE == null) { // t2
        synchronized(Singleton.class) {
            // 也许有其它线程已经创建实例,所以再判断一次
            if (INSTANCE == null) { // t1
                INSTANCE = new Singleton();
            }
            }
        }
    return INSTANCE;
    }
}

字节码看不出效果

image.png

image.png

如上面的注释内容所示,读写 volatile 变量时会加入内存屏障(Memory Barrier(Memory Fence)),保证下面两点:

  • 可见性

    • 写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中

    • 而读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据

  • 有序性

    • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后

    • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

  • 更底层是读写变量时使用 lock 指令来多核 CPU 之间的可见性与有序性

image.png