23: volatile keyword: variable visibility and prohibition of reordering

This article has participated in the "Newcomer Creation Ceremony" event to start the road of gold creation together.

Volatile provides a slightly weaker synchronization mechanism to ensure that the variable value obtained by the thread is always up-to-date.

Variables modified by volatile have two characteristics: variable visibility, and reordering is prohibited

1 variable is visible

1.1 What is variable visibility

Variable visibility means that the variable is visible to all threads, and volatile variables are not cached in registers or invisible to other processors, so when a thread modifies the value of the variable, the new value is visible to other threads. available immediately.

1.2 Why is there a problem with variable visibility

During the execution of the computer, the instructions are executed by the CPU, and the data is stored in the memory. Compared with the execution speed of the CPU, the reading and writing speed of data in the memory is much slower. If any data operation depends on the reading of the memory, the execution speed of the CPU will be slowed down. In order to cope with this situation, there will be a cache area inside the CPU. During the running process of the program, the data to be used will be copied from the memory to the cache area of ​​the CPU. Get the data, and then flush the data in the cache area into the memory after the execution is complete.

There is no problem with single-threaded execution , but problems can arise if multiple threads share a variable :insert image description here

As shown in the figure, it is assumed that Thread1 and Thread2 share the variable i, and both execute i=i+1the instructions. The expected result is that the value of i is 2 after execution. But there is such a scenario: CPU1 and CPU2 both read the value of i=0 from the main memory to their own cache, CPU1 i=i+1will get i=1 after executing Thread1, and then flush i into the main memory, this When CPU2 executes Thread2, because i=0 has been stored in the cache of CPU2, CPU2 directly executes the data in the cache and the result is still i=0+1, still 1, and then CPU2 flushes the value of i into in main memory. At this point, it can be found that both Thread1 and Thread2 are executed, but the data in the main memory is 1 instead of the expected result 2.

1.3 Variable visibility of 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。

The reason for the above two restrictions is that relying on the current value means going through two steps of value acquisition and assignment, and involving infinitives will inevitably involve operations, but volatile cannot guarantee atomicity.

4 Does synchronized have the function of prohibiting instruction reordering and variable visibility

Synchronized is a heavier synchronization mechanism than volatile. Volatile has the function of prohibiting the visibility of reordered variables. Does synchronized also have the same function? The answer is that the question doesn't make sense. As mentioned above, the principle of reordering to achieve optimization is that it does not affect the execution results under a single thread. Synchronized locking makes parallel threads execute serially, which is equivalent to that the threads are all in a single-threaded execution environment. It is not important whether they will be reordered in a single-threaded environment, and will not affect the final result, thus ensuring thread security. . In terms of visibility, after synchronized modifies the variables in the local memory, the modified content of the local memory will be refreshed to the main memory before unlocking, which ensures that the value of the shared variable is up-to-date, which ensures the visibility. Therefore, if synchronized is used, there is no need to use volatile for shared variables.

PS:
The journey of development and growth [continuously updated...]
The first navigation: 22: Thread controller Semaphore - Nuggets (juejin.cn) The
next navigation: 24: Variable sharing between threads - Nuggets (juejin.cn)
Welcome to follow...

Reference:
Why instruction reordering?
Does synchronized have a function to prohibit instruction reordering?
The Art of Java Concurrent Programming [3]-[2] Reordering and Sequential Consistency
Java Concurrent Programming: volatile Keyword Analysis

Guess you like

Origin juejin.im/post/7079382632777121806
Recommended