【Java】volatile 图解

532 阅读4分钟

这是我参与8月更文挑战的第6天,活动详情查看:8月更文挑战

前言

(1)CPU 缓存模型

CPU 缓存模型,图解:

CPU 缓存.png

只要记住:CPU 从内存中读取数据时会经过 CPU 缓存

(2)举个栗子:来看看 volatile

主内存的数据会被加载到 CPU 本地缓存里去,CPU 后面会读写自己的缓存。

因为 CPU 缓存模型,默认情况下是有问题的,特别是多线程并发运行的时候,导致各个 CPU 本地缓存,跟主内存没有同步。

举个栗子:

  1. 线程0:读取内存中 flag,若读取的值与线程内的值不同,则输出
  2. 线程1:每个 1s 更新内存中的 flag
public class Test {
    static int flag = 0;
    public static void main(String[] args) {
        // 线程0:只读取
        new Thread(() -> {
            int localFlag = flag;
            while(true) {
                if (localFlag != flag) {
                    System.out.println("读取到了修改后的标志位:" + flag);
                    localFlag = flag;
                }
            }
        }).start();
        // 线程1:每隔 1s 写入一次
        new Thread(() -> {
            int localFlag = flag;
            while(true) {
                System.out.println("标志位被修改为了:" + ++localFlag);
                flag = localFlag;
                try {
                    Thread.sleep(1000);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }
}

输出结果如下:

标志位被修改为了:1
读取到了修改后的标志位:1
标志位被修改为了:2
标志位被修改为了:3
标志位被修改为了:4
... ...

那么给 flag 加上 volatile,会怎样?

public class Test {
    static volatile int flag = 0;
    // ... ... 
}

输出结果如下:

标志位被修改为了:1
读取到了修改后的标志位:1
标志位被修改为了:2
读取到了修改后的标志位:2
标志位被修改为了:3
读取到了修改后的标志位:3
标志位被修改为了:4
读取到了修改后的标志位:4
... ...

咦,加上 volatile ,每次都能读到最新的值!!!

(3)MESI 协议:缓存一致性协议

MESI 协议:缓存一致性协议,就没有之前那个多线程并发的问题了。

依靠的机制是:CPU 嗅探机制,使内存标志过期。

那底层是如何实现 MESI 的机制?

  • 使用:lock 前缀指令:内存屏障
  • 读和写都会加一个内存屏障

图解,如下:

volatile指令.png

通过哪些指令?操作如下:

  • read:从主存读取
  • load:将主存读取的值写入工作内存
  • use:从工作内存读取数据来计算
  • assign:将计算好的值重新赋值到工作内存中
  • store:将工作内存数据写入主存
  • write:将 store 过去的变量值赋值给主存中的变量

(4)图解 Java 内存模型

Java 内存模型跟 CPU 内存模型类似。

只不过 Java 内存模型是标准化,屏蔽了底层不同计算机的区别。

Java 内存模型,图解如下:

flag案例.png

(5)原子性、可见性、有序性

并发三大问题:原子性、可见性、有序性。

那么就从这三个问题,来看看 volatile

1)原子性:volatile 不行

volatile 不适合运用于需要保证原子性的场景

比如更新的时候需要依赖原来的值,而最典型的就是 a++ 的场景,仅靠 volatile 是不能保证 a++ 的线程安全的。

举个栗子,a++

// 两个线程,自增加 200000
public class DontVolatile implements Runnable {
    volatile int a;
    AtomicInteger realA = new AtomicInteger();
    public static void main(String[] args) throws InterruptedException {
        Runnable r =  new DontVolatile();
        Thread thread1 = new Thread(r);
        Thread thread2 = new Thread(r);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(((DontVolatile) r).a);
        System.out.println(((DontVolatile) r).realA.get());
    }
    @Override
    public void run() {
        for (int i = 0; i < 100000; i++) {
            a++;
            realA.incrementAndGet();
        }
    }
}

运行结果,如下:

187166
200000

可以看出,即便变量 a 被 volatile 修饰了,即便它最终一共执行了 200000 次的自加操作(这一点可以由原子类的最终值来印证),但是依然有一些自加操作失效了,所以最终它的结果是不到 200000 的,这就证明了 volatile 不能保证原子性。

2)可见性:volatile 可以

参见:举个栗子:来看看 volatile

3)有序性:volatile 可以

volatile 可以阻止指令重排序。

编译器和指令器,有时为了提高代码执行效率,会将指令重排序。

重排序之后,让 flag = true 先执行了,会导致线程2 直接跳过 while 等待,执行某段代码,结果 prepare() 方法还没执行,资源还没准备好,此时就会导致代码逻辑出现异常。

二、volatile 作用

回顾下,volatile 主要两个作用:

  1. 保证可见性

Happens-before 关系中对于 volatile 是这样描述的:对一个 volatile 变量的写操作 happen-before 后面对该变量的读操作。 这就代表了如果变量被 volatile 修饰,那么每次修改之后,接下来在读取这个变量的时候一定能读取到该变量最新的值。

  1. 禁止重排序

先介绍一下 as-if-serial 语义:不管怎么重排序,(单线程)程序的执行结果不会改变。在满足 as-if-serial 语义的前提下,由于编译器或 CPU 的优化,代码的实际执行顺序可能与编写的顺序是不同的,这在单线程的情况下是没问题的,但是一旦引入多线程,这种乱序就可能会导致严重的线程安全问题。用了 volatile 关键字就可以在一定程度上禁止这种重排序。

volatile 使用场景

1)适用场合1:布尔标记位

一个比较典型的场景就是布尔标记位的场景: volatile boolean flag

用来控制多线程运行。

public class YesVolatile1 implements Runnable {
    volatile boolean done = false;
    AtomicInteger realA = new AtomicInteger();
    public static void main(String[] args) throws InterruptedException {
        Runnable r =  new YesVolatile1();
        Thread thread1 = new Thread(r);
        Thread thread2 = new Thread(r);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(((YesVolatile1) r).done);
        System.out.println(((YesVolatile1) r).realA.get());
    }
    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            setDone();
            realA.incrementAndGet();
        }
    }
    private void setDone() {
        done = true;
    }
}

2)适用场合 2:作为触发器

场景:作为触发器,保证其他变量的可见性。

下面是 Brian Goetz 提供的一个经典例子:

Map configOptions;
char[] configText;
volatile boolean initialized = false;
 
. . .
 
// In thread A
 
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
 
. . .
 
// In thread B
 
while (!initialized) 
  sleep();
// use configOptions