volatile和重排序的探讨(一)
Volatile探讨之重排序
1.1.1 重排序问题引入
什么是内存屏障?内存屏障主要解决了哪些问题?这些问题解释起来比较复杂。下面我们来解释,先看一个示例:
示例-1
首先来验证一下代码是否是是循序执行的
package com.gxw.first.code.volite;
/**
* 验证代码是否顺序执行
*
*/
public class ReSort {
private static int a = 0, b = 0;
private static int x = 0, y = 0;
// private volatile static int a = 0, b = 0; // 1-1注释
// private volatile static int x = 0, y = 0; // 1-2注释
public static void main(String [] args) throws InterruptedException {
long start=System.currentTimeMillis();
int i = 0;
System.out.println("Process action !");
for (;;){
i++;
a = 0; b = 0; x = 0; y = 0;
Thread t1 = new Thread(
() -> {
a = 1;
x = b;
}
);
Thread t2 = new Thread(
() -> {
b = 1;
y = a;
}
);
t1.start();t2.start();
t1.join();t2.join();
/**
* 在顺序执行的情况下如下如下代码永远不可能打印
*/
if (x == 0 && y == 0) {
System.out.println("X=0 , Y=0 执行了 " + i + "次");
break;
}else {
//空
}
}
System.out.println("执行了"+(System.currentTimeMillis()-start)/1000+"秒");
}
}
结论:
代码不是顺序执行的
描述:
假设代码总是顺序执行的,所以上示例中,对于任意的t1,t2的随机中断,都不存在x=0的同时y=0
(在顺序执行的情况下,x=0时a=1是必然的,同理y=0时b=1是必然的,即使在没有线程安全的情况下)
执行结果:
>Process action !
>X=0 , Y=0 执行了 40937次
>执行了14秒
>
>Process finished with exit code 0
1.1.1.2 volatile修饰下的执行结果
我们打开代码中1-1和1-2的注释
看一下执行结果
>Process action !
>程序一直不退出
结论:
代码在无volatile修饰的情况下并非是顺序执行的!
代码在无volatile修饰的情况下并非是顺序执行的
代码在无volatile修饰的情况下并非是顺序执行的
重要的事情多说几遍
这个结论是不是听起来很刺激?代码不是顺序执行的?难道之前写的代码能正常跑是闹鬼了?
1.1.2 重排序cpu层的探讨
首先我们要破除封建迷信,这里引入一个概念
as-if-serial:看起来是顺序执行的(也叫最终一致性)
1.1.2.1 问提在哪?
计算机组成原理中写过:
cpu组成原理的教科书中写过,cpu组成的三个核心部分
1、寄存器(IR)
2、运算器(ALU)
3、控制器(Control)
寄存器也算控制器的一部分
cpu的三个单元
1、缓存单元(cache)
2、运算单元(ALU)
3、控制单元(Control)
Instruction Register:指令寄存器
ALU:寄存器基础运算单元,负责执行命令
Registry:寄存器里面装的是从内存读到的指令数据
PC:指令的指针
1、寄存器负责从各个线程中读取要执行的指令
2、读取指令后,寄存器讲把指令交给运算器执行
3、由程序计数器来记录执行到了哪个指令
4、在发生线程切换的时候,其他的控制单元会把pc和寄存器中的内容扔进Cache区,以便线程唤醒的时候重新加载(这个过程也叫上下文切换)
其中运算器(ALU)的执行是非常快的、远远大于寄存器和控制器
1.1.2.2 硬件层解决方案-超线程
为了解决ALU执行速度远远大于寄存器和控制器的问题,各个cpu厂家也是绞尽脑汁。提出了线程撕裂者,超线程之类的技术。让现代的cpu一个核心可以支持多个线程的同时执行。其实原理很简单。
我想看到上图大家就理解了,硬件层的解决方法相当暴力,既然寄存器和运算器的执行速度不匹配,那么就多加几个寄存器来解决问题。
1.1.2.3 软件层解决方案-重排序
重排序
通俗的讲:
由于cpu的运算单元执行时很快,而寄存器从内存中读取指令速度是很慢。所以在这个时候如果声明了很多变量,即从内存中读取很多值到寄存器中,会造成运算单元的空闲浪费了很多算力。
我们先看一下如下代码
int i=0; //定义一个变量i
int j=12345; //定义一个变量j
i++; //执行i的自增
这个时候为了提升计算单元的利用率,所以寄存器会选择异步执行从内存中读取数据的操作。
1、异步执行了从内存中读取数据到寄存器的过程
2、查询后面有没有可以利用计算单元(ALU)的代码
3、如果有则判断执行改代码是否影响最终一致性原则(as-if-serial)。
4、不影响则执行运算,影响则等待回调
如上代码:在单线程的情况下,i++和j=12345两句交换了顺序也并不影响代码的最终结果。分析如下步骤
寄存器执行j=12345的时候,从内存取值,造成运算单元空闲。
1、寄存器异步取出 12345
2、为了提升运算单元的利用率,判断后面并没有对 变量j 的运算(即运行i++不会影响程序的最终结果)
3、把i++交给运算器(ALU)执行
4、运算单元由于执行比较快,所以i++先执行完毕。
5、此时变量j从内存中加载j=12345成功
6、所以宏观上就就造成的i++先执行,j=12345后执行的策略
这种过程称为重排序
总结重排序的原因:
1、重排序的原则是 as-if-serial 即不影响最终一致性
2、重排序的原因是
在cpu运行过程中 运算远比从内存读取值得速度要快的多。
到这里是不是有点明白程序为什么没有顺序执行。
怎么防止这种情况发生呢?
1.1.3内存屏障解释
为了可以人工干预重排序的发生,所以各个cpu都提供了内存屏障的功能
1.1.3.1 怎么防止重排序
我想到这里各位就明白内存屏障是个啥东西了
简单来说,就是为了防止重排序,所以在变量使用的前后加了个墙,防止其他指令插队。
大致来说内存屏障有这么几种
1.1.3.2 内存屏障的种类
内存屏障 | 伪代码 | 说明 |
---|---|---|
LoadLoad Barrier | Load; barrier; load | 在A指令执行load的时候,B指令的load不能插队 |
StoreStore Barrier | store ;barrier; store | 在A指令执行写的时候,B指令的写操作不能插队。刷新缓存 |
LoadStore Barrier | load; barrier;store | 在A指令执行读的时候,B指令的写不能插队 |
StoreLoad Barrier | store:load;barrier | 在A指令执行写的时候,B指令的读不能插队。刷新缓存 |
volatitle写操作加的内存屏障
------store-store-barrier--------
volatile-写
------store-Load-barrier--------
注释:为了保证不受重排序的影响所有的写屏障被触发后都当前cpu都会把写后的值,回写到内存中,同时给其他cpu发出一个该变量的值已经改变的信令。
volatitle读操作加的内存屏障
----load-load-barrier----
volatitle-读
----load-store-barrier—
由此volatitle中的两个问题都已经可以充分的解释了
1、volatile的可见性,是由于写屏障触发了缓存的刷新。
2、volatile修饰的变量,可以用来防止重排序的发生。
后续会更行内存屏障唤醒其他cpu刷新缓存的过程,以及volatitle刷新内存带来的问题,和一个juc包中解决这个问题的经典案例(内存对齐)