23:volatile关键字:变量可见性与禁止重排序

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

volatile提供了一种稍弱的同步机制,用来确保线程获取到的变量值总是最新的。

volatile修饰的变量具有两种特性:变量可见性,禁止重排序

1 变量可见

1.1 什么是变量可见性

变量可见性是指变量是对所有线程可见的,volatile变量不会被缓存在寄存器或者对其它处理器不可见的地方,因此当一个线程修改了变量的值,那么新值对于其它线程来说是可以立即获取的。

1.2 为什么会有变量可见性的问题

在计算机的执行过程中,指令由CPU执行,而数据则是存在内存当中。相对于CPU的执行速度,内存中数据的读写速度则慢很多,如果任何数据操作都依赖于内存的读取,CPU的执行速度就会被拖慢。为了应对这种情况,CPU内部会有一个高速缓存区,在程序运行过程中,需要用的数据会从内存中复制一份到CPU的高速缓存区中,CPU执行运算指令时直接从高速缓存区获取数据,执行完毕之后再将高速缓存区的数据刷入到内存中。

单线程执行的话这种方式不会存在问题,但是如果多线程共享一个变量的话就会出现问题: 在这里插入图片描述

如图,假设Thread1与Thread2共享变量i,均执行i=i+1的指令。期望中的结果是执行完毕后i的值为2。但是存在这样一种场景:CPU1和CPU2均从主存中读取i=0的值到自己高速缓存中,CPU1执行Thread1的i=i+1后将得出i=1,然后将i刷入主存中,此时CPU2执行Thread2,因为i=0已存入CPU2的高速缓存中,CPU2直接用高速缓存中的数据执行得出结果依然为i=0+1,依然为1,然后CPU2将i的值刷入主存中。此时可以发现Thread1和Thread2均执行完毕,但是主存中的数据却是1而不是期望中的结果2。

1.3 volatile的变量可见性

而volatile的变量可见性则会保证volatile修饰的变量读写跳过CPU高速缓存这一步,即CPU1执行完毕后会将i的值直接更新到主存中,而CPU2取i的值时则直接从主存中取。如此就解决了上面两个线程串行执行,结果依然不是期望值的问题。

但是这并不意味着volatile修饰后的i就能保证i=i+1的执行同步了。多线程同时对i进行计算依然会存在同步问题,这是因为i=i+1这一计算过程分为三步:

  1. 拿到i的值
  2. i和1执行加算
  3. 将计算出来的值赋值到i中

volatile修饰后变量i只是保证第一步拿到i的值总是最新的,以及第三步i更新后总是能及时更新主存中以供其他线程获取最新值。但是却不能保证两个线程串行执行。因此要想保证i=i+1计算的同步依然需要更重的synchronized来实现。

2 禁止重排序

2.1 什么是重排序

java在运行过程中有两个地方可能用到重排序:一个是编译器编译的时候,一个是处理器运行的时候。

重排序是指编译器或者处理器为了提高处理,会对程序进行优化,最终导致实际的执行顺序与程序的定义顺序不一致。这不意味着重排序是任意无限制的,那样的话程序就乱套了。重排序所实现的优化必须保证不会影响单线程下程序的执行结果,即as-if-serial语义。

如以下程序:

int a = 1;  // step1
int b = 2;	// step2
int c = a + b;	// step3
复制代码

为了遵守as-if-serial语义,编译器或处理器不会对存在数据依赖的操作进行重排序如step3依赖于step1和step2,因为这种排序会会影响执行结果,但是对于不存在数据依赖的操作,编译器或处理器可能对其进行重排序,如step2和step1之前不存在数据依赖关系,因此以上程序的运行顺序可能是step1-step2-step3,也可能是step2-step1-step3。

2.2 为什么要重排序

2.2.1 编译器为什么要重排序

举个例子:张三要做两件事,一件事是水壶接满水并放到炉子上,一件事是把垃圾装到袋子里并丢到楼下垃圾桶里。很显然【接水-烧水-收拾垃圾-丢垃圾】效率会比【接水-收拾垃圾-烧水-丢垃圾】效率更高,因为水壶接完水就在手上,垃圾收拾完也在手上,都是可以直接进行下一步的。但是如果步骤定义的过程中顺序是【接水-收拾垃圾-烧水-丢垃圾】呢,这时为了更高的效率,就会进行重排序,在不影响结果的情况下调整做事顺序。

用代码来体现就是:

int i = 1;  // 接水
int j = 2;	// 收拾垃圾
i = i + 1;	// 烧水
j = j + 1;	// 丢垃圾
复制代码

int i = 1;  // 接水
i = i + 1;	// 烧水
int j = 2;	// 收拾垃圾
j = j + 1;	// 丢垃圾
复制代码

很明显后者的执行效率要比前者高,因为定义的变量在寄存器可以直接操作。但是为什么开发规范或优化策略中并没有这方面的内容呢?就是因为编译重排序的存在,他会在不改变单线程执行结果的前提下选择更优的执行方式。使得程序编写者可以不用花费心思关注这些。

2.2.2 处理器为什么要重排序

处理期间为什么要进行重排序呢?这是因为一个指令的执行会包含到数个步骤,每个步骤可能用到不同的寄存器。而CPU采用的是流水线技术,也就是说CPU具有多个功能单元(如获取、解码、运算和结果),一条指令也分为多个单元。为了充分的利用CPU的资源,前一条指令执行完当前单元后就开始执行下一个需要当前单元参与的指令,而不会等到前一个指令执行完所有单元才开始执行。指令重排序就是为了使具有相似功能单元的指令能够接连执行。

即时编译器的重排序,处理器的乱序执行,以及内存系统的重排序的存在,都是为了减少流水线中断浪费资源。

举例:

add(int x, int y, int z) {
	int r = 0; 	// step1
	r++;		// step2
	int s = 1;	// step3
	int t = 2; 	// step4
}
复制代码

这种情况下CPU并不会等step2完成++任务之后在执行step3,因为step3对step2没有依赖。而是采用流水线形式,流水线技术下装载单元装载r之后交给运算单元执行r++。因为后需s和t的装载不依赖于r,因此依据流水线的规则装载单元不会等运算单元完成r的运算之后才继续装载而是直接装载s和t, 装载s和t的动作可能在运算单元完成r++之后,此时顺序没有改变,但是也可能在运算单元执行完成r++之前,此时就发生了重排序。流水线技术下CPU的执行效率会大大提升。这便是处理器的重排序,指令并不会完全串行执行,step4和step3可能在step2执行完成前就开始执行了。

2.3 重排序在多线程下的问题

上面提到重排序只保证单线程下的程序执行结果。那么重排序在多线程下会有什么问题呢:

2.3.1 编译器下的重排序可能出现的问题

有如下代码:

int i = 0;
boolean flag = false;
public void setMethod() {
	i = 1;			// step1
	flag = true;	// step2
}
public void calcMethod() {
	if(flag) {		// step3
		int j = i;	// step4
	}
}
复制代码

有Thread1执行setMethod,Thread2执行calcMethod,因为重排序只保证单线程执行的结果,而step1和step2不具有依赖关系,那么就可能存在重排序的情况。但是step1(Thread1) - step3(Thread2) - step4(Thread2) - step2(Thread1)计算出的结果j为0,step1(Thread1) - step2(Thread1) - step3(Thread2) - step4(Thread2)计算出来的结果j为1。重排序虽然没有影响Thread1的结果,但是却影响了存在共享数据的Thread2的结果。但是如果我们修饰利用 volatile的禁止重排序使用volatile修饰flag,flag禁止重排序,step1和step2会按照顺序执行,以上问题就迎刃而解了。

2.3.2 处理器下的重排序可能存在的问题

处理器的重排序同样存在问题,以上文2.3.1 编译器下的重排序可能出现的问题中的示例代码为例,再加一步运算如下: 有如下代码:

int i = 0;
boolean flag = false;
public void setMethod() {
	i = 1;			// step1
	i++;			// step2
	flag = true;	// step3
}
public void calcMethod() {
	if(flag) {		// step4
		int j = i;	// step5
	}
}
复制代码

有Thread1执行setMethod,Thread2执行calcMethod,因为重排序只保证单线程执行的结果,而step2和step3不具有依赖关系,那么就可能存在重排序的情况。但是step1(Thread1) - step3(Thread1) - step4(Thread2) - step5(Thread2) - step2(Thread1)计算出的结果j为1,step1(Thread1) - step2(Thread1) - step3(Thread1) - step4(Thread2) - step5(Thread2)计算出来的结果j为2。重排序虽然没有影响Thread1的结果,但是却影响了存在共享数据的Thread2的结果。但是如果我们修饰利用 volatile的禁止重排序使用volatile修饰flag,flag禁止重排序,step1,step2,step3会按照顺序执行,就不会出现因为重排序导致的计算错误的问题了。同样的这也并不意味着多线程执行代码setMethod就不存在同步问题了,原因则在1.3 volatile的变量可见性已经解释过了,一次加算是分多步的,而volatile的禁止重排序特性也同样只能保证计算时使用的是最新值。那么volatile的使用场景是什么呢?

3 volatile的使用

volatile是一种比synchronized更轻量级的同步机制。volatile只能保证可见性。因此仅适用于以下场景:一个变量被多个线程共享,各个线程直接给这个变量赋值。 volatile的使用需要满足以下两个条件:

  1. 对变量的写操作不依赖当前值,也就是说对该变量的写操作仅仅是单纯的赋值。
  2. 该变量没有包含在具有其他变量的不变式中,也就是说其他变量不能依赖于volatile。只有状态真正独立于程序内的其他内容时才能使用volatile。

以上两条限制究其原因还是因为依赖当前值则意味要经历取值和赋值两个步骤,而牵扯到不定式势必要牵扯要运算,而volatile却无法保证原子性。

4 synchronized有禁止指令重排序和变量可见性的功能吗

synchronized是比volatile更重的同步机制,volatile拥有禁止重排序的变量可见性的功能,那么synchronized是不是也具有同样的功能呢?答案是这个问题没有意义。上面提过重排序实现优化的的原则是不影响单线程下的执行结果。synchronized加锁使得并行线程变为串行执行,相当于线程都处于单线程执行环境了,在单线程环境下是否会重排序已经不重要了,都不会影响最终的结果,从而保证线程的安全。可见性方面synchronized在修改了本地内存中的变量后,解锁前会将本地内存修改的内容刷新到主内存中,确保了共享变量的值是最新的,也就保证了可见性。因此若使用了synchronized则共享变量就没有使用volatile的必要了。

PS:
开发成长之旅 [持续更新中...]
上篇导航:22:线程控制器Semaphore - 掘金 (juejin.cn)
下篇导航:24:线程间变量共享 - 掘金 (juejin.cn)
欢迎关注...

参考资料:
为什么要指令重排序?
synchronized有禁止指令重排序的功能吗?
java并发编程的艺术【三】-【二】重排序与顺序一致性
Java并发编程:volatile关键字解析

猜你喜欢

转载自juejin.im/post/7079382632777121806