一、volatile关键字
1、一开始我是真的一无所知对于这个关键字。看完链接【http://www.importnew.com/29860.html】讲解如下:
2、首先介绍volatile关键字之前需要了解很多的硬件内存,cpu知识【有表达不准确的赶紧告诉我哦,大佬们】,以及内存知识库中的一角。
3、讲解要领:从硬件的角度来理解什么是内存屏障,以及内存屏障如何让volatile工作,说明了在多线程中,如何使用volatile来提升性能
4、CPU的多级缓存结构中,最快的是store buffer和invalide queues,并且这两个是每个CPU私有的。所以会出现一些调度问题。CPU读取数据是以行的形式载入,成为缓存行(cacheline),对于多核CPU如果CPU1需要读取的数据在其他CPU中,则这些CPU之间会使用MESI缓存一致性协议来进行协调。以保证数据一致性。而store buffer的引入是为了不浪费CPU的使用率,满足CPU1在发出了read invalidete消息后,等待其他CPU返回read response 和 invalidate ack消息时,CPU继续做其他事情。但是引入以后对于单核的CPU又会出现数据读取错误情况,导致程序错误,例如模拟1:
模拟1:
1 2 3 |
|
假设a不在本cpu的cacheline中。在其他cpu的cacheline中,值为0.会有如下的步骤
序号 | 操作内容 |
---|---|
1 | 发现a的地址不在本cpu的cacheline中,向其他的cpu发送read invalidate消息 |
2 | 将数据写入store buffer中 |
3 | 收到其他cpu响应的read response和invalidate ack消息 |
4 | 执行b=a+1,因为a这个时候已经在cacheline中,读取到值为0,加1后为1,写入到b中 |
5 | 执行assert b==2 失败,因为b是1 |
6 | store buffer中的值刷新到a的cacheline中,修改a的值为1,但是已经太晚了 |
5、为了避免这个问题,所以对于store buffer的设计中增加一个策略叫做store forwarding。就是说cpu在读取数据的时候会先查看store buffer,如果store buffer中有数据,直接用store buffer中的。这样,也就避免了使用错误数据的问题了。
store forwarding可以解决在单线程中的数据不一致问题,但是store buffer所带来的复杂性远不止如此。在多线程环境下,会有其他的问题。下面是模拟代码2:
模拟2:
1 2 3 4 5 6 7 8 9 |
|
假设a和b的值都是0,其中b在cpu0中,a在cpu1中。cpu0执行set方法,cpu1执行print方法。
序号 | cpu0的步骤(执行set) | cpu1的步骤(执行print) |
---|---|---|
1 | 想写入a=1,但是由于a不在自身的cacheline中,向cpu1发送read invalidate消息 | 执行while(b==0),由于b不在自身的cacheline中,向cpu0发送read消息 |
2 | 向store buffer中写入a=1 | 等待cpu0响应的read response消息 |
3 | b在自身的cacheline中,并且此时状态为M或者E,写入b=1 | 等待cpu0响应的read response消息 |
4 | 收到cpu1的read请求,将b=1的值用read response消息传递,同时将b所在的cacheline修改状态为s | 等待cpu0响应的read response消息 |
5 | 等待cpu1的read response和invalidate ack消息 | 收到cpu0的read response消息,将b置为1,因此程序跳出循环 |
6 | 等待cpu1的read response和invalidate ack消息 | 因为a在自身的cacheline中,所以读取后进行比对。assert a==1失败。因为此时a在自身cacheline中的值还是0,而且该cacheline尚未失效 |
7 | 等待cpu1的read response和invalidate ack消息 | 收到cpu0发送的read invalidate消息,将a所在的cacheline设置为无效,但是 为时已晚,错误的判断结果已经产生了 |
8 | 收到cpu1响应的read response和invalidate ack消息,将store buffer中的值写入cacheline中 | 无 |
6、通过上面的例子可以看到,在多核系统中,store buffer的存在让程序的结果与我们的预期不相符合。上面的程序中,由于store buffer的存在,所以在cacheline中的操作顺序实际上先b=1
然后a=1
。为了解决这样的问题,cpu提供了一些操作指令,内存屏障(英文fence,也翻译叫做栅栏)。来看下面的模拟3:
模拟3:
1 2 3 4 5 6 7 8 9 10 |
|
smb_mb()
就是内存屏障指令,英文memory barries。它的作用,是在后续的store动作之前,将sotre buffer中的内容刷新到cacheline。这个操作的效果是让本地的cacheline的操作顺序和代码的顺序一致,也就是让其他cpu观察到的该cpu的cacheline操作顺序被分为smp_mb()之前和之后。要达到这个目的有两种方式
- 遇到smp_mb()指令时,暂停cpu执行,将当前的store_buffer全部刷新到cacheline中,完成后cpu继续执行
- 遇到smp_mb()指令时,cpu继续执行,但是所有后续的store操作都进入到了store buffer中,直到store buffer之前的内容都被刷新到cacheline,即使此时需要store的内容的cacheline是M或者E状态,也只能先写入store buffer中。这样的策略,既可以提升cpu效率,也保证了正确性。当之前store buffer的内容被刷新到cacheline完成后,后面新增加的内容也会有合适的时机刷新到cacheline。把store buffer想象成一个FIFO的队列就可以了。
7、下面来看,当有了smp_mb()
之后,程序的执行情况。所有的初始假设与上面相同。
模拟4:
序号 | cpu0的步骤(执行set) | cpu1的步骤(执行print) |
---|---|---|
1 | 想写入a=1,但是由于a不在自身的cacheline中,向cpu1发送read invalidate消息 | 执行while(b==0),由于b不在自身的cacheline中,向cpu0发送read消息 |
2 | 向store buffer中写入a=1 | 等待cpu0响应的read response消息 |
3 | 遇到smp_mb(),等待直到可以将store buffer中的内容刷新到cacheline | 等待cpu0响应的read response消息 |
4 | 等待直到可以将store buffer中的内容刷新到cacheline | 收到cpu0发来的read invalidate消息,发送a=0的值,同时将自身a所在的cacheline修改为invalidate状态 |
5 | 收到cpu1响应的read response和invalidate ack消息,将a=0的值设置到cacheline,随后store buffer中a=1的值刷新到cacheline,设置cacheline状态为M | 等待cpu0响应的read response消息 |
6 | 由于b就在自身的cacheline中,并且状态为M或者E,设置值为b=1 | 等待cpu0响应的read response消息 |
7 | 收到cpu1的read请求,将b=1的值传递回去,同时设置该cacheline状态为s | 等待cpu0响应的read response消息 |
8 | 无 | 收到cpu0的read response信息,将b设置为1,程序跳出循环 |
9 | 无 | 由于a所在的cacheline被设置为invalidate,因此向cpu0发送read请求 |
10 | 收到cpu1的read请求,以a=1响应,并且将自身的cacheline状态修改为s | 等待cpu0的read response响应 |
11 | 无 | 收到read response请求,将a设置为1,执行程序判断,结果为真 |
可以看到,在有了内存屏障之后,程序的真实结果就和我们的预期结果相同了。
8、CPU中store buffer容量小,容易满。而满的原因是因为收到其他cpu的invalidate ack的速度太慢,而cpu发送invalidate ack的速度太慢是因为cpu要等到将对应的cacheline设置为invalidate后才能发送invalidate ack。有的时候太多invalidate请求,cpu的处理速度就跟不上。为了加速这个流程,硬件设计者设计了invaldate queue来加速这个过程。收到的invalidate请求先放入invalidate queue,然后之后立刻响应invalidate ack消息。而cpu可以在随后慢慢的处理这些invalidate消息。当然,这里必须不能太慢。也就是说,cpu实际上给出了一个承诺,如果一个invalidatge请求在invalidate queue中,那么对于这个请求相关的cacheline,在该请求被处理完成前,cpu不会再发送任何与该cacheline相关的MESI消息。在有了store buffer和invalidate queue后,cpu的处理速度又可以更高。下面是结构图。
但是在引入了invalidate queue又会导致另外一个问题。下面先来看代码模拟5:
模拟5:
1 2 3 4 5 6 7 8 9 10 |
|
代码与上面的例子相同,但是初始条件不同了。这次a同时存在于cpu0和cpu1之中,状态为s。b是cpu0独享,状态为E或者M。
序号 | cpu0的步骤(执行set) | cpu1的步骤(执行print) |
---|---|---|
1 | 想写入a=1,但是由于a的状态是s,向cpu1发送invalidate消息 | 执行while(b==0),由于b不在自身的cacheline中,向cpu0发送read消息 |
2 | 向store buffer中写入a=1 | 收到cpu0的invalidate消息,放入invalidate queue,响应invalidate ack消息。 |
3 | 遇到smp_mb(),等待直到可以将store buffer中的内容刷新到cacheline。立刻收到cpu0的invalidate ack,将store buffer中的a=1写入到cacheline,并且修改状态为M | 等待cpu0响应的read response消息 |
4 | 由于b就在自己的cacheline中,写入b=1,修改状态为M | 等待cpu0响应的read response消息 |
5 | 收到cpu1响应的read请求,将b=1作为响应回传,同时将cacheline的状态修改为s。 | 等待cpu0响应的read response消息 |
6 | 无 | 收到read response,将b=1写入cacheline,程序跳出循环 |
7 | 无 | 由于a所在的cacheline还未失效,load值,进行比对,assert失败 |
8 | 无 | cpu处理invalidate queue的消息,将a所在的cacheline设置为invalidate,但是已经太晚了 |
9、上面的例子,看起来就好像第一个一样,仍然是b=1
先生效,a=1
后生效。导致了cpu1执行的错误。其实这个问题的触发,就是因为invalidate queue没有在需要被处理的时候处理完成,造成了原本早该失效的cacheline仍然被cpu认为是有效,出现了错误的结果。那么只要让内存屏障增加一个让invalidate queue全部处理完成的功能即可。
硬件的设计者也是这么考虑的,请看下面的代码模拟6:
模拟6:
1 2 3 4 5 6 7 8 9 10 11 |
|
a同时存在于cpu0和cpu1之中,状态为s。b是cpu0独享,状态为E或者M。
序号 | cpu0的步骤(执行set) | cpu1的步骤(执行print) |
---|---|---|
1 | 想写入a=1,但是由于a的状态是s,向cpu1发送invalidate消息 | 执行while(b==0),由于b不在自身的cacheline中,向cpu0发送read消息 |
2 | 向store buffer中写入a=1 | 收到cpu0的invalidate消息,放入invalidate queue,响应invalidate ack消息。 |
3 | 遇到smp_mb(),等待直到可以将store buffer中的内容刷新到cacheline。立刻收到cpu0的invalidate ack,将store buffer中的a=1写入到cacheline,并且修改状态为M | 等待cpu0响应的read response消息 |
4 | 由于b就在自己的cacheline中,写入b=1,修改状态为M | 等待cpu0响应的read response消息 |
5 | 收到cpu1响应的read请求,将b=1作为响应回传,同时将cacheline的状态修改为s。 | 等待cpu0响应的read response消息 |
6 | 无 | 收到read response,将b=1写入cacheline,程序跳出循环 |
7 | 无 | 遇见smp_mb(),让cpu将invalidate queue中的消息全部处理完后,才能继续向下执行。此时将a所在的cacheline设置为invalidate |
8 | 无 | 由于a所在的cacheline已经无效,向cpu0发送read消息 |
9 | 收到read请求,以a=1发送响应 | 收到cpu0发送的响应,以a=1写入cacheline,执行assert a==1.判断成功 |
10、可以看到,由于内存屏障的加入,程序正确了。内存屏障存在两方面作用:
- 强制cpu将store buffer中的内容写入到cacheline中
- 强制cpu将invalidate queue中的请求处理完毕
但是有些时候,我们只需要其中一个功能即可,所以硬件设计者们就将功能细化,分别是
- 读屏障: 强制cpu将invalidate queue中的请求处理完毕。也被称之为
smp_rmb
- 写屏障: 强制cpu将store buffer中的内容写入到cacheline中或者将该指令之后的写操作写入store buffer直到之前的内容被写入cacheline.也被称之为
smp_wmb
- 读写屏障: 强制刷新store buffer中的内容到cacheline,强制cpu处理完invalidate queue中的内容。也被称之为
smp_mb
11、JMM内存模型
在上面描述中可以看到硬件为我们提供了很多的额外指令来保证程序的正确性。但是也带来了复杂性。JMM为了方便我们理解和使用,提供了一些抽象概念的内存屏障。注意,下文开始讨论的内存屏障都是指的是JMM的抽象内存屏障,它并不代表实际的cpu操作指令,而是代表一种效果。
- LoadLoad Barriers
该屏障保证了在屏障前的读取操作效果先于屏障后的读取操作效果发生。在各个不同平台上会插入的编译指令不相同,可能的一种做法是插入也被称之为smp_rmb
指令,强制处理完成当前的invalidate queue中的内容 - StoreStore Barriers
该屏障保证了在屏障前的写操作效果先于屏障后的写操作效果发生。可能的做法是使用smp_wmb
指令,而且是使用该指令中,将后续写入数据先写入到store buffer的那种处理方式。因为这种方式消耗比较小 - LoadStore Barriers
该屏障保证了屏障前的读操作效果先于屏障后的写操作效果发生。 - StoreLoad Barriers
该屏障保证了屏障前的写操作效果先于屏障后的读操作效果发生。该屏障兼具上面三者的功能,是开销最大的一种屏障。可能的做法就是插入一个smp_mb
指令来完成。
内存屏障在很多地方使用,这里主要说下对于volatile关键字,内存屏障的使用方式。
-
在每个volatile写操作的前面插入一个StoreStore写写屏障。
-
在每个volatile写操作的后面插入一个StoreLoad写读屏障。
-
在每个volatile读操作的后面插入一个LoadLoad读读屏障。
-
在每个volatile读操作的后面插入一个LoadStore读写屏障。
上面的内存屏障方式主要是规定了在处理器级别的一些重排序要求。而JMM本身,对于volatile变量在编译器级别的重排序也制定了相关的规则。可以用下面的图来表示
volatile变量除了在编译器重排序方面的语义以外,还存在一条约束保证。如果cpu硬件上存在类似invalidate queue的东西,可以在进行变量读取操作之前,会先处理完毕queue上的内容。这样就能保证volatile变量始终是读取最新的最后写入的值。
Happen-before
JMM为了简化对编程复杂的理解,使用了HB来表达不同操作之间的可见性。HB关系在不同的书籍中有不同的表达。这里推荐一种比较好理解的。
A Happen before B,说明A操作的效果先于B操作的效果发生。这种偏序关系在单线程中是没有什么作用的,因为单线程中,执行效果要求和代码顺序一致。但是在多线程中,其可见性作用就非常明显了。举个例子,在线程1中进行进行a,b操作,操作存在hb关系。那么当线程2观察到b操作的效果时,必然也能观察到a操作的效果,因为a操作Happen before b操作。
在java中,存在HB关系的操作一共有8种,如下。
- 程序次序法则,如果A一定在B之前发生,则happen before
- 监视器法则,对一个监视器的解锁一定发生在后续对同一监视器加锁之前
- Volatie变量法则:写volatile变量一定发生在后续对它的读之前
- 线程启动法则:Thread.start一定发生在线程中的动作前
- 线程终结法则:线程中的任何动作一定发生在线程终结之前(其他线程检测到这个线程已经终止,从Thread.join调用成功返回,Thread.isAlive()返回false)
- 中断法则:一个线程调用另一个线程的interrupt一定发生在另一线程发现中断之前。
- 终结法则:一个对象的构造函数结束一定发生在对象的finalizer之前
- 传递性:A发生在B之前,B发生在C之前,A一定发生在C之前。
使用HB关系,在多线程开发时就可以尽量少的避免使用锁,而是直接利用hb关系和volatile关键字来达到信息传递并且可见的目的。
比如很常见的一个线程处理一些数据并且修改标识位后,另外的线程检测到标识位发生改变,就接手后续的流程。此时如何保证前一个线程对数据做出的更改后一个线程全部可见呢。先来看下面的代码例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
有两个不同的线程分别执行writer和reader方法,根据Hb规则,有如下的顺序执行图。假设,CPU0执行writer,CPU1执行readder。则CPU0置1,readder判断flag,则在判断flag之前,需要先写flag,flag为true,所以跳出循环,执行,i=1.
这样的顺序,i读取到的a的值就是最新的,也即是1.
20190318更新
1、今早在牛客上面练习时,做了一道关于volatile的选择题,volatile非线程安全,它只是保证数据获取的是最新的。