一文带你深入了解volatile

三级缓存

CPU内核和内存之间传输数据的速度存在巨大差异,为缩小差距,在CPU内核和内存之间设置了三级缓存用于缓存CPU内核所需数据,从而达到加快CPU内核处理的目的。
从内核到内存分别是L1缓存、L2缓存和L3缓存。
L1缓存和L2缓存是内核专用缓存,一个CPU共享一个L3缓存。
以我现在所用电脑为例:L1缓存:256K,L2缓存1MB,L3缓存6MB。
从L1到L3,缓存越来越大,命中率也越来越高,但是缓存的延迟也会变高。

一文带你深入了解volatile

因为三级缓存的存在,内核和内存的通信变的极为复杂,也就存在许多并发问题。
下面我们以volatile为例,讲一下三大并发问题。

volatile 和 三大并发问题

  • 原子性

  • 可见性

  • 有序性

三大并发问题是指原子性、可见性和有序性问题,下面具体讲一下三大并发问题的影响。

原子性

volatile不保证原子性,下面两段代码是等价的。
volatile的读和写是原子性的。读出来再写进去这种复合操作就不保证原子性,所以volatile不保证原子性。
public class ThreadSafeInteger {
private int value;

public synchronized int get() {
return value;
}
public synchronized void set(int value) {
this.value = value;
}
}
public class ThreadSafeInteger {

private volatile int value;

public int get() {
return value;
}

public void set(int value) {
this.value = value;
}
}

可见性

以下面的代码为例:
core0读取running到缓存中,core3读取running到缓存中;
此时core0修改running为false,因为三级缓存的原因,core0只修改了自己的数据,并没有保存到内存中。
core3的缓存中还是true,也没有同步内存中的数据,当然内存中的数据也没改变。
这时core3的running永远是true,造成了并发问题。
public class Visibleness {
private boolean running = true;

void func() {
System.out.println("thread start……");
while (running){}
System.out.println("thread end……");
}

public static void main(String[] args) throws InterruptedException {
System.out.println("main start……");
Visibleness object = new Visibleness();
new Thread(object::func,"task01").start();
Thread.sleep(2 * 1000);
object.running = false;
System.out.println("main end……");
}
}

//输出:
main start……
thread start……
main end……
上面的代码永远不会输出“thread end……”,除非加上volatile修饰running,因为volatile保证可见性。

有序性

volatile通过插入内存屏障来禁止特定处理器的重排序操作。
内存屏障单纯的解释比较难以理解,后面会根据具体的情况解释内存屏障是如何实现的。
最后总结一下,volatile能解决有序性和可见性问题,volatile和cas一起使用能解决原子性问题。
下面我们继续探究volatile是怎么做到的。

lock前缀

对volatile变量赋值时,编译后的指令增加了lock指令的前缀:
lock add1 $0x0,(%esp)
因为增加了lock前缀,当对volatile变量进行写入操作时,会做两个动作:

1、会将处理器的缓存行立刻写入到内存中。

2、将其他内核缓存了该内存地址的数据设置为无效。


接下来探究一下lock前缀为什么能解决可见性和有序性问题。


Lock指令有两种实现方式:总线锁和缓存一致性机制。

总线锁

总线锁是非常重量级的一种锁,我们看一下总线锁的介绍:

1、CPU总线负责CPU和外部(高速缓存、内存等)通信。

2、使用总线锁会选择一个核心独占总线,其他内核不能和内存通信。

3、对于跨缓存行的数据使用总线锁。(缓存行后面会讲)

一开始CPU只提供总线锁,但总线锁开销比较大,毕竟自己占用了总线,所以后来又提出缓存一致性协议,也是这篇文章的重点。

缓存一致性机制

首先看一下什么是缓存行,CPU读取主存数据时,不会仅读CPU使用到的数据,而是会将周围的数据同时读取到缓存中,这也符合空间局部性原理

空间局部性是指被引用过的存储器位置附近的数据很可能将被引用;

缓存行是缓存和内存进行数据交换的最小单位,一般缓存行的大小是2的整数幂,Linux系统中缓存行的默认值是64byte,也就是说每次内核读取内存的数据都是64byte一起读取。
通过遍历数组的方式可以观察到缓存行的存在。
    int[][] array = new int[64 * 1024][1024];
long startTime1=System.currentTimeMillis(); //获取开始时间
// 横向遍历
for(int i = 0; i < 64 * 1024; i ++)
for(int j = 0; j < 1024; j ++)
array[i][j] ++;
long endTime1=System.currentTimeMillis(); //获取结束时间
System.out.println("程序运行时间:"+(endTime1-startTime1)+"ms");


// 纵向遍历
long startTime2=System.currentTimeMillis(); //获取开始时间
for(int i = 0; i < 1024; i ++)
for(int j = 0; j < 64 * 1024; j ++)
array[j][i] ++;
long endTime2=System.currentTimeMillis(); //获取结束时间
System.out.println("程序运行时间:"+(endTime2-startTime2)+"ms");

因为有缓存行的存在,顺序读取数据比无序读数据快很多

程序运行时间: 176ms
程序运行时间: 2513ms

缓存结构

一文带你深入了解volatile

  • 一个缓存有S个组

  • 一个组有E个缓存行

  • 每个缓存行包含三部分:vaild、tag和block

    • vaild 用于标识该数据的有效性

    • tag 用于指示数据对应的内存地址

    • block 用于存储数据(64byte)

缓存中的组概念我们了解下就行,主要是缓存行,下面我重点介绍下。


vaild主要保存缓存行的各种状态,内核修改了缓存行之后,会将这个状态告诉其他内核,vaild的值就会改变,这也是缓存一致性协议中非常重要的部分。


tag主要指示数据对应的内存地址。内核是不知道自己想要的数据是否在缓存中,只知道要获取的内存地址,所以内核读取内存数据前,会根据内存地址去缓存中判断是否存在这个数据。

MESI(缓存一致性协议)

为了保证缓存的一致性,目前常用两种思路:

写失效:当一个内核修改了数据,其他内核如果有这份数据,就把valid标识为无效 

写更新:当一个内核修改了数据,其他内核如果有这份数据,就更新为新值,标记valid有效。

缓存行状态

目前有多种缓存一致性协议,目前常用的是MESI协议,MESI协议使用的是写失效的思路。

在MESI协议中,缓存行valid有四种状态:


1、 M(Modified,被修改):缓存行中的数据只缓存在该内核的缓存中(为方便理解,这里忽略三级缓存),并被修改过。

当前缓存行中的数据和主存中的数据不一致,需要在某个时间写回到主存中。


如下图所示,core0将a改成了b,此时core0的vaild值变为了M。

一文带你深入了解volatile

2、 E(Exclusive,独享):该缓存行只被缓存在该内核的缓存中,并且是未被修改的,与主存中的数据一致。当其他内核读取该缓存行时变为共享状态。


如下图所示,仅一个core读取了该缓存行,不存在同步问题。

一文带你深入了解volatile

3、 S(Shared,共享):该缓存行可能被多个内核缓存,并且各缓存行中的数据和主数据一致,当有一个CPU修改数据时,其他内核中该缓存行中的数据被设置为无效状态。


如下图所示,两个core同时读取了相同的数据,数据并未被改变,所以vaild状态是S

一文带你深入了解volatile

4、 I(Invalid,无效):该缓存行无效


上面我们介绍了4种缓存行状态,下面我们看一下内核是怎么样知道并改变缓存行状态的。

一文带你深入了解volatile

  • 内核0修改data的数据为0,会先向所有内核广播,告诉其他内核要修改的数据

  • 内核1接收内核0的广播消息之后,会检查缓存中的包含该数据的缓存行

  • 内核1将查找到的缓存行删掉或者设置为无效状态

一文带你深入了解volatile

  • 内核0收到内核1的反馈后,会将数据保存到缓存行中,并修改valid为E或者M

  • 内核0保存数据到内存中。


上面的过程是一个非常理想的情况,但是存在一些问题。

内核之间通信非常快,内核0不可能等待内核1的消息之后才进行操作,所以要想办法解决这个瓶颈。

于是就有了存储缓冲这个buffer。

存储缓冲(Store Buffer)

一文带你深入了解volatile   
CPU中引入Store Buffer解决了CPU之间等待的问题,但是又引入了Buffer和Cache之间数据同步的问题。
为此,针对Store Buffer,CPU在后续变量新值写入之前,把Store Buffer的所有值按顺序刷新到内存中。这就称为内存屏障中的写屏障(Store Barrier)

无效队列(Invalidate Queue)

内核0广播数据修改之后,内核1不可能马上处理,因此引入无效队列,用于存放广播的无效数据。 一文带你深入了解volatile

当其他CPU收到无效指令时,不需要确认缓存行是否真正失效,而是先放到Invalidate Queue中,并返回无效指令确认;等待CPU空闲时再处理Invalidate Queue中的无效指令。


Invalidate Queue的引入解决了CPU不能及时回复消息的问题,但也带来了一些问题,比如未及时将缓存行设置为无效状态并使用了该缓存行。


为此,针对Invalidate Queue,执行后需要等待Invalidate Queue完全应用到内存后,后续读操作才继续执行,保证执行前后的读操作对其他CPU是顺序的,这也称为内存屏障中的读屏障(Load Barrier)。


上面的过程,我们了解了缓存一致性协议和内存屏障的实现,所以:

读屏障:获取其他内核修改,让当前内核中的数据为最新的值,也就是将Invalidate Queue中的数据应用到内核。 

写屏障:将内核的修改让其他内核可见,将storeBuffer的数据写入缓存/内存中

总结一下,lock前缀的指令,使用了缓存一致性协议和总线锁来解决可见性问题,同时也有full barrier的效果,保证了有序性。

CAS

最后说一下cas,使用Unsafe类的compareAndSwapXXX方法可以使用CAS。

CAS编译后,也会自动增加lock前缀。

CAS 主要是通过 lock cmpxchg 指令实现,单核 CPU 只需要 cmpxchg 指令,多核 CPU 使用 lock 前缀指令。
lock cmpxchg
volatile和cas一起使用可以实现原子性。

备注

1、内存屏障:内存屏障在实际应用中比较复杂,根据不同的策略使用不同的Store屏障和Load屏障。

2、StoreLoad也称为full屏障,是开销最大的内存屏障。


原文始发于微信公众号(Java不惑):一文带你深入了解volatile

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/24503.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!