volatile是作用是保证被修饰变量的可见性和有序性,但是之前提到的synchronized已经可以保证多线程并发情况下的线程安全,其中就包括了保证可见性和有序性,为什么还要(或者还需要)使用volatile呢?
synchronized是将修饰的代码块变成了同步代码块,在线程退出同步块的时候,会将同步块中的所有共享变量副本从线程的工作内存同步到主内存中的共享变量中,这是synchronized保证可见性的方式。同时,一个线程进入同步块后会阻塞其它的线程进入,知道解锁了其它线程竞争抢占资源后再进入同步块,这种依次进入同步块的特性天然的满足了有序性。
但是,synchronized毕竟涉及到了线程的阻塞和线程的上下文切换,在某些简单的原子性操作中,这样的处理方式必然带来了额外的性能开销。
所以JVM在面对这些简单的原子性操作时,引入了更轻量级的处理方式——volatile,以此来保证某些变量在并发且不加锁的情况下保证可见性和有序性。
JMM是JDK为了屏蔽不同硬件和不同操作系统中指令执行的差异性,在操作系统的CPU与内存的交互机制上抽象出了一套供Java使用的内存模型,我们使用这套模型进行研究和学习可以更好的帮助我们清楚的认知线程之间的同步和通信问题。上面提到得工作内存和主内存就属于这个模型的一部分。
JMM图示如下:
同理,将JMM抽象模型套用在CPU和内存之间,就得到了一个CPU与内存交互的简图,我们可以通过这个简图去研究可见性问题:
上图可见,每个CPU对共享变量的操作都是将内存中的共享变量复制一份副本到自己高速缓存中,然后对这个副本进行操作。如果没有正确的同步,即使CPU0修改了某个变量,这个已修改的值还是只存在于副本中,此时CPU1需要使用到这个变量,从内存中读取的还是修改前的值,这就是其中一种可见性问题。
还有一种可见性问题,CPU0和CPU1都读取了变量a到自己的副本中,CPU0对变量a 做了写操作并同步到了内存,但是CPU1在后面的操作中没有从内存更新变量值,而是直接使用了之前缓存的值,这样也会导致数据结果不正确。
Java代码的运行是先将Java代码编译成字节码,然后JVM加载字节码最终转换成汇编指令在CPU上运行,被volatile修饰的字段会在其对应的汇编操作指令上加个lock前缀指令,这个指令就可以解决上述的两种可见性问题。
处理器在接收到lock指令的时候会做两件事:
1.将缓存行缓存的数据写回到系统内存中。
2.这个写回到系统内存中的数据,如果在其他CPU的缓存行中存在相同的数据,则将其置为失效状态。
而lock指令更底层的实现主要有两种方式:总线锁和缓存锁。在谈这两种方式之前先解释下缓存行是什么。
缓存行(cache line)是什么?
因为CPU和内存之间运行速度的差异,为了保证CPU的运行速度不被内存给“拖垮”,CPU在对内存中的资源进行操作的时候不会直接去操作内存,而是将内存中的资源先加载到CPU的高速缓存中,在使用的时候就直接从高速缓存中去取。
缓存行就是高速缓冲中用于分配的最小存储单位,也就是说CPU加载高速缓存中的资源,最少也会加载一整个缓存行。
在一些比较“古老”的处理器中可能还会使用的总线锁的方式,CPU与内存数据的交互是通过总线来“运输的”,某个处理器使用了总线锁就表示这段时间就只有它能与内存发生数据交互,其它的处理器都无法访问到系统内存,这种方式必然会导致开销比较大。
所以现代的处理器一般不会使用总线锁,而是使用缓存锁作为替代方案。
缓存锁严格来锁并不是锁,而是基于缓存一致性协议,实现对缓存行数据的状态转换的控制,以此来完成对可见性的保证。
缓存一致性协议(MESI),将缓存行中的数据划分成了4个状态:修改、独占、共享、失效
缓存一致性协议是如何使用的呢?
下面针对1.1提到的第二种可见性问题来分析执行流程。
失效的缓存行会在下次使用到的时候,重新从内存中获取数据,这么一来线程A对共享变量做出了修改,对线程B就立即可见了。
再看一下线程A在将缓存行中的变量同步到的内存时,会通知其他的线程将这个变量所在缓存行置为失效状态的动作,这个动作实际上是一个同步阻塞的动作,A向B发起一个失效通知,然后阻塞等待B响应结果。图示如下:
这里阻塞的时间可能会远大于CPU执行每条指令的时间,可以想到的是,如果这种阻塞操作过多必然会导致CPU运行性能降低。
为了解决CPU算力浪费的问题,引入了Store Bufferes这样一个缓冲区域,把将要同步回内存的数据的放入Store Buffers中,然后当前CPU发送失效通知,发送后不再阻塞等待,而是去执行其它的指令,直到所有失效通知都响应回来后,再将Store Buffers中的数据取出同步到内存。
引入Store Buffers对缓存一致性协议的阻塞问题做了优化,但是这个优化可能会导致后面的指令先于前面的指令执行完毕,这种顺序流入、乱序流出的现象,属于指令重排序。
造成有序性问题的其中一个原因是处理器的指令重排序,指令重排序实际上是处理器的优化方案,让CPU能发挥更好的性能,严格的说不能算是一个问题。
在单线程的情况下,一个方法内的代码对应的CPU指令即使是乱序执行的,对结果来说也不会有影响(除非是后面的指令依赖前面的结果,这种情况由Happen-Before规则禁止重排序),但是对于并发条件下的程序,乱序修改共享变量就可能导致其他的线程读取到的变量值不正确。
作为一个优化来讲,指令重排序是有其存在的必要性的,但是处理器并不知道什么时候应该禁止这种重排序优化来保证程序执行的正确性,于是将禁用的时机抛给了使用者。
针对两种不同的重排序,编译器的重排序和处理器的重排序,JMM统一制定了重排序的管理规则,通过插入内存屏障来标识什么时候是禁止重排序优化的,而插入内存屏障时机,就是volatile修饰的变做读写操作的时候。
JMM定义的内存屏障一共有4种:
屏障类型 | 指令说明 |
---|---|
StoreStore | 写写屏障,插入两个写之间,volatile写之前,禁止前面的普通写或volatile写与当前的volatile写发生重排序 |
StoreLoad | 写读屏障,插入volatile写之后,禁止当前的volatile写与后面的volatile读发生重排序 |
LoadLoad | 读读屏障,插入 volatile读之后,禁止当前的volatile读与后面普通读或volatile读发生重排序 |
LoadStore | 读写屏障,插入volatile读之后,禁止当前的volatile读与后面的普通写或volatile写发生重排序 |
内存屏障就是将代码划分为多个区域,区域与区域之间按照顺序执行,各个区域中的代码可以重排序,如下图所示:
先看一个代码示例,对四个成员变量的内存操作:
public class VolatileBarrierDemo {
int i1;
int i2;
volatile int v1;
volatile int v2;
void readAndWrite() {
int a1 = i1; // 操作一
int a2 = i1; // 操作二
int b1 = v1; // 操作三
int b2 = v2; // 操作四
i1 = i1 + 1; // 操作五
v1 = i1 * 2; // 操作六
v2 = i1 * 3; // 操作七
i2 = i2 + 1; // 操作八
}
}
在readAndWrite方法中,四种内存屏障都用到了,具体流程如下图所示:
JMM通过在各个指令之间插入不同内存屏障,来达到在某些问题禁用重排序优化的效果,以此来保证并发环境下由有序性造成的线程安全问题。
在2.3.3缓存一致性协议中提到了CPU通过“缓存锁”来解决可见性问题,JMM通过对底层实现的抽象,将3.1中提到的volatile读/写操作,分别定义了其内存语义,更方便与理解和使用。
如果将读写的内存语义连起来看,就是一个做volatile写操作的线程A,和一个做volatile读操作的线程B,A对这个volatile修饰的共享变量的修改,对线程B可见。
volatile的作用是并发环境下,在一定的作用范围内解决共享变量的可见性和有序性问题,相对于synchronized和显示的加锁,volatile在性能上根据优势,可以尽可能的以更细的粒度来保证线程安全。
可见性:
有序性: