线程安全性之可见性、缓存一致性(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(整个共享) - 主存,顺序为依次读取,但是又有了新的问题
线程安全性之可见性、缓存一致性(MESI)以及伪共享问题分析
从上图可以看到,CPU1在修改完flag后同步到主存,但是当CPU2去取flag的时候,因为先从缓存取的缘故,可能还有旧的值,这就是缓存一致性问题。

3.缓存一致性解决方案

毋庸置疑:加锁

  • 总线锁,更新到主存处加锁

  • 缓存锁

    • 缓存一致性协议(MESI,MOSI)
    • MESI,分别表示修改(modify)、独占(exclusive)、共享(share)、失效(invalid)

    修改:当CPU去修改i变量的时候,会把状态修改为modify状态
    独占:该变量只存在当前CPU缓存行中
    共享:多个CPU都加载了该变量
    失效:当缓存行中i变量发生改变时,发现是共享状态,那么需要通知另一个CPU的i变量修改为失效状态
    根据MESI协议,读取的时候只有MES走缓存,I状态下的要直接访问内存

  • 看下MESI协议流程图
    线程安全性之可见性、缓存一致性(MESI)以及伪共享问题分析
    当修改i = 1时,整个流程如下

    • CPU1修改变量i时,状态会修改为M(修改)状态,同时通知CPU2的变量i修改为I(无效)状态
    • CPU1修改完成后同步到主存,并将变量i设置为E(独占)状态
    • 当CPU2操作变量i时,发现是失效状态,会去内存中重新加载。最终通过总线探测得到CPU1也有加载变量i,就会将CPU1和CPU2中的变量i都会修改为S(共享)状态,否则就是独占状态

总得来说就是在修改高速缓存共享(share)状态下的时候,会发送一个失效指令给其他CPU,然后其他CPU读取高速缓存时发现是失效状态,那么从重新从内存加载进来,以解决缓存一致性问题。
看下解决方案图:
线程安全性之可见性、缓存一致性(MESI)以及伪共享问题分析
从上图可以看图更新缓存会走到缓存锁/总线锁,具体实现不是我们实现,而是由CPU加上汇编指令#Lock去实现,当我们加上volatile关键字后,最终的执行指令中就会生成#Lock汇编指令,从而达到加锁的效果

总结: 在java中加上 volatile关键字,就等于加上了汇编指令#Lock,达到加锁的效果

  • 查看运行的汇编指令可以参考hsdis,这里不贴图了,点击去百度
  • 具体区别就是加了Volatile关键字和没加的汇编指令中有没有#Lock的区别

注意点:MESI协议只能保证并发编程中的可见性,并未解决原子性和有序性的问题,所以只靠MESI协议是无法完全解决多线程中的所有问题。

伪共享问题

1.伪共享问题的表现

并发修改在一个缓存行中的多个独立变量,表面上是并发执行的,但实际在CPU处理的时候是串行执行的,并发的性能有很大的影响。

缓存是由缓存行组成的,通常是64字节组成。
一个java的long类型是8字节,因此一个缓存行中可以存放8个long类型的变量。

缓存行是CPU内部用来存放数据的最小存储区域,缓存行每次加载数据是一段一段的(提升性能),X86的电脑一段就是64位,但在这个缓存行下会出现以下问题
线程安全性之可见性、缓存一致性(MESI)以及伪共享问题分析
一个缓存行中X、Y、Z三个变量,那么在CPU0操作X时,那么在CPU1中,整个缓存行就失效了,这个时候,如果CPU1修改了Y的值,就必须先提交CPU1的缓存,然后再去主存中读取数据,这样就出现了问题,XY在两个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
下一篇:线程安全性之有序性和内存屏障

野火烧不尽,春风吹又生

上一篇:CPU缓存一致性协议MESI


下一篇:MESI缓存一致性协议(算是白话)有问题欢迎评论