硬件级多线程保障

存储结构图

在这里插入图片描述
对于现代CPU,广告中我们经常可以听到一个三级缓存的概念。其中第一级和第二级缓存,为每个CPU核心独享,第三级缓存,是所有CPU核心共享,缓存集成在CPU内部,读取写入速度非常快,但是容量很小
再往下一级便是我们的内存,容量大,读写速度相对较快,
再往下便是我们的磁盘, 容量可以非常大,但是读写速度比较一般。

那整个计算机存储系统是如何工作的呢?假设我们CPU核心1执行到了某一条指令,需要读入标识为A的对象数据:

  • 核心1去自己的第一级缓存L0中尝试获取数据A,如果没有则尝试从L1中尝试获取,如果还是没有,则依次往下进行尝试查找,比如当在L3中找到数据A之后,将数据写入L2,再读入L1,再L0
  • 当下次还需要读取数据A时,如果L0中还存在该数据,直接读取,如果没有了,重复步骤1

数据一致性

在这里插入图片描述
现代CPU一般都会多个核心,每个核心独立工作,假设现有2个核心,分别开启线程,将主内存中的变量X和Y分别读进自己的一级二级缓存,执行对应的业务逻辑,如果程序运行中,CPU1将X值修改成了1,CPU2将X值修改成了2,当线程执行完毕,将X数据刷新进主存时,CPU1需要写入的值是1,而CPU2需要写入的值却是2,产生数据不一致问题。

如何解决各个CPU核心之间的数据不一致问题呢?主要有以下两种方式:

  • 总线锁(bus lock)
  • 缓存一致性协议

总线锁

概念:总线,也叫CPU总线,是CPU与芯片组成连接的主干道,负责CPU与外界所有部件的通信,包括高速缓存,内存,向各个部件发送控制信号、寻址、数据传输等。

总线锁:当CPU1需要执行某数据项时,其在总线上发出LOCK信号,这样其他CPU,如CPU2将不能操作该数据项变量的内存地址缓存,以此阻塞住其他所有CPU核心,CPU1独享此共享内存。

缺点:代价较大,在锁定期间,其他CPU只能等待。

缓存一致性协议

概念:当CPU某一块核心,修改了它自己的L1、L2缓存中的数据,通知其他CPU核心不要再使用它们内部的缓存,从主内存中重新读取。

注意,它只是一个协议,每个CPU厂家的具体实现逻辑是不一样的,有MESI,MSI等多种。以常用的Intel CPU为例,使用的MESI协议,简单介绍如下:
对缓存数据定义了以下这几种状态:

  • modified(修改):当前CPU对该缓存块进行了修改,必须要写回主存,其他CPU不能再缓存原有信息
  • exclusive(独享):当前CPU独享该缓存块,其他CPU不能装载该缓存块
  • share(共享):该缓存块同时被多个CPU装载读取,未被修改
  • invalid(无效):该缓存块数据,已经被其他CPU修改了,当前CPU对该缓存块的缓存失效,不能再使用

问题:

  1. 会涉及缓存行对齐的问题,下面会讲
  2. 某些数据很大的时候,无法进行缓存,还是需要使用总线锁

缓存行对齐

设想一下这样一个问题,当CPU运行到某一条指令时,需要读取一个变量a,那CPU读取数据时,是否仅仅是读取a这一条数据呢?CPU为了提高效率,会在内存中一次性读取一整行的数据(一个cache line),现在CPU一般是64个字节

还是以上面的图示为例:
在这里插入图片描述
现有x,y两个数据在内存同一行,CPU有CPU1和CPU2两个核心分别执行不同的计算任务,CPU1任务中只需要用到x,CPU2任务重只需要用到y。
下面就会有这样的一种场景,CPU1和CPU2会同时将x,y两条数据都缓存进自己的L1、L2,然后CPU1不停的修改x数据,根据缓存一致性协议,需要不停的通知CPU2,让CPU2不要再使用x的缓存,需要到主存中重新获取。但是实际上CPU2里的任务根本就不要用到x,还不得不不间断的从主存中加载x和y。返回来当CPU2修改了Y值,CPU1同样也是有这样的问题。

因缓存行问题,导致CPU的高速缓存区失去了作用,需要不停的读写主存。

小demo

在这里插入图片描述
在这里插入图片描述
现在有一个class A,内部定义一个变x,有两个线程分别负责不停的修改A1和A2。两个程序唯一不同的点就是class A,在第二次运行时,在变量x之前,加了7个long类型的变量。对比下两次运行时间,可以看到第二次耗时明显小于第一次。这是为什么呢?

我们看下第一次运行时内存情况:
在这里插入图片描述
数组合计占16个字节,A1和A2这两个对象会在一个缓存行里,CPU1在读取A1时会顺带着将A2页读取,CPU2在读取A2时会顺带着将A1页读取。当CPU1不停修改A1时,需要通知CPU2去主存中读取新的A1,同理当CPU2不停修改A2时,需要通知CPU1去主存中读取新的A2,直接导致两个CPU的高速缓存失效。

我们再来看下第二次运行时的情况:
在这里插入图片描述
第二次运行时,对象中合计有8个long类型字段,占64字节,如此CPU不能同时读取A1和A2,也即A1和A2在两个缓存行中。
CPU1在读取A1时,不会读取到A2,同理CPU2在读取A2时,不会读取到A1,后续每次对变量X的修改,都在各个CPU的内部高速缓存中进行,所以执行效率大幅提升。

WCBuffer(write combining buffer)

当CPU执行存储指令时,会优先试图将数据写入离CPU最近的L1,如果在L1上没有命中缓存,则会依次去访问L2,L3,主存,性能呈指数级下降。这种情况下该怎么办呢?这个时候就涉及到另一个缓存区:WCBuffer。

WCBuffer,也叫合并写缓冲区。CPU会在L0缓冲区没有命中,在请求L2缓冲区所有权过程中,将待写入的数据,先写入WCBuffer,其大小与一个cache line相同,64字节。该缓冲区允许CPU在访问该缓冲区数据时同时执行其他指令。

后续对一个缓存行的写操作,在写操作执行提交到L2之前,会在WCBuffer合并写。比如先将缓存行数据修改为1,再修改为2,再修改为3,那提交到L2的数据将直接是最终数据3。

public class Main {

    private static final int ITERATIONS = Integer.MAX_VALUE;
    private static final int ITEMS = 1 << 24;
    private static final int MASK = ITEMS - 1;

    private static final byte[] arrayA = new byte[ITEMS];
    private static final byte[] arrayB = new byte[ITEMS];
    private static final byte[] arrayC = new byte[ITEMS];
    private static final byte[] arrayD = new byte[ITEMS];
    private static final byte[] arrayE = new byte[ITEMS];
    private static final byte[] arrayF = new byte[ITEMS];
    private static final byte[] arrayG = new byte[ITEMS];
    private static final byte[] arrayH = new byte[ITEMS];

    public static void main(final String[] args) {
        System.out.println("case1 运行时间 = " + runCaseOne());
        System.out.println("case2 运行时间 = " + runCaseTwo());
    }

    public static long runCaseOne() {
        long start = System.nanoTime();
        int i = ITERATIONS;

        while (--i != 0) {
            int slot = (i & MASK);
            byte b = (byte) i;
            arrayA[slot] = b;
            arrayB[slot] = b;
            arrayC[slot] = b;
            arrayD[slot] = b;
            arrayE[slot] = b;
            arrayF[slot] = b;
            arrayG[slot] = b;
            arrayH[slot] = b;
        }
        return System.nanoTime() - start;
    }

    public static long runCaseTwo() {
        long start = System.nanoTime();
        int i = ITERATIONS;
        while (--i != 0) {
            int slot = (i & MASK);
            byte b = (byte) i;
            arrayA[slot] = b;
            arrayB[slot] = b;
            arrayC[slot] = b;
            arrayG[slot] = b;
        }
        i = ITERATIONS;
        while (--i != 0) {
            int slot = (i & MASK);
            byte b = (byte) i;
            arrayD[slot] = b;
            arrayE[slot] = b;
            arrayF[slot] = b;
            arrayH[slot] = b;
        }
        return System.nanoTime() - start;
    }
}

本人电脑iMac,3 GHz Intel Core i5,输出结果:
case1 运行时间 = 7358440377
case2 运行时间 = 5989994707

注:网上其他文档里面的数据差距可以达到1.5倍,但是case1和case2的单个运行时间是我电脑耗时的2倍,可能是我CPU运行比较快,差距没那么明显。还有一些例子合计是6个数组,也是不对的。

看上面的代码,是不是很困惑,明明case2的循环次数是case1的2倍,为什么case2的执行时间反而比case1短呢?

在这里插入图片描述
一个WCBuffer内64字节的缓冲区维护了一个64位的字段,每更新一个字节就会设置对应的位,来表示将缓冲区交换到外部缓存时哪些数据是有效的。

一个intel的CPU,在同一个时刻只能拿到4个WCBuffer缓冲区,在case1中需要连续写入8个不同位置的内存,那么连续更新数据写满缓冲区时,CPU就需要等待,将缓冲区的内容写入L2,此时CPU是处于强制暂停状态。一个循环内的剩余数据,需要等待下一次填满缓冲区。

而case2中,每一次循环正好是4个不同位置的内存,因合并写缓冲区满到引起的cpu暂停的次数会大大减少,实现了执行效率的提示。

这意味着,在一个循环中,我们不应该同时写超过4个不同的内存位置,否则我们将可能不能享受到合并写(write combining)的好处。

CPU乱序执行

处理器基本上会按照程序中书写的机器指令的顺序执行。按照书写顺序执行称为按序执行(In-Order )。按照书写顺序执行时,如果从内存读取数据的加载指令、除法运算指令等延迟(等待结果的时间)较长的指令后面紧跟着使用该指令结果的指令,就会陷入长时间的等待。尽管这种情况无可奈何,但有时,再下一条指令并不依赖于前面那条延迟较长的指令,只要有了操作数就能执行。这种情况就会产生输出结果和预计不符。

CPU 内存屏障

为了避免在某些情况下出现CPU指令的乱序执行,便出现了CPU的内存屏障技术,注意这里说的是CPU的内存屏障技术,不是JVM的内存屏障。各个品牌的CPU对屏障的定义和处理不一样,还是以intel为例:
intel有3中内存屏障:

  • sfence:store fence,写屏障,在sfence指令前的写操作必须在sfence指令后的写操作前完成。
  • lfence:load fence,读屏障,在lfence指令前的读屏障操作必须在lfence指令后的读操作前完成。
  • mfence:读写屏障,在mfence指令前的读写操作必须在mfence指令后的读写操作前完成。

是不是没看懂啥意思?以sfence屏障指令为例:
在这里插入图片描述
目前有两条写指令1和2,中间插入一条sfence,表示写指令1必须先执行,然后再执行写指令2,两者不可以换顺序。lfence和mfence同理可得。
汇编指令,“Lock”就是一个读写屏障,相信很多人反编译Class文件后会看到有Lock指令,其实这就是JVM帮我们加的CPU内存屏障指令。

JVM内存屏障

JVM的内存屏障,一定是基于CPU内存屏障来的。JVM对内存读写屏障进行了组合:

  • LoadLoad屏障
  • StoreStore屏障
  • LoadStore屏障
  • StoreLoad屏障

以LoadLoad屏障为例,其他几个类似:
在这里插入图片描述
LoadLoad指令要求,必须Load1先执行,然后再执行Load2

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章