一、JMM保证共享对象的可见性
如果一个字段被声明为volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。
而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
二、深入剖析volatile关键字
首先说明为什么要使用volatile(轻量级的synchronized):Volatile变量修饰符如果使用恰当的话,它比synchronized的使用和执行成本会更低,因为它不会引起线程上下文的切换和调度。
1.volatile关键字的两层语义
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
(1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
(2)禁止进行指令重排序。
2、在X86处理器下通过工具获取JIT编译器生成的汇编指令来查看对volatile进行写操作时,CPU会做什么?
instance = new Singleton();//instance是volatile变量
汇编指令: 0x01a3de1d: movb $0x0,0x1104800(%esi);0x01a3de24: lock addl $0x0,(%esp);
如果对声明了volatile的变量进行写操作,JVM会向处理器发送一条Lock前缀的指令,Lock前缀指令会引发两条原则:
(1)Lock前缀指令会引起处理器缓存写会到内存;
(2)一个处理器的缓存回写到内存会导致其他处理器的缓存无效:
处理器使用嗅探技术保证它的内部缓存、系统内存和对其他处理器的缓存的数据在总线上保持一致(缓存一致性协议),党处理器发现自己缓存对应的内存地址被修改,就会将当前处理器的缓存行设置为无效,会重新从系统内存中把数据读到处理器缓存里。
3、intel的手册对lock前缀的说明如下:
(1)、确保对内存的读-改-写操作原子执行。
在Pentium及Pentium之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从Pentium 4,Intel Xeon及P6处理器开始,intel在原有总线锁的基础上做了一个很有意义的优化:如果要访问的内存区域(area of memory)在lock前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或以修改状态),并且该内存区域被完全包含在单个缓存行(cache line)中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定(cache locking),缓存锁定将大大降低lock前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。
(2)、禁止该指令与之前和之后的读和写指令重排序(通过内存屏障)。
(3)、把写缓冲区中的所有数据刷新到内存中。
综上所述:
volatile关键字保证了操作的可见性,但是volatile能保证对变量的操作是原子性
volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性
理解volatile特性的还办法就是把对 volatile变量的单个读/写,看做是使用同一个锁对这些单个读/写操纵做了同步。
三、volatile的内存语义
1、volatile变量具有下列特性:
(1)可见性,volatile变量的读总能看到对这个volatile变量最后的写入;
(2)原子性,对任意单个volatile变量的读写具有原子性,但volatile++复合操作是不具有原则性的;
2、volatile写/读建立的happens before关系
从JDK1.5开始,volatile变量的读-写能实现线程之间的通信,从内存的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果。
public class VolatileExample {
int a=0;
volatile boolean flag=false;
public void writer(){
a=1; //1
flag=true; //2
}
public void reader(){
if(flag){ //3
int i=a; //4
}
}
}
假设线程A在执行writer()后,线程B执行reader( ),根据happen-before规则:
1、根据程序次序规则,1 happens before 2,3 happens before 4
2、根据volatile规则,2 happens before 3
3、根据happens before的传递规则,1 happens before 4
(1)当执行写入volatile时,也就是2,JMM会将该线程A对应本地内存更新过的共享变量刷新到主内存,那到共享变量a对其他线程是可见的,也就读到a就是想要的true,而不是false。
(2)当读一个volatile变量时,JMM会把该线程B对应的本地内存置为无效,线程直接从主内存读取共享变量,同时该读操作会把本地内存的值更为与主内存的值统一。如图:
![](https://img-blog.csdnimg.cn/20190524100100362.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzQwNjQ1ODIy,size_16,color_FFFFFF,t_70)
四、volatile内存语义的实现
重排序主要分为编译器重排序和处理器重排序,JMM会限制这两种类型的重排序类型来保证volatile的内存语义
1、第二个操作是volatile写时,第一个操作不管是什么,都不能重排序 。
2、第一个操作是volatile读时,第二个操作不管是什么,都不能重排序 。
3、第一个操作是volatile是写,第二个操作是volatile是读,不能重排序。
JMM内存屏障插入策略:(Load:加载(读)、Store:保存(写),屏障名称就可以看出读写的先后顺序)
1、在每个volatile写操作前插入StoreStore屏障
2、在每个volatile写操作后插入StroreLoad屏障
3、在每个volatile读操作后插入LoadLoad屏障
4、在每个volatile读操作后插入LoadStore屏障
volatile写操作
![](https://img-blog.csdn.net/20170809011911598?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvanl4bXVzdA==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast)
上面的StroreStore屏障保证了在volatile写之前,其前面的所有普通写操作对任意处理器都是可见的,因为StroreStore屏障保障所有的普通写在本地内存的数据在voltile写之前刷新到主内存,而volatile写后面的StoreLoad屏障,作用是避免volatile写与后面可能有的volatile读/写操作重排序 。
volatile读操作
可以看出,volatile的写和读的内存屏障是非常保守的,也可以看出JMM在是实现上的一个特点:先确保正确性,然后再去追求执行效率。
在实际执行中,只要不改变volatile的读-写的内存语义,编译器会做灵活处理:
public class VolatileBarrierExample {
int a;
volatile int v1 = 1;
volatile int v2 = 2;
void readAndWrite() {
int i = v1;// 第一个volatile读
int j = v2;// 第二个volatile读
a = i + j;// 普通写
v1 = i + 1;// 第一个volatile写
v2 = j * 2;// 第二个volatile写
}
}
我们可以看下图,就知道编译器自动做了一些优化:省去一些不必要的屏幕:
注意:最后的StoreLoad屏障不能省略,因为编译器无法确定第二个volatile写后是否会有volatile读或写,保守起见,都会在该处加一个StoreLoad屏障。
图片来源:https://blog.csdn.net/jyxmust/article/details/76946283
原文:《java并发编程的艺术》、《深入理解Java虚拟机》