03、并发编程带来的挑战之可见性

线程的可见性问题

在单线程的环境下,如果向一个变量先写入一个值,然后在没有写干涉的情况下读取这个变量的值,那这个时候读取到的这个变量的值应该是之前写入的那个值。这本来是一个很正常的事情。但是在多线程环境下,读和写发生
在不同的线程中的时候,可能会出现:读线程不能及时的读取到其他线程写入的最新的值,这就是所谓的可见性。

可见性问题的案例

这段代码是一个Java程序,用于演示可见性问题。下面是对代码的解释和注释:

public class VisibilityDemo {
    private int count = 0; // 声明一个私有的易失性整型变量count,初始值为0

    public void increase() {
        for (int i = 0; i < 10000; i++) {
            count++; // 在循环中将count的值加1
        }
    }

    public void decrease() {
        for (int i = 0; i < 10000; i++) {
            count--; // 在循环中将count的值减1
        }
    }

    public static void main(String[] args) throws InterruptedException {
        VisibilityDemo demo = new VisibilityDemo(); // 创建一个VisibilityDemo对象demo
        Thread t1 = new Thread(demo::increase); // 创建一个线程t1,执行demo对象的increase方法
        Thread t2 = new Thread(demo::decrease); // 创建一个线程t2,执行demo对象的decrease方法
        t1.start(); // 启动线程t1
        t2.start(); // 启动线程t2
        t1.join(); // 等待线程t1执行完毕
        t2.join(); // 等待线程t2执行完毕
        System.out.println(demo.count); // 输出demo对象的count值,由于可见性问题,输出结果可能不正确
    }
}

该程序定义了一个VisibilityDemo类,其中包含一个私有的易失性整型变量count,初始值为0。类中有两个公共方法increase()decrease(),分别用于增加和减少count的值。这两个方法通过循环将count的值加1或减1,循环次数为10000次。

main方法中,首先创建了一个VisibilityDemo对象demo。然后创建了两个线程t1t2,分别执行demo对象的increase()方法和decrease()方法。接着通过调用start()方法启动这两个线程。使用join()方法等待线程执行完毕。最后,输出demo对象的count值。需要注意的是,由于可见性问题,输出的结果可能不正确。

volatile解决可见性问题

在上面的程序中,可以增加 volatile 这个关键字来解决,代码如下:

public class VisibilityDemo {
    private volatile int count = 0; // 声明一个私有的易失性整型变量count,初始值为0

    public void increase() {
        for (int i = 0; i < 10000; i++) {
            count++; // 在循环中将count的值加1
        }
    }

    public void decrease() {
        for (int i = 0; i < 10000; i++) {
            count--; // 在循环中将count的值减1
        }
    }

    public static void main(String[] args) throws InterruptedException {
        VisibilityDemo demo = new VisibilityDemo(); // 创建一个VisibilityDemo对象demo
        Thread t1 = new Thread(demo::increase); // 创建一个线程t1,执行demo对象的increase方法
        Thread t2 = new Thread(demo::decrease); // 创建一个线程t2,执行demo对象的decrease方法
        t1.start(); // 启动线程t1
        t2.start(); // 启动线程t2
        t1.join(); // 等待线程t1执行完毕
        t2.join(); // 等待线程t2执行完毕
        System.out.println(demo.count); // 输出demo对象的count值,由于可见性问题,输出结果可能不正确
    }
}

为了提升处理性能所做的优化

在整个计算机的发展历程中,除了CPU、内存以及I/O设备不断迭代升级来提升计算机处理性能之外,还有一个非常核心的矛盾点,就是这三者在处理速度的差异。CPU的计算速度是非常快的,其次是内存、最后是IO设备(比如磁盘),也就是CPU的计算速度远远高于内存以及磁盘设备的I/O速度。
如下图所示,计算机是利用CPU进行数据运算的,但是CPU只能对内存中的数据进行运算,对于磁盘中的数据,必须要先读取到内存,CPU才能进行运算,也就是CPU和内存之间无法避免的出现了IO操作。而cpu的运算速度远远高于内存的IO速度,比如在一台2.4GHz的cpu上,每秒能处理2.4x109次,每次处理的数据量,如果是64位操作系统,那么意味着每次能处理64位数据量。
在这里插入图片描述
虽然CPU从单核升级到多核甚至到超线程技术在最大化的提高CPU的处理性能,但是仅仅提升CPU性能是不够的,如果内存和磁盘的处理性能没有跟上,就意味着整体的计算效率取决于最慢的设备,为了平
衡这三者之间的速度差异,最大化的利用CPU。所以在硬件层面、操作系统层面、编译器层面做出了很多的优化

  1. CPU增加了高速缓存
  2. 操作系统增加了进程、线程。通过CPU的时间片切换最大化的提升CPU的使用率
  3. 编译器的指令优化,更合理的去利用好CPU的高速缓存每一种优化,都会带来相应的问题,而这些问题是导致线程安全性问题的根源,那接下来我们逐步去了解这些优化的本质和带来的问题。

CPU层面的缓存

CPU在做计算时,和内存的IO操作是无法避免的,而这个IO过程相对于CPU的计算速度来说是非常耗时,基于这样一个问题,所以在CPU层面设计了高速缓存,这个缓存行可以缓存存储在内存中的数据,CPU每次会先从缓存行中读取需要运算的数据,如果缓存行中不存在该数据,才会从内存中加载,通过这样一个机制可以减少CPU和内存的交互开销从而提升CPU的利用率。
对于主流的x86平台,cpu的缓存行(cache)分为L1、L2、L3总共3级。
在这里插入图片描述
如图所示,在一个4核CPU的架构下,有3个级别的缓存,分别是L1 Cache(一级缓存)、L2 Cache(二级缓存)、L3 Cache(三级缓存)。其中L1 Cache又分为L1I(一级指令缓存)和L1D(一级数据缓存)。 L1 Cache和L2 Cache是CPU核心内的缓存,独立归属给各个CPU,而L3 Cache是CPU之间共享的。

引入缓存之后的工作流程

增加了CPU缓存之后,基于时间局部性原理,如果一个信息正在被访问,那么近期它很有可能再次被访问,所以缓存的命中率很高,大部分CPU可以达到90%左右,在加上L3缓存的存在,使得95%的数据可以在CPU缓存中加载,大大提升了CPU的计算效率从而提高利用率。

缓存行Cache Line

CPU中的缓存是由多个Cache Line组成,Cache Line是CPU和内存交换数据的最小单元,在x86架构中,每个缓存行大小为64 bytes(字节)。在上一节课中,有简单提到cpu从内存中读取数据时是以数据块状方式读取。当CPU把内存的数据加载到CPU高速缓存中时,一次会读取64个字节放入到同一个缓存行,基于空间局部性原理,如果一个存储器的位置被引用,那么将来它附近的位置也会被引用。因此这种方式对于CPU来说,能够减少和内存的交互次数提升CPU利用率。这样大大节省了CPU直接读取内存的时间,也使得CPU读取数据时基本上不需要等待。
假设在32位的CPU中,一次性读取4个字节,在访问非对齐的过
程:
在这里插入图片描述

超线程技术

超线程(hyper-threading)其实就是同时多线程(simultaneous multitheading),是一项允许一个CPU执行多个控制流的技术。它的原理很简单,就是把一颗CPU当成两颗来用,将一颗具有超线程功能的物理CPU变成两颗逻辑CPU,而逻辑CPU对操作系统来说,跟物理CPU并没有什么区别

缓存一致性问题

CPU高速缓存的出现,虽然提升了CPU的利用率,但是同时也带来了另外一个问题–缓存一致性问题,这个一致性问题体现在。在多线程环境中,当多个线程并行执行加载同一块内存数据时,由于每个CPU都有自己独立的L1、L2缓存,所以每个CPU的这部分缓存空间都会缓存到相同的数据,并且每个CPU执行相关指令时,彼此之间不可见,就会导致缓存的一致性问题,据图流程如下图所示:
在这里插入图片描述

缓存一致性问题

CPU高速缓存的出现,虽然提升了CPU的利用率,但是同时也带来了另外一个问题–缓存一致性问题,这个一致性问题体现在。
在多线程环境中,当多个线程并行执行加载同一块内存数据时,由于每个CPU都有自己独立的L1、L2缓存,所以每个CPU的这部分缓存空间都会缓存到相同的数据,并且每个CPU执行相关指令时,彼此之间不可见,就会导致缓存的一致性问题,据图流程如下图所示:
在这里插入图片描述
两个CPU的缓存行中都缓存了x=20这个值,其中Processor 2将x=20修改成了x=40,那么这个时候把数据同步到主内存,此时就会出现,Processor 0缓存行中的x的值和Processor 2缓存行中x的值不相同,这就是缓存的一致性问题。
为了解决这个问题,在CPU层面引入了总线锁的机制。

总线锁

什么是总线,简单来说,就是内存和CPU之间的一条通信管道,数据就是通过这个管道进行传输,如下图所示。
CPU和内存通信,会通过前端总线来访问,前端总线是所有CPU和芯片组连接的一条马路,负责CPU与外界所有部件(内存、北桥)通信。
PCI,外设部件互联总线
在这里插入图片描述
而所谓的总线锁,就是当CPU执行i++操作时,当前CPU会在总线上发出一个Lock#信号,当一个处理器在总线上输出此信号,其他处理器的请求将被阻塞,那么该处理器就可以独占共享锁.

缓存一致性协议

为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有MSI,MESI,MOSI等。最常见的就是MESI协议。接下来给大家简单讲解一下MESI。
MESI表示缓存行的四种状态,分别是

  • M(Modify[ˈmɒdɪfaɪ]) 表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
  • E(Exclusive[ɪkˈskluːsɪv]) 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
  • . S(Shared[ʃerd]) 表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致
  • I(Invalid[ˈɪnvəlɪd]) 表示缓存已经失效

在CPU的缓存行中,每一个Cache一定会处于以下三种状态之一

  • Shared
  • Exclusive
  • Invalid
    在这里插入图片描述
    MESI的工作流程
    基于缓存一致性协议,我们知道每个CPU的高速缓存上都会标记当前缓存数据的状态,那他是如何实现缓存一致性的呢?
    简单来说,假设某一个共享变量缓存在多个CPU的高速缓存中,此时该缓存的状态为Shared。如果,此时其中一个CPU核心修改了这个缓存的值,那么他应该要发一个消息高速其他CPU来知道修改缓存这个事情,最简单的办法就是让其他的CPU中的缓存行失效。通过这样一种机制,就可以保证各个CPU核心之间的缓存行的一致性。
    假设CPU执行一个 a=1的指令,而假设a这个数据的状态是shared,那么此时,它在修改本地缓存的数据并同步到主存时,需要给其他CPU发送一个invalidate消息,其他CPU收到这个消息之后,让缓存行失效,然后回应一个invalidate acknowledge消息。当该CPU收到反馈之后,就可以安全的写入消息了。
    在这里插入图片描述
    需要注意,这里的通信是基于snoopy嗅探协议,每个CPU的Snoopy都会监听总线上的事件,当某个处理器发出请求时,会分配一个总线标识,等待其他处理器响应。其他处理器收到请求之后进行相应的处理。

总结可见性的本质

引入总线锁和缓存锁机制之后,CPU对于内存的操作大概可以抽象成下面这样的结构,从而达到缓存一致性效果
在这里插入图片描述

StoreBuffer

如下图所示,当一个CPU进行写入时,首先会给其它CPU发送Invalid消息,然后把当前写入的数据写入到Store Buffer中。同时给其他cpu发送消息,然后继续做其它事情,等到收到其它cpu发过来的响应消息,再将数据从storebuffer移到cache line。
在这里插入图片描述
当前CPU核心如果要读Cache中的数据,需要先扫描Store Buffer之后再读取Cache。但是此时其它CPU核是看不到当前核的Store Buffer中的数据的,要等到Store Buffer中的数据被刷到了Cache之后,才会基于MESI协议触发失效操作。而当一个CPU核收到Invalid消息时,会把消息写入自身的InvalidateQueue中,随后异步将其设为Invalid状态。这个优化方案,可以在一定程度上解决了CPU在基于缓存一致性协议同步数据时的阻塞问题,从而提升CPU的利用率,但是仍然会存在问题,在如下代码中。

a = 1;
b = a + 1;
assert(b == 2);

如下图所示:

  1. cpu0 要写入a,将a=1写入store buffer,并发出Read Invalidate消息,
    继续其他指令。
  2. cpu1 收到Read Invalidate,返回Read Response(包含a=0的cache line)
    和Invalidate ACK,cpu0 收到Read Response,更新cache line(a=0)。
  3. cpu0 开始执行b=a+1,此时cache line中还没有加载b,于是发出Read
    Invalidate消息,从内存加载b=0,同时cache line中已有a=0,于是得
    到b=1,状态为Modified状态。
  4. cpu0 得到 b=1,断言失败。
  5. cpu0 将store buffer中的a=1推送到cache line,然而此时已经导致了问
    题。
    在这里插入图片描述
    造成这个问题的根源在于对同一个cpu存在对a的两份拷贝,一份在cache,一份在store buffer,而cpu计算b=a+1时,a和b的值都来自cache。仿佛代码的执行顺序变成了这个样子:
b = a + 1;
a = 1;
assert(b == 2);

上面这个问题的原因是: CPU0把a=1修改后,先写入到store buffer,然后在基于Read Invalidate消息从其他CPU中同步了a=0这个值,使得后续的数据操作结果不对。如果此时CPU0中后续的指令操作,能够直接从StoreBuffer中读取到已经修改后的a=1操作,就能避免这个问题。

Store Forwarding

所以,CPU的硬件工程师提出了一个优化方案Store Forwarding,这个方案的原理就是: CPU可以直接从Store Buffer中加载数据,也就是支持将CPU存入到Store Buffer的数据传递给后续的加载操作,而不需要经过Cache。
也就是在上图中,第3个步骤中,执行b=a+1时, a的值是从Store Buffer中加载到的,而a=1,所以b=2,最终结果也是正确的。
但是这个优化方案,虽然解决了同一个CPU读写数据的问题,但是在多线程环境下仍然存在问题,如下代码:

int a=0,b=0;
executeToCPU0(){
a=1;
b=1;
}
executeToCPU1(){
while(b==1){
assert(a==1);
}
}

初始状态下,假设a,b值都为0,a存在于cpu1的cache中,b存在于cpu0的cache中,均为Exclusive状态,cpu0执行executeToCPU0函数,cpu1执行executeToCPU1函数,就可能出现b1返回true,但是assert(a1)返回false。很多同学肯定会表示不理解,这种情况怎么可能成立?那接下来我们去分析一下
在这里插入图片描述

  1. cpu1执行while(b == 0),由于cpu1的Cache中没有b,发出Read b消息
  2. cpu0执行a=1,由于cpu0的cache中没有a,因此它将a(当前值1)写入到
    store buffer并发出Read Invalidate a消息
  3. cpu0执行b=1,由于b已经存在在cache中,且为Exclusive状态,因此可
    直接执行写入
  4. cpu0收到Read b消息,将cache中的b(当前值1)返回给cpu1,将b写回到
    内存,并将cache Line状态改为Shared
  5. cpu1收到包含b的cache line,此时b==1,满足while(b=1)这个条件,
    执行assert(a=1)的断言。
  6. cpu1执行assert(a == 1),由于此时cpu1 cache line中的a仍然为0并
    且有效(Exclusive),断言失败
  7. cpu1收到Read Invalidate a消息,返回包含a的cache line,并将本地包
    含a的cache line置为Invalid,然而已经为时已晚。
  8. cpu0收到cpu1传过来的cache line,然后将store buffer中的a(当前值1)
    刷新到cache line

出现这个问题的原因在于cpu不知道a, b之间的数据依赖,cpu0对a的写入需要和其他cpu通信,因此有延迟,而对b的写入直接修改本地cache就行,因此b比a先在cache中生效,导致cpu1读到b=1时,a还存在于store buffer中。从代码的角度来看,executeToCPU0函数似乎变成了这个样子:

executeToCPU0(){
b=1;
a=1;
}

这个问题,就是我们经常说的指令重排序问题,在单线程环境下,这种指令交换并不会对结果产生影响,但是在多线程环境中,赋值的先后会产生可见性问题。

CPU性能优化之-Invalid Queue

引入了Store Bufrer再加上Store Forwarding,在一定程度上进一步优化了CPU的利用率,然而在CPU层面还有一个问题需要考虑,就是store buffer大小是有限制的,所有的写入操作发生 Cache Mssing(也就是数据不在本地),都会先使用Store Buffer,所以Store Buffer很容易满。而当Store Buffer满了之后,Cpu还是会阻塞在对应Invalidate ACK以处理Store Buffer中的数据。
因此,还是要回到Invalidate ACK中来,Invalidate ACK这边耗时的主要原因是CPU需要先将对应的Cache line设置为Invalid后再返回Invalidate ACK。一个很忙的cpu可能会导致其它cpu都在等它回Invalidate ACK,如下图所示。
在这里插入图片描述
CPU为了优化这个问题,还是采用同步转异步的方式,也就是说,在上面的
图中,CPU1不需要处理Cache line之后再返回Invalidate ACK,而是可以先
把Invalid消息放入到某个请求对立Invalid Queue,然后立刻返回Invalidate
ACK。CPU1可以后续再处理Invalid Queue中的消息,大幅度降低了
Invalidate ACK响应的时间。
引入Invalid Queue后的CPU Cache架构如下图所示。
在这里插入图片描述
加入了invalid queue之后,cpu在处理任何cache line的MSEI状态前,都必
须先看invalid queue中是否有该cache line的Invalid消息存在没有处理的情
况,而且这个优化同样会带来数据不一致的问题。
还是以如下代码为例,仍然假设a, b的初始值为0,a在cpu0,cpu1中均为
Shared状态,b在cpu0独占(Exclusive状态),cpu0执行executeToCPU0,
cpu1执行executeToCPU1:

int a=0,b=0;
executeToCPU0(){
a=1;
b=1;
}
executeToCPU1(){
while(b==1){
assert(a==1);
}
}

引入Invalid queue之后,执行流程如下。
在这里插入图片描述

  • cpu0执行a=1,由于其有包含a的cache line,将a写入store buffer,并发
    出Invalidate a消息。
  • cpu1执行while(b == 0),它没有b的cache,发出Read b消息。
  • cpu1收到cpu0的Invalidate a消息,将其放入Invalidate Queue,返回
    Invalidate ACK。
  • cpu0收到Invalidate ACK,将store buffer中的a=1刷新到cache line,标
    记为Modified。
  • cpu0执行b=1,由于其cache独占b,因此直接执行写入,cache line标记
    为Modified。
  • cpu0收到cpu1发的Read b消息,将包含b的cache line写回内存并返回该
    cache line,本地的cache line标记为Shared。
  • cpu1收到包含b(当前值1)的cache line,执行while循环中的代码
  • cpu1执行assert(a == 1),由于其本地有包含a旧值的cache line,读到a初
    始值0,断言失败。
  • cpu1这时才处理Invalid Queue中的消息,将包含a旧值的cache line置为
    Invalid。
    导致最终断言失败的根本原因是,CPU1在读取a的cache line时,没有先处
    理Invalid Queue中的cache line的Invalid操作,这种优化和前面提到的
    Store Forwarding优化,都会导致cpu执行的指令顺序不一致,最终导致可
    见性问题

指令重排示意图:(参考博文)

在这里插入图片描述
下面是一个会出现指令重排的示例代码,其中两个线程同时对一个共享变量进行修改,但是由于指令重排的存在,可能导致其中一个线程对共享变量的修改对另一个线程不可见。

public class VisibilityDemo {
    private volatile int count = 0;

    public void increase() {
        for (int i = 0; i < 10000; i++) {
            count++;
        }
    }

    public void decrease() {
        for (int i = 0; i < 10000; i++) {
            count--;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        VisibilityDemo demo = new VisibilityDemo();
        Thread t1 = new Thread(demo::increase);
        Thread t2 = new Thread(demo::decrease);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(demo.count); // 输出结果可能不正确
    }
}

在上面的示例代码中,我们创建了一个VisibilityDemo类,其中包含一个共享变量count和一个volatile修饰符。在increase()decrease()方法中,我们对count进行增加和减少操作,并加上volatile修饰符。这样,每次读取或写入count时都会从主内存中读取最新的值,从而保证了可见性。因此,在main()方法中,每个线程都能够正确地读取到其他线程对count的修改,从而保证了可见性,最终输出的结果也是正确的。

然而,由于Java编译器可能会对代码进行优化,导致指令重排的发生。例如,在下面的代码中,线程1先执行了count++操作,然后线程2执行了count--操作。但是,由于编译器可能会将这两个操作重新排序,使得线程2先执行了count--操作,从而导致其中一个线程对共享变量的修改对另一个线程不可见。

public class VisibilityDemo {
    private volatile int count = 0;

    public void increase() {
        for (int i = 0; i < 10000; i++) {
            count++;
        }
    }

    public void decrease() {
        for (int i = 0; i < 10000; i++) {
            count--;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        VisibilityDemo demo = new VisibilityDemo();
        Thread t1 = new Thread(demo::increase);
        Thread t2 = new Thread(demo::decrease);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(demo.count); // 输出结果可能不正确
    }
}

为了解决这个问题,我们可以使用Java中的synchronized关键字来保证原子性。在increase()decrease()方法中,我们对count进行增加和减少操作,并加上synchronized修饰符。这样,每次只有一个线程能够进入该方法,从而保证了原子性。因此,在main()方法中,每个线程都能够正确地读取到其他线程对count的修改,从而保证了可见性,最终输出的结果也是正确的。

伪共享代码

基于缓存行的操作下,一个缓存行是64个字节,而在Java中一个long类型是8个字节,因此一个缓存行中可以存8个long类型的变量。如果当前访问的是一个long类型数组,当数组中的一个值被加载到缓存中时,也会同步加载另外7个,因此在这种情况下,CPU可以减少和内存的交互,快速完成这些数据的计算,这个是缓存行所带来的优势。
但是,假设存在这样一种情况,就是当多个线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响到彼此的性能,这就是伪共享问题。
如下图所示,在CPU核心0上运行的线程想更新变量X,同时核心2上的线程想要更新变量Y,由于X、Y、Z都在同一个缓存行中,每个线程都要去竞争缓存行的所有权来更新变量,如果核心1获得了所权,缓存子系统将会使核心2中对应的缓存行失效。当核心2获得了所有权然后执行更新操作,核心1就要使自己对应的缓存行失效。这会来来回回的经过L3缓存,大大影响了性能。如果互相竞争的核心位于不同的插槽,就要额外横跨插槽连接,问题可能更加严重。
以下是对给定的Java代码添加注释说明:
在这里插入图片描述

public class CacheLineExample implements Runnable {
    public final static long ITERATIONS = 500L * 1000L * 100L; // 定义一个常量,表示迭代次数
    private int arrayIndex = 0; // 定义一个私有变量,表示数组索引
    private static ValueNoPadding[] longs; // 定义一个静态ValueNoPadding数组

    public CacheLineExample(final int arrayIndex) {
        this.arrayIndex = arrayIndex; // 构造函数,初始化数组索引
    }

    public static void main(final String[] args) throws Exception {
        for (int i = 1; i < 10; i++) {
            System.gc(); // 调用垃圾回收器
            final long start = System.currentTimeMillis(); // 记录当前时间
            runTest(i); // 运行测试
            System.out.println(i + " Threads, duration = " + (System.currentTimeMillis() - start)); // 输出结果
        }
    }

    private static void runTest(int NUM_THREADS) throws InterruptedException {
        Thread[] threads = new Thread[NUM_THREADS]; // 定义线程数组
        longs = new ValueNoPadding[NUM_THREADS]; // 定义ValueNoPadding数组
        for (int i = 0; i < longs.length; i++) {
            longs[i] = new ValueNoPadding(); // 初始化每个ValueNoPadding对象
        }
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new CacheLineExample(i)); // 创建并启动线程
        }
        for (Thread t : threads) {
            t.start(); // 启动线程
        }
        for (Thread t : threads) {
            t.join(); // 等待线程执行完毕
        }
    }

    @Override
    public void run() {
        long i = ITERATIONS + 1;
        while (0 != --i) {
            longs[arrayIndex].value = 0L; // 将ValueNoPadding对象的值设置为0
        }
    }

    public final static class ValuePadding {
        protected long p1, p2, p3, p4, p5, p6, p7;
        protected volatile long value = 0L;
        protected long p9, p10, p11, p12, p13, p14;
        protected long p15;
    }

    @Contended // 实现对齐填充
    public final static class ValueNoPadding {
        // protected long p1, p2, p3, p4, p5, p6, p7;
        // 8字节
        protected volatile long value = 0L; // 将ValueNoPadding对象的值设置为0
        // protected long p9, p10, p11, p12, p13, p14, p15;
    }
}

这段代码实现了一个简单的并发程序,通过多线程来模拟对缓存行的操作。主要包含以下几个部分:

  1. CacheLineExample类实现了Runnable接口,用于在多个线程中执行任务。
  2. main方法中,创建了10个线程,并循环执行测试,输出每次测试的结果和耗时。
  3. runTest方法中,初始化了线程数组和ValueNoPadding数组,并为每个线程创建了一个CacheLineExample对象,然后启动线程并等待它们执行完毕。
  4. run方法是Runnable接口的实现,在每个线程中执行一定次数的循环操作,将ValueNoPadding对象的值设置为0。
  5. ValuePadding类是一个内部类,包含了一些成员变量和方法,用于演示对齐填充的概念。
  6. ValueNoPadding类是对齐填充的实现,只占用8字节的内存空间,其中的value成员变量用于存储值。

在如上代码中,演示了在构建ValuePadding和ValueNoPadding带填充和不带填充的情况下,CPU的处理性能。从演示结果中可以很明显看出,不带填充效果的程序处理会非常慢。在Java 8中,可以采用@Contended在类级别上的注释,来进行缓存行填充。这样,多线程情况下的伪共享冲突问题。 其实,@Contended注释还可以应用于字段级别(FieldLevel),当应用于字段级别时,被注释的字段将和其他字段隔离开来,会被加载在独立的缓存行上。在字段级别上,
@Contended还支持一个“contention group”属性(ClassLevel不支持),同一个group的字段们在内存上将是连续,但和其他字段隔离开来。执行时,必须加上虚拟机参数-XX:-RestrictContended,
@Contended注释才会生效。

内存屏障

CPU在性能优化道路上导致的顺序一致性问题,在CPU层面无法被解决,原因是CPU只是一个运算工具,它只接收指令并且执行指令,并不清楚当前执行的整个逻辑中是否存在不能优化的问题,也就是说硬件层面也无法优化这种顺序一致性带来的可见性问题。
因此,在CPU层面提供了写屏障、读屏障、全屏障这样的指令,在x86架构中,这三种指令分别是SFENCE、LFENCE、MFENCE指令,

  • sfence:也就是save fence,写屏障指令。在sfence指令前的写操作必须在sfence指令后的写操作前完成。
  • lfence:也就是load fence,读屏障指令。在lfence指令前的读操作必须在lfence指令后的读操作前完成。
  • mfence:也就是modify/mix,混合屏障指令,在mfence前得读写操作必须在mfence指令后的读写操作前完成。

在Linux系统中,将这三种指令分别封装成了, smp_wmb-写屏障、smp_rmb-读屏障、smp_mb-读写屏障三个方法。

总结可见性问题本质

如下图所示,基于上述分析,我们基本上了解到,导致可见性问题的本质,是因为CPU层面的不断优化,从最早的CPU高速缓存、到Store Buffer,本意上是提高CPU的利用率,但是实际上却因为这样一个优化带来了缓存一致性问题。
为了解决CPU的缓存一致性问题,CPU提供了总线锁、缓存锁的机制,只需要在总线上声明Lock#信号,CPU便会增加锁的机制来解决缓存一致性问题。
但是CPU对于缓存锁的优化还不满足,因此增加了store buffer机制,进一步提升CPU利用率,而由此就导致了指令重排序的问题,这种指令重排序最终在程序中的影响就是,线程产生了脏读,也就是所谓的可见性问题。
在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值