并发编程的艺术-线程可见性volatile


并发编程的艺术-线程可见性volatile

简介

在多线程环境下,两个线程对同一变量进行操作时,互相是不可见状态,不可见状态就会导致一个线程修改了这个共享变量,而另一个线程不知道你去修改了,就可能导致变量最终预期与实际不一致的情况。为了确保共享变量能够保证一致性,volatile为此而来,

示例

在简介当中,我们知道了volatile是为了解决不可见性而来,在实际应用中,会带来什么问题呢? 举个简单的示例如下:

public static Boolean TYPE = true;
public static void main(String[] args) throws Exception {
    Thread thread = new Thread(() -> {
        int i = 0;
        while (TYPE) {
            i++;
        }
    });
    thread.start();
    System.out.println("线程已启动");
    Thread.sleep(1000);
    TYPE = false;
}

代码中只创建了个简单的线程,当TYPE为true时,子线程会一直执行++操作,主线程改变TYPE为false,我们预期这个main方法执行后,应该为自动停止。但是实际结果中你会发现,这个代码会一直执行,永远循环下去。导致这个问题的原因就是线程thread并没有发现TYPE已经被主线程改变。那么如何解决问题呢?

  • 方案一 增加本文重点volatile
  • 方案二 活性失败,比如在while循环中加入io操作,例如 System.out.print
  • 方案三 增加JVM启动参数 -Djava.compiler = NONE

方案是有了,那怎么产生的呢?它们又是怎么解决的呢?

原因

编译器重排序

说到原因,不得不说HosSpot虚拟机的两个即时编译器

  • Client Compiler
  • Server Compiler

程序具体使用哪种编译器由JVM虚拟机来决定。

其中,上述代码的问题点就出现与ServerCompiler编译器,它是一个面向服务端并且充分优化的高级编译器,包含不限于:

  • 无用代码消除(Dead Code Elimination)
  • 循环表达式外提(Loop Expression Hoisting)
  • 循环展开(Loop Unrolling)

其中,循环表达式外提就是导致示例代码出现问题的根本原因,经过它编译后的代码变为了

Thread thread = new Thread(() -> {
    int i = 0;
    if (TYPE) {
         while (true) {
        	i++;
    	}
    }
});

由此可以发现,经过它优化后,TYPE进入时为true,后续根本不会变化,所以其他线程对TYPE变更时,示例线程根本不会知道,所以我们可以采用方案三进行编译优化禁用,但是为了这一个小细节去影响整个全局的优化,代价是否有点大呢?

CPU高速缓存

除编译器优化外,CPU的高速缓存机制也会导致可见性问题,但是在CPU层面又有总线锁,缓存锁的机制去解决这类问题,总线锁和缓存锁通过Lock信号触发,如果当前CPU支持缓存锁,则不会在总线上声明Lock信号,而是基于缓存一致性协议来保证缓存的一致性。如果CPU不支持缓存锁,则会在总线上声明Lock信号锁定总线,从而保证同一时刻只允许一个CPU对共享内存的读写操作。volatile就是通过此方式来解决可见性问题。

CPU指令重排序

指令重排序,通俗来讲就是CPU在保证单线程情况下,重排序之后的运行结果与程序本身预期结果一致的前提下,进行重排序的一种机制。例如下面这篇示例:

public static void main(String[] args) throws InterruptedException {
    int i = 5;
    int y = 13;
    int t = i + y;
}

上述代码我们可以知道,代码会从上到下依次执行, 但是i与y的执行顺序对于最终t的结果毫无影响,这时候就有可能发生指令重排序变为:

int y = 13;
int i = 5;
int t = i + y;

Volatile

我们在TYPE加入volatile关键字

public static volatile Boolean TYPE = true;

我们通过汇编指令启动main方法,会得到下面的结果:

0x00000207c9066087: lock add dword ptr [rsp],0h  ;*putstatic TYPE
                                                ; - com.timeroar.blog.concurrent.voliat.VloliateThreadTest::<clinit>@4 (line 10)

大家发现没,主线程去修改TYPE变量的值时,在修改命令前面会增加一个Lock信号,原因是volatile关键字会在JVM层面声明一个C++的volatile,得到这个声明后JVM会调用storeload()内存屏障方法,此方法会执行lock指令,将volatile声明的变量在CPU层面从Store Buffers中刷新到缓存行,这样当其他线程再去读取``volatile`声明的变量的值时,会从内存中或者其他缓存了此变量的缓存行中重新
加载,使得线程能够获得此变量最新的值。

Volatile的重排序规则

volatile并不会在所有情况都限制重排序规则,我们用一张表来解释Volatile什么时候允许重排序,什么时候不允许

第一个操作\第二个操作 普通读 普通写 Volatile读 Volatile写
普通读 X
普通写 X
Volatile读 X X X X
Volatile写 X X

由图中展示,其实我们只需要记住三点即可:

  • 当第一个操作是volatile写时,且第二个操作是volatile读或写时,这两个操作不允许重排序。
  • 当第二个操作是volatile写时,不管第一个操作的读/写是普通变量还是volatile修饰的变量,都不允许这两个操作重排序。
  • 当第一个操作是volatile读时,不管第二个操作的读/写是普通变量还是volatile修饰的变量,都不允许这两个操作重排序。

文章作者: TimeRoar
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 TimeRoar !
评论
  目录