前置知识
缓存一致
在程序的执行过程中,指令都是由CPU进行执行的.执行的过程中需要读取指令以及对部分变量进行读写.而内存相对于CPU来说虽然能够存储的内容多,但是寻址太慢.所以设计处理器的厂商会在CPU和内存之间设计一个缓存.
就像上图的L1/L2/L3,为了方便说明简化成下图
多核心处理器中,每个核心有自己单独的缓存.所以在多线程操作一个共享变量时,出现两个线程从主存中拷贝同一个变量,但在执行过程中一个核心无法从另一个核心的缓存中获取最新值.使用过时的值进行计算得到错误的结果,并覆盖了内存中的值.
这个过程中,两个核心的缓存不一致导致了问题.为了解决缓存一致问题,通常使用以下两种方式同步
指令重排
CPU为了能够加速程序的运行,会将部分指令进行重排1
2
3a = 1;
b = 2;
c = a + b;
例如上述的代码中,可能a的赋值先执行,也有可能b的赋值先执行.因为c的赋值依赖于a和b所以一定后于a/b的赋值后执行
CPU的指令重排序保证了逻辑一致1
2
3
4
5
6
7
8
9void foo(void) {
a = 1;
b = 1;
}
void bar(void) {
while (b == 0) continue;
assert(a == 1);
}
但在多线程中,CPU仅保证在自己内部的执行逻辑是不够的,在这个例子中,foo/bar分别在不同的线程中执行.
- foo在指令重排时将b的赋值提前了,通过缓存一致性更新到了bar线程,此时bar中的断言将会错误.
- bar的线程因为指令重排,直接先执行了断言,导致程序错误.
所以我们需要一种阻止共享变量在CPU内执行顺序变化的指令.
内存屏障
CPU提供了四种屏障供我们使用
屏障类型 | 说明 |
---|---|
LoadLoad | 在此屏障前的加载数据都先于屏障后的加载 |
LoadStore | 在此屏障前的加载数据都先于屏障后的写入 |
StoreLoad | 在此屏障前的写入都先于屏障后的加载 |
StoreStore | 在此屏障前的写入都先于屏障后的写入 |
其中的写入是指等待CPU中的Store Buffer中的写入变更flush完全到缓存中
加载是指等待 Invalidate Queue 完全应用到缓存后
StoreLoad会等待这个屏障前的所有内存操作指令(读取和写入)都完成后,再进行屏障后的内存装载,开销也是最大的一个 也是包含了其他三个指令的作用
因为各种架构的处理器设计不同的原因,有些处理器仅支持部分指令的重排
例如常部署的X86仅支持StoreLoad重排
volatile
当一个变量使用volatile进行修饰时,在编译的过程中,编译器会做两件事
- 使用LOCK#指令修饰变量的赋值,保证变量的可见性
- 使用内存屏障对变量的重排序进行限制
JMM内存屏障插入策略
是否能重排序 | 第二个操作 | |||
---|---|---|---|---|
第一个操作 | 普通读 | 普通写 | volatile 读 同步块入口(monitor enter) |
volatile 写 同步块出口(monitor exit) |
普通读 | LoadStore | |||
普通写 | StoreStore | |||
volatile 读 同步块入口(monitor enter) |
LoadLoad | LoadStore | LoadLoad | LoadStore |
volatile 写 同步块出口(monitor exit) |
StoreLoad | StoreStore |
从上表得知
- 普通读写不会插入内存屏障
- 普通变量的读写在volatile读之前不存在内存屏障
- volatile写之后 普通变量的读写没有内存屏障
对于编译器去分析代码进行最小化插入屏障几乎不可能,所以JMM采取保守策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障
保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了
- 在每个volatile写操作的后面插入一个StoreLoad屏障
此屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)。
- 在每个volatile读操作的后面插入一个LoadLoad、LoadStore屏障
然后再进行简单优化.
下列是一个简答的例子1
2
3
4int t = x; // x 是 volatile 变量
[LoadLoad]
[LoadStore]
<other ops>1
2
3
4
5<other ops>
[StoreStore]
[LoadStore]
x = 1; // x 是 volatile 变量
[StoreLoad] // 这里带了个尾巴
volatile的使用
volatile并不能保证原子性
由于volatile仅仅保证对单个volatile变量的读/写具有原子性.
经典例子自增1
i = i + 1; // i是volatile变量
上面的代码实际上包含了3个操作,读取i的值,i的值加上1后赋值给i.
i虽然对所有CPU是可见的,但是读取i之后当前CPU可能会进行其他操作,此时再加上1再赋值给i时是滞后的.更新后的i值也是错误的
所以自增只能使用锁进行保证,volatile的正确使用是
- 对变量的些不能依赖于当前变量的值
- 该变量没有包含在具有其他变量的不变式中
双重检查
1 | class Singleton{ |
为什么使用了双重检查锁之后还需要对变量进行volatile变量进行修饰.
因为对象的实例化分为3步
- 分配对象内存空间 // memory = allocate();
- 初始化对象 // ctorInstance(memory)
- 设置instance指向刚刚分配的地址 // instance = memory
而经过重排后的代码可能导致23步骤交换,分配了内存地址后但是实际的对象还未初始化
这时如果有另一个线程进行访问对象,虽然不是空指针对象 但是这个对象还未进行初始化。