线程基础:多任务处理(18)——MESI协议以及带来的问题:volatile关键字

=====================
(接上文《线程基础:多任务处理(18)——MESI协议以及带来的问题:伪共享》)

4、volatile关键字及其使用

4.1、volatile关键字使用场景

volatile关键字有以下几大场景:

  • 用于多线程状态下只需要保证线程间可见性的场景,如果被多线程通知操作的资源需要具有原子性那么还是要使用synchronized或者lock()相关方法来实现。在实际应用中,这样的场景就是多个线程同时共享一个标记且这个标记不会实际参与多线程的业务处理过程——例如上文中的示例。

  • 用于避免指令重排的场景,指令重排的知识点比较复杂。在JVM虚拟机层面上的指令重排可以较简单的看作前者为了提高CPU运行效率,对已经编译好的语句顺序进行重新排序。当然重新排序的前提是,不会影响单线程情况下这些语句的执行结果。

4.2、主存一致性(线程可见性)场景

Java中的锁机制(无论是悲观锁原理还是乐观锁原理)保证了执行过程的原子性,从表现上来说就是共享资源的线程安全性。但有的时候我们并不需要在多个线程间共享的资源具有线程安全性,请看如下代码块:

// ......
private static boolean flag = false;
// ......
public static void mian(String[] args) {
  // ......
  for(int index = 0 ; index < 5 ; index++) {
    Thread MyThread = new Thread(() -> {
      while(true) {
        if(flag) {
          return;
        }
        // do something
      }
    });
    // 这5个线程就在做一些业务,等待flag信息退出
    MyThread.start();
  }
  for(int index = 0 ; index < 5 ; index++) {
    Thread YourThread = new Thread(() ->{
      long startTime = System.currentTimeMillis();
      long currentTime = startTime;
      while(true) {
        if(++currentTime - startTime > 1000000000l ) {
          flag = true;
          return;
        }
        if(flag) {
          return;
        }
      }
    });
    // 这5个线程就一直在运行,各做各的计数,计算次数也不一定相同
    // 只要有一个线程计算得出的条件成立,就变更flag标记
    YourThread.start();
  }
  // ......
} 
// ......

以上代码中的flag变量,只是标记当前进程满足了某种条件,进程中的多个线程可以依据这个条件在第一时间进行退出。从以上代码片段可以看出,这个flag变量实际上并没有参与线程中的任何计算过程——即使有多个线程同时抢占到了flag的操作权,也没有关系,因为flag无非就是一个标记。

但是另一个方面,我们又需要保持flag变量的多线程可见性。试想一下如果某一个线程将flag标记变更为true,但是其它线程在一个非常短的瞬间并没有看到这个flag变量的变化,究其原因是因为线程更改的flag变量信息只停留在CPU的缓存中,还没有被回写到主存(详细原因介绍请参见上文)。这个情况就会导致虽然flag已经被变更,但是某个线程会多做几次循环才会退出。

以上情况显然是我们不希望看见的,这个时候就需要使用volatile关键字,让flag变量的更新被直反应到主存上,而其它线程也直接到主存上读取flag变量的最新值。代码调整如下所示:

// ......这里增加了volatile 
private volatile static boolean flag = false;
// ......
public static void mian(String[] args) {
  // ......
  // 其它代码一致
  // ......
} 
// ......

这时候只要flag变量的值发生变化,所有线程都会立即观察到,并立即在正确的时间点退出循环,不会出现“多循环”几次的情况。

4.3、避免指令重排场景

所谓指令重排是指,CPU和编译器为了提升程序执行的效率,按照一定的规则允许进行指令优化。但是,在某些情况下这种优化会带来一些执行的逻辑问题,主要的原因是代码逻辑之间是存在一定的先后顺序。在并发执行情况下会发生二义性,即按照不同的执行逻辑会得到不同的结果信息。

以上概念放到具体的Java环境下,Java编译器会进行指令重排,但前提是不论怎么重排序,编译器都保证执行结果在单线程运行的情况下不被改变;同时也保证满足as-if-serial语义规范,请看以下代码:

所谓as-if-serial语义规范可以简单理解为,一段将要运行的程序其前后语义的依赖结构不会在指令重排后受到影响,但实际细节更复杂,这里就不再扩展开讲)

// ......
float a = 100;
float b = 1000;
float c = a * b * b;
// ......

以上代码片段中,变量c依赖于变量a和变量b,但是变量a和变量b之间并没有依赖关系。也就是说在单线程运行环境下,无论编译器按照原义将变量a放在变量b之前,还是编译器按照优化后的顺序将变量b的初始化放到变量a之前,其运行结果都不会对变量c的最终值产生异响。但是再多线程情况,就会出现一些意想不到的效果,请看以下简单示例(例子是网上找的一个):

在线程A中:

// ......
context = loadContext();
inited = true;
// ......

在线程B中:

// ......
//根据线程A中对inited变量的修改决定是否使用context变量
while(!inited ){
   sleep(100);
}
doSomethingwithconfig(context);
// ......

如果线程A中发生了指令重排序(有一定几率),那么B中很可能就会拿到一个尚未初始化或尚未初始化完成的context,从而引发程序错误。这时,我们使用volatile关键字对inited变量进行修饰,就可以避免指令重排。

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 技术黑板 设计师:CSDN官方博客 返回首页