1 引言
volatile关键字想必大家并不陌生,我们在进行多线程编程开发时经常遇到。使用volatile的一个关键目的是为了保证内存中共享变量的可见性。下文会从cpu指令和JMM内存模型角度探究,volatile是如何保证内存可见性的。
2 CPU存储器层次结构简介
我们大家都知道,cpu的运行速度是非常之快的,假如,cpu直接从硬盘读取和操作数据,会怎么样呢?那大家肯定会崩溃。因为,硬盘的运行速度是非常慢的相对于cpu来说。为了使cpu大量的时间用来计算而不是等待读取,出现了内存。但是随着cpu速度的不断提升,内存逐渐的也跟不上cpu的运行速度了为了解决这种差异,充分利用cpu的使用效率,cpu缓存出现了,他是介于cpu处理器和内存之间的临时数据交换缓冲区。下面贴一张大家非常眼熟的又非常经典的cpu存储器层次结构图。想必看过《深入理解计算机系统》一书的同学一定见过这张图。
我们大致可以将此图抽象为
从图中我们可以看出,cpu和内部的高速缓存直接交互,而高速缓存则将主内存的数据加载到高速缓存中,来供cpu进行操作消费。
3 JMM(Java Memory Model)java内存模型简介
Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM),java线程内存模型和cpu缓存模型类似,是基于cpu缓存模型来建立的,java线程内存模型是标准化的,用于屏蔽掉各种硬件和操作系统的内存访问差异。
下图为java内存与处理器存储模型之间的关系图:
java内存模型规定了所有的变量都存储在主内存中,每条线程有自己的工作内存。线程的工作内存中保存了被该线程使用的变量的考本副本。线程不能够直接对主内存中的变量进行读写,也就是说,线程对变量所有的操作都必须在工作内存中进行,并且,不同线程之间也无法访问彼此之间的比变量,线程间变量的传递需要通过主内存来完成,线程,主内存,工作内存的关系如下图。
4 验证模型
为了验证上述所讲的模型,我们拿一段代码来做实验。如下图
首先我们开启两个线程,定义一个共享变量flag将其设置为true。线程1启动循环,线程2将变量flag修改为false。运行结果证实了我们上述的JMM模型猜想,每个线程操作共享变量的时候回将共享变量的副本拷贝到自己的工作内存中,所以就算B线程已经将flag标志修改为false,1线程依旧读取自身工作内存中的flag=true。从而一直在死循环。
有没有什么方法能能够解决现在遇到的这种问题呢?熟悉并发编程的小伙伴肯定脱口而出,使用volatile修饰共享变量就可以了。我们照做来尝试一下。结果如下图:
如我们所料,线程1如期结束了。为什么会出现这种情况呢?使用volatile和没有使用volatile的共享变量有什么区别呢?我们将hsdis-amd64.dll(GitHub - atzhangsan/file_loaded)文件放入对应jdk或者jre的bin目录下,增加jvm启动参数
-server
-Xcomp
-XX:+UnlockDiagnosticVMOptions
-XX:+PrintAssembly
-XX:CompileCommand=compileonly,*VolatileExample.changeFlag
对这段代码进行反编译看一下有什么不同。
1)不使用volatile修饰
2)使用volatile修饰
我们可以看出volatile修饰的变量反编译完成之后执行时有lock前缀修饰,下面我们来着重的研究一下这个lock前缀,lock指令就是volatile保证内存可见性的关键。通过查询资料,我们发现lock前缀修饰的指令带多核cpu中会有 引发几个操作
- lock前缀指令会引起处理器缓存写会到主内存
- 指令写回主内存之后其他cpu中的缓存会相应的失效。
这就很能说明问题了,线程2将变量改为false由于变量为volatile修饰所以直接刷新回主内存,并且,最重要的是,1线程中flag变量的副本失效,只能重新从主内存中加载,这时候,flag变量已经变为了false所以线程1可以顺利结束死循环结束运行。
那么这里有同学可能会问,为什么1线程可以感知到2线程修改了flag变量呢?是线程2向线程1发送了一条信息吗?当然不是,我们前面讲过,不同线程之间是不能直接通信的。那么这是为什么呢?原因就在于缓存一致性。下面我们就来进一步了解探究,缓存一致性
5 MESI缓存一致性协议
MESI缓存一致性协议是cpu缓存一致性协议的一种。MESI协议将cache line的状态分成modify、exclusive、shared、invalid,分别是修改、独占、共享和失效。
- modify:当前CPU cache拥有最新数据(即最新的cache line),其他CPU拥有失效数据(cache line的状态是invalid),虽然当前CPU中的数据和主存是不一致的,但是以当前CPU的数据为准;
- exclusive:只有当前CPU中有数据,其他CPU中没有改数据,当前CPU的数据和主存中的数据是一致的;
- shared:当前CPU和其他CPU中都有共同数据,并且和主存中的数据一致;
- invalid:当前CPU中的数据失效,数据应该从主存中获取,其他CPU中可能有数据也可能无数据,当前CPU中的数据和主存被认为是不一致的;
MESI缓存一致性协议中,每个cache的控制器不仅知道自己的操作(local read和local write),通过监听总线也知道其他CPU中cache的操作(remote read和remote write)。对于自己本地缓存有的数据,CPU仅需要发起local操作,否则发起remote操作,从主存中读取数据,cache控制器通过总线监听,仅能够知道其他CPU发起的remote操作,但是如果local操作会导致数据不一致性,cache控制器会通知其他CPU的cache控制器修改状态。
- local read(LR):读本地cache中的数据;
- local write(LW):将数据写到本地cache;
- remote read(RR):读取内存中的数据;
- remote write(RW):将数据写通到主存;
说了这么多理论性的东西,话题回到我们第三小节的验证上来。
首先,线程1读取贡献变量flag=true,此时所有cpu都没有这个数据,这个线程将数据从主内存中加载到cache中,发生一次RR操作,此时当前cpu cache line状态为exclusive即只有当前CPU中有数据,其他CPU中没有改数据,当前CPU的数据和主存中的数据是一致的。
再往下运行,线程2开启,此时线程2本地是没有flag共享变量的,所以从主内存读取,所以此时发生RR操作,线程1cpu cache通过总线监听,得知发生了RR操作,此时,将当前1线程cpu cache line状态修改为shared即当前CPU和其他CPU中都有共同数据,并且和主存中的数据一致。
接下来,线程2将flag贡献变量修改为false,此时发生了一次LW操作此时cpu cache line状态修改为modify,即当前CPU cache拥有最新数据(即最新的cache line),其他CPU拥有失效数据(cache line的状态是invalid),虽然当前CPU中的数据和主存是不一致的,但是以当前CPU的数据为准;
同时在cpu2将数据写会主内存也就是发生RW操作的时候,cpu1监听到之后将自己的cpu cache line状态修改为invalid,此时当前CPU中的数据失效,数据应该从主存中获取,其他CPU中可能有数据也可能无数据,当前CPU中的数据和主存被认为是不一致的,此时由于cpu1 cache line状态为invalid所以会从主内存中从新加载flag变量,此时新的flag变量为false所以线程1结束死循环,程序执行结束。
6 总结
本文浅析了volatile是如何保证内存可见性的,同时也大致的抽象介绍了JMMjava内存模型,当然volatile的作用并不是只有保证内存可见性,当然还有禁止指定重排序等等。这些会在我后面的文章中带大家一起探究学习。我所讲的这些内容只是简单的个人理解,只是并发编程的冰山一角,如果有错误或遗漏的地方欢迎各位同学一起探讨。