概述
先说结论,java中volatile的功能有两个:线程可见性+禁止指令重排序
看一段代码:
public class T12_Volatile {
static boolean flag=true;
static void m1(){
while (flag){
}
System.out.println("end");
}
static void m2(){
flag=false;
}
public static void main(String[] args) throws InterruptedException {
new Thread(T12_Volatile::m1,"t1").start();
Thread.currentThread().sleep(2000);
new Thread(T12_Volatile::m2,"t2").start();
}
}
直观的从代码上来看,t1线程应该在t2线程将flag变为false后输出end并结束线程,然而,事实却并不是这样。现实是t1线程一直在运行,从此不再停止。那么原因为何?
要解释以上问题,需要从JMM(java 内存模型)说起。JMM模型图如下:
在java内存中,分为主内存和线程本地内存,主内存存储一些静态变量或者示例数据等,当线程要操作这些数据时,要从主存中将这些数据拷贝到自己线程空间中来,操作完后,再讲数据回写回主存。也就是说,当多个线程操作同一个变量时,操作的不是变量本身,而是各个线程对这个变量的copy。所以线程之间的数据操作结果是不可见的。
所以对于上述问题,flag在两个线程中的状态如下:
当t2线程将flag修改并回写回主存后,t1中的flag变量并没有及时更新,读取的值仍然是旧值。
分析完了原因,那么可有对应的解决方法?答案是:有。
为解决线程之间可见的问题,java中提供了关键字----volatile。
话不多说,加上试试:
public class T12_Volatile {
static volatile boolean flag=true;
static void m1(){
while (flag){
}
System.out.println("end");
}
static void m2(){
flag=false;
}
public static void main(String[] args) throws InterruptedException {
new Thread(T12_Volatile::m1).start();
Thread.currentThread().sleep(2000);
new Thread(T12_Volatile::m2).start();
}
}
输出结果:
通过结果我们可以看到,线程t1正常结束,也就是说加上volatile后,t1线程看到flag的修改结果,简直amazing!
再看另外一段代码:
public class T10_Disorder {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
int i=0;
for (;;){
i++;
x=0;y=0;
a=0;b=0;
Thread one=new Thread(()->{
a=1;
x=b;
},"t1");
Thread other=new Thread(()->{
b=1;
y=a;
},"t2");
one.start();
other.start();
one.join();
other.join();
if(x==0&&y==0){
System.out.println("第"+i+"次执行:"+"x="+x+",y="+y);
break;
}
}
}
}
上面这段代码很简单,就是两个线程分别服a,x和b,y进行操作,从代码上看,不论是t1先执行还是t2先执行,x和y的值都不可能同时为0。然而,输出结果却让我们surprise,输出结果如下:
那么这能说明什么问题,且是什么原因呢?
x,y同时为0,只能是说明y=a的指令在b=1的指令之前执行,也就是说,程序的指令顺序发生改变。那么究竟何原因,竟会造成这种现象?
解释个中原因,根本还是cpu和缓存之间速度不匹配,相差几个数量级,如果按照指令顺序执行,会造成cpu资源浪费(浪费在等待缓存上),所以会将没有数据关联的两条指令变换顺序(不等了,先干别的)。总结来说一句话,为了提高程序的运行性能,会对不存在数据依赖的指令进行指令重排。
详解
上述的两段代码一大段就讲了两点:volatile可以实现线程的可见性;程序存在指令重排。
那么volatile是如何这般神奇地实现线程的可见性的呢?
我们使用javap -v
命令查看class生成的字节码,内容太多,贴点主要的:
static volatile boolean flag;
descriptor: Z
flags: ACC_STATIC, ACC_VOLATILE
我们看到,经过volatile修饰过的变量,在字节码中会增加一个ACC_VOLATILE标记,那么这个标记的含义为何?查看下JVM的代码:
在bytecodeinterpreter.cpp中有这么段代码,若此时内存被volatile修饰,执行下一段代码:
查看fence()方法:
inline void OrderAccess::fence() {
if (os::is_MP()) {
// always use locked addl since mfence is sometimes expensive
#ifdef AMD64
__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
__asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
}
}
我们能看到,fence中是执行"lock; addl $0,0(%%esp)
这么一条汇编指令,语义为往一块寄存器中写入0,实际并没有什么意义,只是实现了内存屏障(待会说),那么这又和实现线程可见性有啥子关系呢?查看汇编指令集,我们可以知道,lock指令的功能锁住总线,保证只能有一个线程访问内存,且lock期间,将当前内核高速缓存行的数据立刻回写到内存,并且使在其他内核里缓存了该内存地址的数据无效(通过缓存一致性协议)。
也就是说,使用volatile修饰的变量,当线程将其加载到自己的内存块,修改后会立马回写至主存,并使得其他线程空间中对这个变量的缓存无效,其他线程需要操作这个变量时,需要重新到主存中加载,从而实现了线程的可见性。
解释完线程的可见性,那么禁止指令重排又是怎么回事呢?
上节说到,由于各种优化,一些数据不相干的指令执行顺序会被重新排序,这种设计固然可喜,然在多线程下会出现不可预知的问题,如以上代码。所以,该如何不让cpu对指令重新排序呢。jvm给出了答案,在指令前后加上内存屏障可以禁止指令重排序。
那什么是内存屏障呢?
内存屏障可以理解为一道栅栏,这道栅栏前后的指令不能“蹦来蹦去”,只能乖乖按照顺序执行,慢点也无所谓。
java编译器在生成指令的适当的位置会插入内存屏障来禁止特定类型的处理器的重排序,并把内存屏障分为以下四类:
- LoadLoad:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStore:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- LoadStore:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreLoad对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
了解了上面这些基本概念,那么看下jvm源码是怎么实现的
在给volatile修改的变量赋值时,执行代码如下:
if (cache->is_volatile()) {
if (tos_type == itos) {
obj->release_int_field_put(field_offset, STACK_INT(-1));
} else if (tos_type == atos) {
VERIFY_OOP(STACK_OBJECT(-1));
obj->release_obj_field_put(field_offset, STACK_OBJECT(-1));
} else if (tos_type == btos) {
obj->release_byte_field_put(field_offset, STACK_INT(-1));
} else if (tos_type == ltos) {
obj->release_long_field_put(field_offset, STACK_LONG(-1));
} else if (tos_type == ctos) {
obj->release_char_field_put(field_offset, STACK_INT(-1));
} else if (tos_type == stos) {
obj->release_short_field_put(field_offset, STACK_INT(-1));
} else if (tos_type == ftos) {
obj->release_float_field_put(field_offset, STACK_FLOAT(-1));
} else {
obj->release_double_field_put(field_offset, STACK_DOUBLE(-1));
}
OrderAccess::storeload();
}
根据当前变量类型给变量赋值,调用release_int_field_put(field_offset, STACK_INT(-1))
方法,进去瞅瞅,代码如下:
inline void oopDesc::release_int_field_put(int offset, jint contents)
{ OrderAccess::release_store(int_field_addr(offset), contents); }
而OrderAccess::release_store(int_field_addr(offset), contents)
又是根据不同的操作系统和cpu结构来调用具体实现
以linux-x86为例,实现代码如下:
inline void OrderAccess::release_store(volatile jbyte* p, jbyte v) { *p = v; }
我们看到他使用了c++中的volatile关键字,被volatile修饰表示该变量随时都会发生改变,要想获取值,需要重新到对应的内存地址中拿。所以在编译器级别,不会对该位置的指令进行优化。
在赋值完以后,调用了OrderAccess::storeload();
方法,名称上来讲是如此的熟悉,就是上面提到的四种屏障之一,还是那个全能的。进去看看如何实现:
inline void OrderAccess::loadload() { acquire(); }
inline void OrderAccess::storestore() { release(); }
inline void OrderAccess::loadstore() { acquire(); }
inline void OrderAccess::storeload() { fence(); }
惊奇的发现这一块不仅有storeload,其他三个都在。而fence()
方法是我们上面提到的那个方法,就是锁住总线,向一个寄存器中写一个0值,虽然这个操作对于程序来讲毫无意义,但是下面的指令无法越过lock
,从而达到禁止指令重排这一目的。
使用场景
DCL 单例
java的单例模式的实现方式中有这么一种写法
public class T13_Single {
private volatile static T13_Single instance;
int a=0;
private T13_Single(){
this.a=12;
}
public static T13_Single getInstance() {
if(instance==null){
synchronized (T13_Single.class){
if(instance==null){//double check lock
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
instance=new T13_Single();
}
}
}
return instance;
}
}
总结
再次回到原点volatile:通过lock指令,实现保证线程可见性+禁止指令重排