JUC并发编程之剖析volatile底层原理

目录

JUC并发编程之剖析volatile底层原理

CPU高速缓存(Cache Memory)

带有高速缓存的CPU执行计算的流程

目前流行的多级缓存结构

多核CPU多级缓存一致性协议MESI

MESI协议缓存状态

MESI状态转换

多核缓存协同操作

​编辑单核读取

双核读取

修改数据

同步数据

​编辑volatile保证可见性 - 底层缓存一致性协议MESI[重点]

提出问题:

接下来进行揭晓:

volatile保证不了原子性 - 底层分析[重点]

为什么volatile保证不了原子性???

分析:

缓存行伪共享

MESI优化和他们引入的问题

CPU切换状态阻塞解决-存储缓存(Store Bufferes)

Store Bufferes

Store Bufferes的两个风险

硬件内存模型



JUC并发编程之剖析volatile底层原理

CPU高速缓存(Cache Memory)

CPU在摩尔定律的指导下以每18个月翻一番的速度在发展,然而内存和硬盘的发展速度远远不及CPU。这就造成了高性能能的内存和硬盘价格及其昂贵。然而CPU的高度运算需要高速的数据。为了解决这个问题,CPU厂商在CPU中内置了少量的高速缓存以解决I\O速度和CPU运算速度之间的不匹配问题。

在CPU访问存储设备时,无论是存取数据抑或存取指令,都趋于聚集在一片连续的区域中,这就被称为局部性原理。

时间局部性(Temporal Locality):如果一个信息项正在被访问,那么在近期它很可能还会被再次访问。

比如循环、递归、方法的反复调用等。

空间局部性(Spatial Locality):如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。

比如顺序执行的代码、连续创建的两个对象、数组等。

带有高速缓存的CPU执行计算的流程

https://www.processon.com/diagraming/6400394f5741de2e05c10a71

1.程序以及数据被加载到主内存中

2.指令和数据被加载到CPU的高速缓存

3.CPU执行指令,把结果写到高速缓存

4.高速缓存中的数据写回到主内存

目前流行的多级缓存结构

由于CPU的运算速度超越了1级缓存的数据I\O能力,CPU厂商又引入了多级的缓存结构。

多级缓存结构

多核CPU多级缓存一致性协议MESI

多核CPU的情况下有多个一级缓存,如何保证缓存内部数据的一致,不让系统数据混乱。这里就引出了一个一致性协议MESI。

MESI协议缓存状态

MESI 是指4种状态的首字母。每个Cache line有4个状态,可用2个bit表示,它们分别是:

缓存行:缓存数据存储的单元,一个缓存行为64byte(字节)

M:Modified 修改  E:Exclusive 独享,互斥

S: Sharede 共享    I:Invalid 无效

状态

描述

监听任务

M 修改 (Modified)

该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。

缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。

E 独享、互斥 (Exclusive)

该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。

缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。

S 共享 (Shared)

该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。

缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。

I 无效 (Invalid)

该Cache line无效。

举个例子:来说明MESI

线程1和线程2开启,会对x变量进行操作。

线程1先进行操作x时,为x&E。线程2之后也操作访问x,则线程1状态发生变化,为x&E->S,线程2为:x&S

线程2对于x进行修改值的操作,则线程1状态又发生变化,为x&E->S->I。线程2状态也发生变化,为x&S->M

注释:M表示修改,E表示独享,S表示共享,I表示无效

注意:

对于M和E状态而言总是精确的,他们在和该缓存行的真正状态是一致的,而S状态可能是非一致的。如果一个缓存将处于S状态的缓存行作废了,而另一个缓存实际上可能已经独享了该缓存行,但是该缓存却不会将该缓存行升迁为E状态,这是因为其它缓存不会广播他们作废掉该缓存行的通知,同样由于缓存并没有保存该缓存行的copy的数量,因此(即使有这种通知)也没有办法确定自己是否已经独享了该缓存行。

从上面的意义看来E状态是一种投机性的优化:如果一个CPU想修改一个处于S状态的缓存行,总线事务需要将所有该缓存行的copy变成invalid状态,而修改E状态的缓存不需要使用总线事务。

MESI状态转换

理解该图的前置说明:

1.触发事件

触发事件

描述

本地读取(Local read)

本地cache读取本地cache数据

本地写入(Local write)

本地cache写入本地cache数据

远端读取(Remote read)

其他cache读取本地cache数据

远端写入(Remote write)

其他cache写入本地cache数据

2.cache分类:

前提:所有的cache共同缓存了主内存中的某一条数据。

本地cache:指当前cpu的cache。

触发cache:触发读写事件的cache。

其他cache:指既除了以上两种之外的cache。

注意:本地的事件触发 本地cache和触发cache为相同。

上图的切换解释:

状态

触发本地读取

触发本地写入

触发远端读取

触发远端写入

M状态(修改)

本地cache:M 

触发cache:M

其他cache:I

本地cache:M 

触发cache:M

其他cache:I

本地cache:M→E→S

触发cache:I→S

其他cache:I→S

同步主内存后修改为E独享,同步触发、其他cache后本地、触发、其他cache修改为S共享

本地cache:M→E→S→I

触发cache:I→S→E→M

其他cache:I→S→I

同步和读取一样,同步完成后触发cache改为M,本地、其他cache改为I

E状态(独享)

本地cache:E

触发cache:E

其他cache:I

本地cache:E→M

触发cache:E→M

其他cache:I

本地cache变更为M,其他cache状态应当是I(无效)

本地cache:E→S

触发cache:I→S

其他cache:I→S

当其他cache要读取该数据时,其他、触发、本地cache都被设置为S(共享)

本地cache:E→S→I

触发cache:I→S→E→M

其他cache:I→S→I

当触发cache修改本地cache独享数据时时,将本地、触发、其他cache修改为S共享.然后触发cache修改为独享,其他、本地cache修改为I(无效),触发cache再修改为M

S状态(共享)

本地cache:S

触发cache:S

其他cache:S

本地cache:S→E→M

触发cache:S→E→M

其他cache:S→I 

当本地cache修改时,将本地cache修改为E,其他cache修改为I,然后再将本地cache为M状态

本地cache:S

触发cache:S

其他cache:S

本地cache:S→I

触发cache:S→E→M

其他cache:S→I

当触发cache要修改本地共享数据时,触发cache修改为E(独享),本地、其他cache修改为I(无效),触发cache再次修改为M(修改)

I状态(无效)

本地cache:I→S或者I→E

触发cache:I→S或者I →E

其他cache:E、M、I→S、I

本地、触发cache将从I无效修改为S共享或者E独享,其他cache将从E、M、I 变为S或者I

本地cache:I→S→E→M

触发cache:I→S→E→M

其他cache:M、E、S→S→I

既然是本cache是I,其他cache操作与它无关

既然是本cache是I,其他cache操作与它无关

下图示意了,当一个cache line的调整的状态的时候,另外一个cache line 需要调整的状态。

M

E

S

I

M

×

×

×

E

×

×

×

S

×

×

I

举个栗子来说:

假设cache 1 中有一个变量x = 0的cache line 处于S状态(共享)。

那么其他拥有x变量的cache 2、cache 3等x的cache line调整为S状态(共享)或者调整为 I 状态(无效)。

多核缓存协同操作

假设有三个CPU A、B、C,对应三个缓存分别是cache a、b、 c。在主内存中定义了x的引用值为0。

单核读取

那么执行流程是:

CPU A发出了一条指令,从主内存中读取x。

从主内存通过bus读取到缓存中(远端读取Remote read),这是该Cache line修改为E状态(独享).

双核读取

那么执行流程是:

CPU A发出了一条指令,从主内存中读取x。

CPU A从主内存通过bus读取到 cache a中并将该cache line 设置为E状态。

CPU B发出了一条指令,从主内存中读取x。

CPU B试图从主内存中读取x时,CPU A检测到了地址冲突。这时CPU A对相关数据做出响应。此时x 存储于cache a和cache b中,x在chche a和cache b中都被设置为S状态(共享)。

修改数据

那么执行流程是:

CPU A 计算完成后发指令需要修改x.

CPU A 将x设置为M状态(修改)并通知缓存了x的CPU B, CPU B将本地cache b中的x设置为I状态(无效)

CPU A 对x进行赋值。

同步数据

那么执行流程是:

CPU B 发出了要读取x的指令。

CPU B 通知CPU A,CPU A将修改后的数据同步到主内存时cache a 修改为E(独享)

CPU A同步CPU B的x,将cache a和同步后cache b中的x设置为S状态(共享)。

volatile保证可见性 - 底层缓存一致性协议MESI[重点]

提出问题:

在一个CPU多核多线程的情况下,对于一个变量x加上了关键字volatile后,底层具体干了什么来实现volatile的可见性的?

private static volatile int x ;


public static void test01() {

    new Thread(()->{
        x=2;
    },"thread-a").start();

    new Thread(()->{
        x=3;
    },"thread-b").start();

}

先看看加上volatile关键字后代码进行转化到汇编指令后的形式:

接下来进行揭晓:

(1) 最初的方案:总线锁。

对于一个x加了volatile关键字后,保证了可见性。底层最初的方案,我们是在bus总线处加上了一个总线锁,一个CPU多核(多核说明有多个线程)可以对同一个x进行操作,但加上一个总线锁后,把通往内存的通道给锁死了。一个线程进行操作内存数据时,其它线程就不可进行操作啦。虽然保证了变量x的可见性,但是这种总线锁的模式把多核多线程的性能给完全丧失啦,所以之后引出了缓存一致性MESI优化理论

(2) 最终优化的方案,也是目前的方案:MESI缓存一致性协议

MESI优化理论说明:

 如图所示:ProcessOn FlowchartProcessOn Flowchart

从(1)中我们可以看出总线锁这种方式的弊端,所以使用MESI理论进行优化它。

MESI理论的情况比较复杂:

情况1:【在存储的变量x是小于缓存行(64字节)并且两个线程非同时修改变量x的】

对于多核多线程对变量x进行修改,我们把变量x的值写到本地缓存行中,本地缓存行的大小为64byte。不同核对应不同的线程,不同线程对应不同的缓存行来存储变量x。

由图可知:

thread a首先占用到了x,由于为独享,所以标记为x=2&E。

但是很快,线程thread b也会占用到x,所以此时x为共享,线程thread a标记x=2&E->S,thread b标记x=0&S。

之后thread b在thread a之前进行修改了x的值,此时会发出一个通知告诉thread a:"x已经被其它线程修改啦",那么此时thread a对应的x变量的空间就应该丢弃啦。所以此时thread a标记为x=2&E->S->I,thread a标记为x=3&S->M。

最后thread a会重新进行分配x,即:x=0->3

注释补充:其实在修改x变量值的时候,有一种假设,给缓存行进行上一个缓存行lock锁的,但是对于不同线程的缓存lock对象肯定是不同的,因为不同线程的缓存行是不共享的。那么上lock就没有任何的意义,所以这里使用到一个"通知"来保证多线程环境下,线程b修改了变量x,其它线程对此及时可见,赶紧丢弃的效果。

情况2:如果两个线程同时进行修改变量x的话

多核多线程的情况下,由于CPU的运行效率是非常高速的,所以同时进行修改变量x是有很大概率的。因此引出了"总线裁决"。总线裁决是一瞬间的裁决,根据CPU底层机制进行选定到底让哪一个线程的修改结果生效的决定。反正只会有一个线程的修改生效,然后会通知其它线程进行丢弃该修改的变量。

情况3:如果变量的大小大于缓存行的空间大小时

只说一句话,缓存一致性协议MESI升级为之前的总线锁(已解释过)。因为使用多个缓存行去存储同一个变量,无法保证操作的原子性。

volatile保证不了原子性 - 底层分析[重点]

private static volatile int counter ;

//伪代码
public static void test01() {

    new Thread(()->{
        counter++;//底层分为两步:1.counter=0 2.counter=counter+1
    },"thread-a").start();

    new Thread(()->{
        counter++;
    },"thread-b").start();

}

为什么volatile保证不了原子性???

通过前面的分析可知,volatile是可以保证可见性的,但是保证不了原子性。

分析:

如图所示:ProcessOn Flowchart

 counter++的底层分为两步:1.counter=0 2.counter=counter+1。

(1) thread-a线程从缓存行中读取这两步对应的指令到寄存器中进行执行,执行完毕后把counter=1的结果写回到缓存行中。根据前面的MESI缓存一致性原则可知,此时我们会给还没有执行完的thread-b线程发出一个"通知",告诉thread-b:你的counter失效啦!!现在counter=1

(2) 但是此时thread-b对于自己执行的counter=counter+1这个指令操作已经读取到寄存器中正在执行了,只有缓存行中的counter=0指令才会失效,此时缓存行的counter=1。但是寄存器中的counter=counter+1指令会正常执行完毕(读取到寄存器时的counter=0,所以最终结果为counter=1)。最终结果counter=1会覆盖thread-b后的缓存行的counter=1,故最终thread-b的结果为:counter=1。

(本以为thread-a通知thread-b:counter变为了1后,最终结果会为2,但是为1,符合可见性,但是不符合原子性)。

(3) 最终thread-a得出counter++后得出的结果为counter=1,thread-b得出的结果也为counter=1,两个线程一块写回到内存中去。会覆盖的写回,最终结果就是counter=1

(4) 这个时候出现问题:明明执行了两次counter++,为什么最终counter=1????

总结:出现这种情况,就是因为volatile保证不了多条指令的原子性。

缓存行伪共享

什么是伪共享?

CPU缓存系统中是以缓存行为单位进行存储的。目前主流的CPU Cache 的 Cache Line 大小都是64Bytes。在多线程情况下,如果需要修改“共享同一个缓存行的变量”,就会无意中影响彼此的性能,这就是伪共享(False Sharing)。

举个例子:

现在有2个long 型变量 a 、b,如果有t1在访问a,t2在访问b,而a与b刚好在同一个cache line中,此时t1先修改a,将导致b被刷新

怎么解决伪共享?

Java8中新增了一个注解:@sun.misc.Contended。加上这个注解的类会自动补齐缓存行,需要注意的是此注解默认是无效的,需要在jvm启动时设置 -XX:-RestrictContended 才会生效。

@sun.misc.Contended
public final static class TulingVolatileLong {
    public volatile long value = 0L;
    //public long p1, p2, p3, p4, p5, p6;
}

MESI优化和他们引入的问题

缓存的一致性消息传递是需要时间的,这就使得切换时会产生延迟。当一个缓存被切换时其它缓存收到消息完成各自的切换并且发出消息这么一大长串的时间中CPU都会进行等待所有缓存响应完成。可能出现的阻塞都会导致各种各样的性能问题和稳定性问题。

CPU切换状态阻塞解决-存储缓存(Store Bufferes)

比如你需要修改本地缓存中的一条信息,那么你就必须将I(无效)状态通知到其它拥有该缓存数据的CPU缓存中,并且等待确认。等待确认的过程会阻塞处理器,这会降低处理器的性能。应为这个等待远远比一个指令的执行时间长的多。

Store Bufferes

为了避免这种CPU运算能力的浪费,Store Bufferes被引入使用。处理器把它想要写入到主存的值写到Store Bufferes这一缓存,然后继续去处理其它事情。当所有失效确认都接收到时,数据才会最终被提交。

但是使用Store Bufferes有两个风险。

Store Bufferes的两个风险

第一:就是处理器会尝试从存储缓存(Store Buffer)中读取值,但是它没有进行提交。这个的解决方案为Store Forwarding,它使得加载的时候,如果存储缓存中存在,则进行返回。

第二:保存什么时候会完成,这个并没有任何保证。

value = 3;
void exeToCPUA(){
  value = 10;
  isFinsh = true;
}
void exeToCPUB(){
  if(isFinsh){
    //value一定等于10?!
    assert value == 10;
  }
}

试想一下开始执行时,CPU A保存着finished在E(独享)状态,而value并没有保存在它的缓存中。(例如,Invalid)。在这种情况下,value会比finished更迟地抛弃存储缓存。完全有可能CPU B读取finished的值为true,而value的值不等于10。

即isFinsh的赋值在value赋值之前。

这种在可识别的行为中发生的变化称为重排序(reordings)。注意,这不意味着你的指令的位置被恶意(或者好意)地更改。

它只是意味着其他的CPU会读到跟程序中写入的顺序不一样的结果。

顺便提一下NIO的设计和Store Bufferes的设计是非常相像的。

硬件内存模型

执行失效也不是一个简单的操作,它需要处理器去处理。另外,存储缓存(Store Buffers)并不是无穷大的,所以处理器有时需要等待失效确认的返回。这两个操作都会使得性能大幅降低。为了应付这种情况,引入了失效队列。它们的约定如下:

  • 对于所有的收到的Invalidate(失效)请求,Invalidate Acknowlege(确认)消息必须立刻发送
  • Invalidate请求(失效请求)并不真正执行,而是被放在一个特殊的队列中,在方便的时候才会去执行。具体什么时候方便,根据处理器进行决定
  • 处理器不会发送任何消息给所处理的缓存条目,直到它处理Invalidate。

注:Invalidate意思为:失效

即便是这样处理器已然不知道什么时候优化是允许的,而什么时候并不允许。

干脆处理器将这个任务丢给了写代码的人。这就是内存屏障(Memory Barriers)

写屏障 Store Memory Barrier(a.k.a. ST, SMB, smp_wmb)是一条告诉处理器在这执行之后的指令之前,进行应用所有已经在存储缓存(Store Buffer)中的保存的指令。

读屏障Load Memory Barrier (a.k.a. LD, RMB, smp_rmb)是一条告诉处理器在执行任何的加载前,先应用所有已经在失效队列中的失效操作的指令。

举例:

void executedOnCpu0() {
    value = 10;
    //写屏障:在更新数据之前必须将所有存储在存储缓存(Store buffer)中的指令执行完毕
    storeMemoryBarrier();
    finished = true;
}
void executedOnCpu1() {
    while(!finished);
    //读屏障:在读取之前将所有失效队列中关于该数据的指令执行完毕
    loadMemoryBarrier();
    assert value == 10;
}

加入Store Buffer和Invalidate Queue后的整体图:

ProcessOn Flowchart

参考资料:《并发编程的艺术》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值