理解java中的volatile关键字

你好,这篇文章总结一下java中的volatile关键字。

从CPU讲起

大家都知道,存储设备容量越大越贵,速度越快越贵。
而计算机可以简单的看作为三步

  1. 输入
  2. 处理
  3. 输出

早期计算机使用的是穿孔纸袋作为输入,可以想象,输入的速度有多么的低~
随着计算机的发展,处理速度和输入速度都取得了飞跃性的发展。而处理速度更是远超输入速度。
为了弥补CPU与内存之间速度的鸿沟,有了DRAM(也就是我们说的内存),SRAM(L1CACHE,L2CACHE使用的叫做SRAM,构造复杂,价格比DRAM高得多)

从CPU到DRAM,中间还有:
TLB(快表,0周期)
L1CACHE(L1缓存,一些地方给出的数据是仅有1CPU周期的延迟)
L2CACHE(L2缓存,有更多延迟)
L3CACHE(L3缓存,更多更多的延迟)

当CPU想请求某个字的时候,将首先从L1缓存中去读,如果读不到,将从L2中读。再读不到,继续去L3读。

如果直接读到了缓存,这就是缓存命中(cache hit)。如果没读到,就是缓存不命中(cache miss).

缓存不命中(cache miss)有三种情形。

  1. 冷不命中(cold miss),
  2. 冲突不命中(conflict miss)
  3. 容量不命中(capacity miss)
    后续的文章可能会详细介绍

以上是读相关的,写相关的更容易理解一些。
写分为 write-through 和 write-back
write-throuth理解为写穿,指的是将写的结果一路穿透到最下层的存储器。(理解为同步刷盘)
write-back理解为写即回,写了之后将写的内容标记一下,只有当需要更新的时候,再将这些内容写到下一层的缓存。(理解为异步刷盘)

MESI一致性协议

大家都知道,并发问题的根本原因都是出现了竞态(race condition)。

为了消除竞态,业界有很多种办法。

最好的方式当然就是从一开始就不出现并发问题,比如严格控制线程之间的调度顺序(hash的一些运用),单线程操作(redis),特殊的数据结构(disruptor)

更次一点的,比如java中的string,是不可变(immutable)的,这样也从根本消除了竞态。

最差的解决方案便是加锁了。

我主业是java,碰到有问题的变量一个synchronized就套上去,很快啊。

再比如数据库,比如mysql,各种奇奇怪怪的锁,什么intention lock啊,insert intention lock啊,balabala的。复杂又不友好。

高层应用有这么多的方法解决竞态,那么cpu呢?cpu现在都是多核的了,cpu怎么解决并发写?
进入正题。
MESI协议(Modified-Exclusive-Shared-Invalid) 是一种缓存一致性协议,用于保证缓存一致。

CPU的结构

在开始之前,先简单描述一下CPU的逻辑结构

如图,每个CPU都有自己的缓存--高速CACHE,只有自身可以进行读写

CPU与CPU之间有共享缓存

CPU与CPU之间靠总线交互信息

写缓冲器 在后文介绍

MESI的目的

像一开始说的那样,CPU真的是太快太快了,能跟得上CPU速度的存储介质太贵太贵了。

为了省钱,越快的缓存越小,越靠近CPU。

CPU运算出来的结果先存到靠近CPU的高速CACHE中,高速缓存满了,再下放到更低一层地存储介质里,如此往复。

每个CPU都有自己的高速缓存,在大家都只是读数据的情况下,这当然没有啥并发问题---就像java中的string一样,都只是读罢了,完全没有竞态。

那如果CPU-0想写数据呢? 假设CPU-0要将某段内存地址A的值从0变成3,而CPU-1中的高速缓存仍然存储A的值为0,这两个CPU的值不就不一样了?

这岂不是读脏数据?

为了解决这种问题,我们有了MESI协议。

什么是MESI

MESI没有啥很高大上的含义,是几个单词的缩写

Modified---Exclusive---Shared---Invalid

M---E---S---I

yo

每条数据,在这条数据上,会有一个位置来存储这个数据当前的状态

这个状态 只有四种

从一个CPU的视角出发.

Modified: 这条数据已经被我改变过了,我可以随意操作

Exclusive: 这条数据是我独享的,我可以随意操作

Shared: 这条数据可能被其它CPU也读到了,我要小心谨慎一点

Invalid : 这条数据失效啦!不再是我的了

想象消费者生产者模式,理解一下

每个CPU,自己又是消费者,又是生产者。

自己消费的内容有四种,如下图,包括Read,Invalidate,Read Invalidate,Writeback。

自己能生产的消息只有两种,Read Response, Invalidate Acknowledge。

看到这你也许已经晕了,没关系,这张图不重要,可以回头再看。结合下面这张图,来实际的运用一次。

请看图,CPU-0要读一个数据,发现,欸,这条数据在我的高速缓存里,但状态是I,意思这条数据不再是我的了。那我得去别的地方读。

于是CPU-0进行一个总线的广播,CPU-1接到了这条消息,于是CPU-1从自己的高速CACHE里拿这个地址的数据(这里注意,是从自己的高速缓存里而不是写缓冲器里!对后面的理解重排序很重要!)。

CPU-1发现,欸,这条数据是M状态,意思是被我修改过,也许其它CPU还不知道这条数据被我修改过呢?我得赶紧把这条被我修改过的数据高速其它CPU,于是CPU-1把这条数据写到共享CACHE里,再通过总线响应Read Response给CPU-0这条数据的当前值。然后这条数据的状态变成了S(CPU-0现在也知道了这条数据)

于是CPU-0总能读到一条数据的最新值。

这就是MESI协议在读上的运用啦

再来看看写

来,一步步走。

还是从CPU-0开始。

CPU-0再次没有从自己独占的高速缓存中找到这条地址(或者发现这条地址是I)

于是在总线广播Read Invalidate,要求接到这条消息的CPU,立马把这条地址的数据给我,并且你们的这条数据要置为I(要失效)

CPU-0的意思很明确啦,我就是要让其它CPU的这条数据都失效掉,我要独占这条数据~!

其它CPU接收到Invalidate Acknowledge消息后,也很听话的失效掉了自己的这条记录,然后返回Invalidate Acknowledge给CPU-0

发出投名状 "我们都很听话的失效掉了自己的这条记录啦"

CPU-0收到所有CPU的返回后,异常满意。

于是将这条数据的状态变成E(独占成功)

变成了自己的独占数据后,自然可以为所欲为的写数据了~

到了这里,有的读者可能发出了智慧的声音,“有没有可能多个CPU想同时独占某条数据呢?"

有可能。当然了,也是有解决方案的,总线仲裁(bus arbitration)。就不细讲了。

写缓冲器

其实这里还有一个问题

CPU-0广播出Read Invalidate后,一定要等待其它CPU返回Invalidate Acknowledge,才能去写这条数据吗?

并不是

CPU-0会直接将要写入的相关数据存入写缓冲器,并发送Read Invalidate消息。不进行等待,直接执行其它命令,不傻等,以此提高自身效率。

等其它CPU的回复都到达后,CPU-0才会将这个写缓冲器中的写操作真正写入到自己的高速缓存行中。

存储转发

因为引入了写缓冲器,那么CPU对某个数据的改变结果有可能还在自己的写缓冲器中,这个数据的内存地址对应的缓存行是老值,

所以执行读操作的时候,会现在自己的写缓冲器读一下,如果里面存在对应条目,直接作为读结果返回。否则才去自己的高速缓存里读。

这种直接从写缓冲器读取数据的技术,叫做存储转发

内存重排序

写缓冲器和存储转发都有可能导致内存重排序

以一个例子解释一下

如图,假设A和B一开始都为0,T1和T2是两个变量。此执行顺序按时间从上至下。

按期望来说,最后结果应该是T1=3, T2=1

在T1时刻,CPU-0让A从0变为1,CPU-1将B从0变为3,但CPU-1的操作仅仅只是放入写缓冲器

所以在T2时刻,CPU-0读到的B的值,可能还是0

这就导致最后结果变成了, T1=0,T2=1

那么对CPU-0来讲,就好像B=3的操作是T2=A之后才进行的

在CPU-0的视角中,CPU-1的写操作(B=3)被延迟到读操作(读A的值,T2=A)后的行为,叫做StoreLoad重排序(Stores Reordered After Loads)

同样,写存储器还会引起StoreStore重排序,再举一个例子

假设:A的值初始为0,mutex的值初始为0。一开始执行的时候,CPU-0的高速CACHE有mutex的副本,但没有A的副本。

所以执行A=3会进入写缓冲器中,而执行mutex=1会通过MESI协议同步给CPU-1

到了T3时刻,对于CPU-1来讲,mutex=1了,可以继续往下执行

T4时刻,对于CPU-1来讲,变量A仍然是0,因为对A的改变还在CPU-0的写缓冲器中

在CPU-1的视角中,CPU-0的写操作(A=3)被延迟到写操作(mutex=1)后的行为,叫做StoreStore重排序

解决重排序的方法--内存屏障

StoreLoad屏障--解决StoreLoad重排序

对于一个指令序列:

Store1; Store2; Store3; StoreLoadBarrier; Load1; Load2; Load3;

StoreLoadBarrier将禁止该屏障前后的重排序

也就是说,Load1,Load2,Load3,一定在Store1,Store2,Store3之后发生

但是Store1,Store2,Store3有可能发生重排序,也即是实际执行的指令序列有可能是这样的:

Store3; Store1; Store2; StoreLoadBarrier; Load1; Load2; Load3;

Load1,Load2,Load3之间也有可能发生重排序

实现方式为,将写缓冲器的条目刷进高速CACHE。所以之后的读,一定能读到最新值。

StoreStore屏障--解决StoreStore重排序

Store1; Store2; Store3; StoreStoreBarrier Store4; Store5; Store6;

Store1,Store2,Store3的任何操作,对于Store4,Store5,Store6都是可见的。同样,屏障前后的操作也有可能发生重排序

实现方式为,将写缓冲器的现有条目进行标记,表示这些条目的写操作需要先于该屏障之后的写操作被提交。

处理器在执行写操作的时候,如果发现写缓冲器中存在被标记的条目,那么即使这个写操作对应的高速缓存状态为E或M,处理器也不将其直接写入高速缓存,而是写入写缓冲器。在写缓冲器里是按顺序刷进高速缓存的。

volatile关键字的原理

终于到了volatile了。

先看看JSR-133(Java Specification Requests)中JMM(Java Memory Model)对volatile的定义

 A write to a volatile field happens before every subsequent read of that volatile.

对volatile修饰的field的写,发生在任何读之前。

怎么做到的呢?其实就是在写volatile和读volatile中间,插入一个上文中提到的StoreLoad屏障

写volatile----->StoreLoadBarrier----->读volatile

StoreLoadBarrier的实现原理上文也提了---清空写缓冲器

这样就导致,任何一个CPU写了volatile后,都会将最新值放入高速CACHE中。

其它任何CPU,想要读这个volatile,必定能拿到最新值(还记得上面加粗的从高速CACHE中取值嘛?)

好了,这篇文章到这就结束了。

有一些地方没描述,或者是选择性地描述了---比如无效化队列,loadload重排序,再比如jvm在不同平台的不同表现。

还有一些地方解释的不清不楚的。

不过作为简单的描述型,总结型的文章,这样也够了。

最近在研究JSR-133,下一篇文章可能是这个方向的。

posted @ 2020-12-19 01:01  图师傅  阅读(210)  评论(0)    收藏  举报