Java 多线程 深入理解volatile语义

1、解决可见性问题

CPU为了避免频繁读内存导致的性能降低,所以CPU内部设计了寄存器和高速缓存来提供数据访问速度。

1、线程重复读取一个变量时,会使用缓存中的值,而不会读内存,所以存在读提前。

2、线程首次从内存读取某个变量的同时会缓存附近的数据,所以存在读提前。

3、线程写变量时,会先写入CPU缓存,然后异步刷新到内存,所以存在写延迟。

因为读提前,所以当线程读取某个变量时,可能并不是从内存中读取,而是来自CPU缓存,那么不同的线程中见到的变量可能因为加载到缓存中的时间不同而不同;

因为写延迟,多线程中先写的不一定能被后读的线程见到,导致读到的仍然是旧值。

(线程切换和可见性没关系,因为线程切换前保存上下文,重新执行前会加载d,所以单线程在多核CPU上也不会有可见性问题)。

为解决可见性问题,java从1.0便提供了volatile关键字,对于volatile变量禁用CPU缓存,这样线程每次读写volatile变量时都会直接读写内存中的值。

2、解决重排序问题

重排序举例

我们编写的代码和CPU实际运行的指令的顺序可能是不同的,这是因为编译器会重排序来提高性能,默认情况下编译器保证重排序之后对於单线程无影响,但是不保证多线程情况下不出错,比如以下程序:

一个线程依次更新a b c 三个变量,而另一个线程检测3个变量是否按顺序设置的

public static void main(String[] args) {
    	//重复多次,因为很可能不会重排序又或者重排序未被发现
        for (int i = 0; i < 100_000; i++) {
            final State state = new State();
            // Write values
            new Thread(() -> {
                state.a = 1; //step1
                state.b = 1; //step2
                state.c = 1; //step3
            }).start();
            // Read values
            new Thread(() -> {
                //倒序读,降低因读的先后顺序影响测试结果的概率,当然这里也有可能重排序
                //但是无论是写语句重排序还是读操作重排序,只要打印ERROR都可验证重排序
                int tmpC = state.c; //step4
                int tmpB = state.b; //step5
                int tmpA = state.a; //step6
                if (tmpB == 1 && tmpA == 0) {
                    System.out.println("ERROR!! b == 1 && a == 0");
                    System.exit(-1);
                }
                if (tmpC == 1 && tmpB == 0) {
                    System.out.println("ERROR!! c == 1 && b == 0");
                    System.exit(-1);
                }
                if (tmpC == 1 && tmpA == 0) {
                    System.out.println("ERROR!! c == 1 && a == 0");
                    System.exit(-1);
                }
            }).start();
        }
        System.out.println("Done");
    }

    static class State {
        int a = 0;
        int b = 0;
        int c = 0;
    }
重复执行100次

#!/bin/bash

$(cat /dev/null > stdout.log)

for ((i=1; i<=100; i++))
do
  echo "exec no $i"
  java Test >> stdout.log
done
k8snode12@/tmp/test>cat /proc/cpuinfo | grep 'physical id'| wc -l
16

在16核Linux操作系统中执行该程序100次,根据stdout统计结果如下:

测试结果 出现次数 含义
全部执行完成,打印Done 32 未发现重排序
打印 ERROR!! b == 1 && a == 0 0 可能step2先于step1
打印 ERROR!! c == 1 && b == 0 68 可能step3先于step2
打印 ERROR!! c == 1 && a == 0 0 可能step3先于step1

说明一定d发生了重排序,在java1.5以前即使给变量c增加volatile标记,仍可能发生类似的重排序,但是1.5及其之后便不会有该问题,给State类的变量c增加volatile标记后,基于java1.8执行结果如下:

测试结果 出现次数 含义
全部执行完成,打印Done 91 未发现重排序
打印 ERROR!! b == 1 && a == 0 9 可能step2先于step1
打印 ERROR!! c == 1 && b == 0 0 可能step3先于step2
打印 ERROR!! c == 1 && a == 0 0 可能step3先于step1

通过以上测试说明volatile标记成功禁止了step2和step3重排序,但是没有禁止step1和step2的重排序,继续给State类的变量b增加volatile标记后测试结果如下:

测试结果 出现次数 含义
全部执行完成,打印Done 100 未发现重排序
打印 ERROR!! b == 1 && a == 0 0 可能step2先于step1
打印 ERROR!! c == 1 && b == 0 0 可能step3先于step2
打印 ERROR!! c == 1 && a == 0 0 可能step3先于step1

通过以上测试说明volatile标记成功禁止了step1和step2重排序。

禁止重排序的三条规则

1.前面的任意操作不能重排为volatile写之后执行。
 2.后面的任意操作不能重排为volatile读之前执行。
 3.当第一个操作是volatile写时,第二个操作是volatile读时,不能进行重排序。

(volatile写后面的无数据依赖操作可能提前执行,volatile读前面的无数据依赖操作可能延后执行。)

禁重排序的原因

因为对于共享变量来说,常常在真正执行写指令之前需要一些初始化工作,而且这些初始化指令和写指令之间可能并没有数据依赖关系(如果有,则任意变量都不能被重排序),因此可能发生重排序,导致写指令提前执行了,其他程序因此可能读到错误的值。与此相对的,写操作之后的无数据依赖的操作可提前执行,因为按照程序定义一般不会把初始化操作故意放后边,即使真的放后面了,提前初始化也不会造成错误。

典型场景就是双重检查实现懒汉式单例模式时,未对单例变量声明volatile,代码如下:

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

假设有两个线程 A、B 同时调用 getInstance() 方法,他们会同时发现 instance == null ,于是同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。这看上去一切都很完美,无懈可击,但实际上这个 getInstance() 方法并不完美。问题出在哪里呢?出在 new 操作上,我们以为的 new 操作应该是:

1、分配一块内存 M,内存地址为address;

2、在地址为address的内存上初始化 Singleton 对象;

3、然后address赋值给 instance 变量。

但是实际上优化后的执行路径却是这样的(因为23步之间没有数据依赖性,所以可能被重排序,但是都依赖第1步,因此第1步始终先执行):

1、分配一块内存 M,内存地址为address;

2、然后address赋值给 instance 变量;

3、在地址为address的内存上初始化 Singleton 对象。

优化后会导致什么问题呢?我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上,但是此时instance已经被复制即不等于null了;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以会直接返回 instance,而此时的 instance 是没有初始化过的,如果线程B继续访问 instance 的成员变量就可能触发空指针异常。

volatile禁止重排序采用悲观策略。

禁止重排序的直观作用

在java1.5以前,有如下两个线程:

在这里插入图片描述

假设在step3晚于step4的前提下来分析,虽然tempC一定等于2,但是tempB和tempA都可能等于0,因为step5和step6可能在step4之前执行,step3可能在step2之前执行,即java允许这些重排序行为,换句话说java并不保证:

  • step1 happens before step6
  • step2 happens before step5

为了能够让多线程按指定顺序执行,为了禁止重排序带来的顺序问题,java在1.5对volatile关键字进行了增强,即增加了一条happens before规则:volatile写 happens before volatile读,单独看这条规则其实没啥意义,因为对volitile变量的写对的volatile的读操作必然是可见的,这就是volatile的原始语义,但是java1.5之后这条规则不再是单一规则,而是一条happens before规则了!那么上升到happens before规则有什么作用呢?

  1. happens before 具有传递性!
  2. 单线程中前面的操作happens-before后续的操作。
  3. volatile写 happens before volatile读

因为:

  • step 2 happens before step 3 (规则2)
  • step 3 happens before step 4 (规则3)
  • step 3 happens before step 5 (规则2)

所以根据规则1可知 step 2 happens before step 5 ,同理可得 step 1 happens before step 6

让程序看起来如下图一样按顺序执行:

在这里插入图片描述

happens before规则保证了可见性和有序性,使得多线程可以像单线程一样顺序执行!

禁止重排序实现原理

// 通过插入内存屏障

3、不能解决原子性问题

实际上happens before 规则和原子性也无关,原子性的操作在执行过程中不会发生线程切换,java不保证对volatile的整个操作过程中不发生线程切换,所以volatile无法解决i++的问题,原子性问题只能通过互斥锁或者CAS来解决。

public class Test {
    static volatile int counter = 0;
    public static void main(String[] args) throws Exception {
        List<Thread> threads = new LinkedList<>();
       for (int i = 0; i< 10000;i++){
           Thread thread = new MyThread();
           thread.setName("thread " + i);
           threads.add(thread);
       }
       threads.forEach(t-> t.start());
        System.out.println(counter);
    }
    private static class MyThread extends Thread{
        @Override
        public void run() {
            counter +=1;
        }
    }
}

public class Test {
    static volatile int counter = 0;
    public static void main(String[] args) throws Exception {
        List<Thread> threads = new LinkedList<>();
       for (int i = 0; i< 10000;i++){
           Thread thread = new MyThread();
           thread.setName("thread " + i);
           threads.add(thread);
       }
       threads.forEach(t-> t.start());
        System.out.println(counter);
    }
    private static class MyThread extends Thread{
        @Override
        public void run() {
            counter +=1;
        }
    }
}

public class Test {
    static volatile int counter = 0;
    public static void main(String[] args) throws Exception {
        List<Thread> threads = new LinkedList<>();
       for (int i = 0; i< 10000;i++){
           Thread thread = new MyThread();
           thread.setName("thread " + i);
           threads.add(thread);
       }
       threads.forEach(t-> t.start());
       threads.forEach(t-> {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
       System.out.println(counter);
    }
    private static class MyThread extends Thread{
        @Override
        public void run() {
            counter +=1;
        }
    }
}

4、提炼volatile的本质作用

前文分析了volatile可以解决多线程的可见性和有序性问题,篇幅很长,这里我们高屋建瓴的总结一下volatile的使用场景!

volatile 关键字并不是 Java 语言的特产,古老的 C 语言里也有,它最原始的意义就是禁用 CPU 缓存。

例如,我们声明一个 volatile 变量 volatile int x = 0,它表达的是:告诉编译器,对这个变量的读写,不能使用 CPU 缓存,必须从内存中读取或者写入。这个语义看上去相当明确,但是在实际使用的时候却会带来困惑。

例如下面的示例代码,假设线程 A 执行 writer() 方法,按照 volatile 语义,会把变量 “v=true” 写入内存;假设线程 B 执行 reader() 方法,同样按照 volatile 语义,线程 B 会从内存中读取变量 v,如果线程 B 看到 “v == true” 时,那么线程 B 看到的变量 x 是多少呢?直觉上看,应该是 42,那实际应该是多少呢?这个要看 Java 的版本,如果在低于 1.5 版本上运行,x 可能是 42,也有可能是 0;如果在 1.5 以上的版本上运行,x 就是等于 42。

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;  //step1
    v = true; //step2
  }
  public void reader() {
    if (v == true) { //step3
      // 这里x会是多少呢?
    }
  }
}

分析一下,为什么 1.5 以前的版本会出现 x = 0 的情况呢?

我相信你一定想到了,变量 x 可能被 CPU 缓存而导致可见性问题,另外也可能因为step1和step2被重排序了

这个问题在 1.5 版本已经被圆满解决了。Java 内存模型在 1.5 版本对 volatile 语义进行了增强。怎么增强的呢?答案是新增了一项 Happens-Before 规则。volatile就是解决以上这种不确定性的方法之一,x一定是42

5、Happens-Before规则(只列和volatile有关的)

  • 程序的顺序性规则

    这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。这还是比较容易理解的,比如刚才那段示例代码,按照程序的顺序,第 6 行代码 “x = 42;” Happens-Before 于第 7 行代码 “v = true;”,这就是规则 1 的内容,也比较符合单线程里面的思维:程序前面对某个变量的修改一定是对后续操作可见的。

  • volatile 变量规则

    这条规则是指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。这个就有点费解了,对一个 volatile 变量的写操作相对于后续对这个 volatile 变量的读操作可见,这怎么看都是禁用缓存的意思啊,貌似和 1.5 版本以前的语义没有变化啊?如果单看这个规则,的确是这样,但是如果我们关联一下规则 3,就有点不一样的感觉了。

  • 传递性

    这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。我们将规则 3 的传递性应用到我们的例子中,会发生什么呢?可以看下面这幅图:

在这里插入图片描述
从图中,我们可以看到:

1、“x=42” Happens-Before 写变量 “v=true” ,这是规则 1 的内容;

2、写变量“v=true” Happens-Before 读变量 “v=true”,这是规则 2 的内容 。

再根据这个传递性规则,我们得到结果:“x=42” Happens-Before 读变量“v=true”。这意味着什么呢?

如果线程 B 读到了“v=true”,那么线程 A 设置的“x=42”对线程 B 是可见的。也就是说,线程 B 能看到 “x == 42” ,有没有一种恍然大悟的感觉?这就是 1.5 版本对 volatile 语义的增强.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章