可见性问题:Volatile的本质

什么是可见性问题

在单线程的环境下,如果向一个变量先写入一个值,在没有写干涉的情况下读取这个变量的值,此时读取到的这个变量的值应该是之前写入的那个值。但是在多线程环境下,读和写发生在不同的线程中的时候,可能会出现:读线程不能及时的读取到其他线程写入的最新的值,即线程1的写没有对线程2可见。

那么为什么会发生这种情况,下面具体来分析。

缓存一致性问题

为了提高计算机的处理速度(cpu速度快,内存速度慢),cpu引入了高速缓存(即下图L1、L2等)。

image-20200923132302834

当程序在运行过程中,会将运算需要的数据从内存复制一份到CPU高速缓存中,那么CPU进行计算时就可以从它的高速缓存读取数据和写入数据,当运算结束后,再将高速缓存中的数据刷新到内存中。这个代码在单线程中运行时没有任何问题的,但在多线程环境下,每条线程可能运行于不同的CPU核心中(单核CPU同样存在),因此每个线程运行时都有自己的高速缓存,此时就会导致缓存一致性问题

i=i+1为例:

假设i的初始值为0,线程1读取i=0到cpu1的高速缓存,线程2读取i=0到cpu2的高速缓存。然后线程1进行了+1操作,并把i=1写入到了内存中,线程2也进行了+1操作,而它也将i=1写入到了内存,此时i并不是2,而是1。这就是缓存一致性问题,cpu1缓存的写对cpu2的缓存“不可见”

解决缓存一致性问题,通常有2种方法:

  • 总线锁
  • 缓存一致性协议

总线锁

在早期的CPU中,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个LOCK#信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,也就是说阻塞了其他CPU对其它部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。而这使得锁定期间,其他处理器不能操作其他内存地址的数据。导致效率低下。

缓存一致性协议

而在x86架构下,会通过缓存一致性协议来保障数据一致性。其核心思想是:当CPU向内存写入数据时,如果发现操作的变量时共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存是无效的,那么它就会从内存重新读取。缓存一致性协议有 mesi、mosi、msi...等等,以mesi为例:

mesi,表示了缓存的四种状态:

  • modify:表示数据只缓存在当前CPU缓存中,并且是被修改状态(也就是缓存中的数据和主内存中的数据不一致)。
  • excusive:表示数据只缓存在当前CPU缓存中,并且没有被修改。
  • shared:表示数据被多个CPU缓存,并且此时各个缓存中的数据和主内存数据一致。
  • invalid:表示缓存已经失效。

缓存一致性协议的优化:store bufferes

同样以i=i+1为例:当cpu1、cpu2同时读取了i=0的数据到各自的高速缓存中后,cpu1对自身高速缓存的i做了修改,因为缓存一致性协议,它会通知cpu2让其把自身的高速缓存中的i数据标记为invalid失效,cpu2在完成失效后通知cpu1已经完成失效操作。而cpu1在发送-接收的这个通信的过程中,会处于短暂的阻塞状态。为了优化这个这个短暂的通信阻塞,每个cpu核心引入了store bufferes这一缓存来优化这一过程。

引入store bufferes后,cpu1的store bufferes会存储cpu1的i+1结果,在cpu1发送-接收失效命令这一过程中,cpu1将不再阻塞,而会继续进行程序后面的其他操作,在接收到cpu2已经完成失效操作后,cpu1再将store bufferes中的i+1结果交到内存中,这种情况下,cpu1就不会存在短暂的阻塞状态。

store bufferes带来的指令重排序问题

store bufferes的引入却会带来另一个问题,指令重排序问题

还是这个例子:i=i+1;j=i;,此时在cpu1的store bufferes中已经存储了i=i+1;结果,而cpu1因为store bufferes的存在暂时没有将这个结果交到内存中,而是""在等待通信的过程中""直接执行了下一步的j=i;操作,此时j被赋予的值是i还未+1的值,而不是i+1后的值!这就是store bufferes带来的指令重排序问题:j=i发生在i=i+1之前。

而为了解决指令重排序带来的问题,引入了内存屏障机制。

内存屏障解决指令重排序

X86的内存屏障memory barrier指令包括

  • sfence(写屏障) :处理器把在写屏障之前的所有在store bufferes中的数据同步到主内存,即写屏障之前的指令结果对屏障之后的读或写可见。如果j=i有写屏障,那么就把j=i之前的在store bufferes中的i+1先读取到内存中。

  • lfence(读屏障):处理器把在读屏障之后的读操作,都放在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的。

  • mfence(全屏障):确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作。


Volatile的本质

java内存模型:JMM

简单来说,JMM定义了共享内存中多线程程序读写操作的行为规范:在虚拟机中把共享变量存储到内存以及从内存中取出共享变量的底层实现细节。通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了CPU多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。

JMM的设计就是避免前文叙述的两个问题:高速缓存导致的可见性问题,store bufferes导致的指令重排序问题。

对于缓存一致性问题,硬件层面有总线锁和缓存锁,缓存锁是基于MESI协议。而对于指令重排序,硬件层面提供了内存屏障指令。而JMM在这个基础上提供了volatilefinal等关键字,使得我们可以在合适的时候使用相应的关键字来禁止高速缓存和禁止指令重排序来解决可见性和有序性问题。

首先来分析volatile关键字。

Lock指令

private static volatile int i = 1;

编译这句话,使用hsdis工具来查看此时的汇编指令:

发现此处会有一句Lock指令,而这个Lock就是保证操作可见性的指令。如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。而根据缓存一致性协议,其他cpu核心的高速缓存的这个数据就会失效,这就保证了一致性即可见性规则。即volatile的两条规则:

  1. Lock前缀的指令会引起处理器缓存回写到内存。
  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存失效。

参考:

什么是缓存一致性问题?如何解决呢?

并发编程

《java并发编程的艺术》

文章已创建 17

相关文章

开始在上面输入您的搜索词,然后按回车进行搜索。按ESC取消。

返回顶部