线程安全性之可见性、缓存一致性(MESI)以及伪共享问题分析
作者:互联网
可见性问题
可见性是什么:线程A变量对线程B不可见,例如数据库脏读。
1.代码示例
static boolean flag = false;
static int num = 0;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
//里面无触发活性的东西 会导致活性失效
while (!flag){
num++;
}
}).start();
Thread.sleep(1000);
System.out.println(num);
flag = true;
}
//输出结果
1255362997
然后惊奇的发现,程序并没有停止呀,可见性问题就此展开
2.活性失效
简单来说,程序进行的值在没有触发操作,没有进行重新加载,也就还是以前的值。
//然后在里面加入这个
System.out.println(num);
//或者
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
加上上述代码,程序又能正常结束了,这是为什么呢?
sout 是IO里面的操作,里面有synchronized同步操作
public void println(int x) {
synchronized (this) {
print(x);
newLine();
}
}
Thread.sleep(10)是阻塞操作,会继续持有锁,但是放弃cpu执行机会,导致上下文切换
综上所述:上面两种方式都会重新加载值,故而可以正常结束线程
CPU高速缓存
1.由来
磁盘(程序) -> 加载程序 -> 内存(数据) -> 运行 -> CPU
CPU的运行速度会比磁盘读写快的多,那么在磁盘读写时,CPU将处于阻塞状态,造成了CPU资源的浪费,于是便有了一系列的优化
- CPU资源利用问题
CPU增加3级高速缓存(L1 L2 L3)
操作系统中,增加进程、线程 ->通过CPU的时间片切换,提升CPU利用率
编译器优化(JVM的深度优化)
2.缓存一致性
在我们的任务管理器 -CPU界面可以看到 L1 L2 L3缓存
CPU - L1(区分) - L2(单个CPU共享) - L3(整个共享) - 主存,顺序为依次读取,但是又有了新的问题
从上图可以看到,CPU1在修改完flag后同步到主存,但是当CPU2去取flag的时候,因为先从缓存取的缘故,可能还有旧的值,这就是缓存一致性问题。
3.缓存一致性解决方案
毋庸置疑:加锁
-
总线锁,更新到主存处加锁
-
缓存锁
- 缓存一致性协议(MESI,MOSI)
- MESI,分别表示修改(modify)、独占(exclusive)、共享(share)、失效(invalid)
修改:当CPU去修改i变量的时候,会把状态修改为modify状态
独占:该变量只存在当前CPU缓存行中
共享:多个CPU都加载了该变量
失效:当缓存行中i变量发生改变时,发现是共享状态,那么需要通知另一个CPU的i变量修改为失效状态
根据MESI协议,读取的时候只有MES走缓存,I状态下的要直接访问内存 -
看下MESI协议流程图
当修改i = 1时,整个流程如下- CPU1修改变量i时,状态会修改为M(修改)状态,同时通知CPU2的变量i修改为I(无效)状态
- CPU1修改完成后同步到主存,并将变量i设置为E(独占)状态
- 当CPU2操作变量i时,发现是失效状态,会去内存中重新加载。最终通过总线探测得到CPU1也有加载变量i,就会将CPU1和CPU2中的变量i都会修改为S(共享)状态,否则就是独占状态
总得来说就是在修改高速缓存共享(share)状态下的时候,会发送一个失效指令给其他CPU,然后其他CPU读取高速缓存时发现是失效状态,那么从重新从内存加载进来,以解决缓存一致性问题。
看下解决方案图:
从上图可以看图更新缓存会走到缓存锁/总线锁,具体实现不是我们实现,而是由CPU加上汇编指令#Lock
去实现,当我们加上volatile
关键字后,最终的执行指令中就会生成#Lock
汇编指令,从而达到加锁的效果
总结: 在java中加上 volatile关键字,就等于加上了汇编指令#Lock,达到加锁的效果
- 查看运行的汇编指令可以参考
hsdis
,这里不贴图了,点击去百度 - 具体区别就是加了Volatile关键字和没加的汇编指令中有没有#Lock的区别
注意点:MESI协议只能保证并发编程中的可见性,并未解决原子性和有序性的问题,所以只靠MESI协议是无法完全解决多线程中的所有问题。
伪共享问题
1.伪共享问题的表现
并发修改在一个缓存行中的多个独立变量,表面上是并发执行的,但实际在CPU处理的时候是串行执行的,并发的性能有很大的影响。
缓存是由缓存行组成的,通常是64字节组成。
一个java的long类型是8字节,因此一个缓存行中可以存放8个long类型的变量。
缓存行是CPU内部用来存放数据的最小存储区域,缓存行每次加载数据是一段一段的(提升性能),X86的电脑一段就是64位,但在这个缓存行下会出现以下问题
一个缓存行中X、Y、Z
三个变量,那么在CPU0
操作X
时,那么在CPU1
中,整个缓存行就失效了,这个时候,如果CPU1
修改了Y
的值,就必须先提交CPU1
的缓存,然后再去主存中读取数据,这样就出现了问题,X
和Y
在两个CPU
上被修改,本是一个并行的操作,但由于缓存一致性,却成为了串行,会严重影响并发的性能,这就叫做伪共享问题。
2.伪共享问题的解决方案
引入对齐填充,不足64位的情况下,采取补齐
Java中提供了两种方案:
- 填充法:在两个long类型中间使用额外的7个long进行填充
public class Pointer{
long index;
long a1,a2,a3,a4,a5,a6,a7;
long count;
}
上面的代码使用填充法后,在内存行中的布局如下
index
count
- 使用@Contentded注解
对类使用时:是整个字段快两端都被填充
对字段使用时:该字段会和其他字段分离到不存的缓存行上
,同时还支持contention group属性,同一组的字段在内存上是连续的。
@sun.misc.Contended(“v1”)
该注解需要添加JVM启动参数才能生效:-XX:-RestrictContended
以上就是本章的全部内容了。
上一篇:并发编程之锁的认识和同步锁 – synchronized
下一篇:线程安全性之有序性和内存屏障
野火烧不尽,春风吹又生
标签:缓存,变量,CPU1,修改,MESI,线程,共享,CPU 来源: https://blog.csdn.net/qq_35551875/article/details/121651039