volatile关键字,开发中一般不用,主要用途就是用来面试造火箭,那么它在代码中的真正作用是啥呢??
上一篇博客说到,对于现代多核心计算机而言,在多线程的情况下,如果一个变量没有加volatile修饰,可能出现线程间各自拷贝主内存的变量值到自己独有的线程CPU缓存中对数据做各种操作,导致变量的变化在线程间不可见的问题。
举个例子:
public class VolatileTest1 { private Integer i = 0; /** * 改变i的值 */ public void f1() { int localVal = i; while (i < 5) { i = localVal++; System.out.println("改变i的值:" + i); try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } } } /** * 监听i的值 */ public void f2() { int localVal = i; while (i < 5) { if (localVal != i) { System.out.println("i发生改变:" + i); localVal = i; } } } public static void main(String[] args) { VolatileTest1 v1 = new VolatileTest1(); new Thread(v1::f1).start(); new Thread(v1::f2).start(); } }上述代码运行结果是:
运行多次,均是阻塞结尾。但是如若我们给变量i加上volatile过后:
public class VolatileTest1 { private volatile Integer i = 0; /** * 改变i的值 */ public void f1() { int localVal = i; while (i < 5) { i = localVal++; System.out.println("改变i的值:" + i); try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } } } /** * 监听i的值 */ public void f2() { int localVal = i; while (i < 5) { if (localVal != i) { System.out.println("i发生改变:" + i); localVal = i; } } } public static void main(String[] args) { VolatileTest1 v1 = new VolatileTest1(); new Thread(v1::f1).start(); new Thread(v1::f2).start(); } }变量i加上volatile后,方法f2的线程就能感知到i的变化,从而成功输出变化值。
其实volatile能让线程之间可见的原理就是,线程各自在CPU缓存的改变,volatile都强制让其刷新到主内存。
计算机CPU在执行程序指令的时候,出于性能考虑,只要指令最终语义与重排序之前一致,会把指令重新排序执行。
我们来看个简单的字节码
可以看到i++在字节码层面变成了
LINENUMBER 7 L0 GETSTATIC com/zab/concurrenttest/volatiletest/ByteCodeTest.i : I ICONST_1 IADD PUTSTATIC com/zab/concurrenttest/volatiletest/ByteCodeTest.i : I步骤如下:
获取静态变量定义常量1i自增1把常量放回主内存我们看看重排序的影响:
int a = 1; int b = 2; a++; b++;假如重排序后:
int a = 1; a++; int b = 2; b++;另一线程读取的时候,因为a++提前,可能把a=1,b=2的情况读成了a=2,b=2。
当然这只是描述重排序的后果,真正引起的问题的还是字节码指令级别的重排序。
我们已经知道指令重排序虽然可以提高执行效率,但是在并发条件下,却会出各种意想不到的问题。
在了解为什么volatile能够禁止指令重排序之前,我们需要了解什么是happen-before rules。
正如上图所说,如果代码块X不管怎么运行(单线程还是多线程)都在代码块Y前执行,那么X和Y之前具有一种happen-before的关系。这种关系在java中有如下几种:
1、线程的start()方法永远发生在其线程run()方法代码块执行之前
2、线程B的join()方法,保证线程B的run()代码块,在线程A插入join()方法后的代码块执行之前运行。
3、单线程语句靠前,执行优先
4、有锁的情况,释放锁发生在其余代码执行之前
5、加volatile的变量,多线程情况下,其变量写操作发生在读操作之前
happen-before规则盗图国外网站,感谢该网站把规则画得那么清晰明了。
https://www.logicbig.com/tutorials/core-java-tutorial/java-multi-threading/happens-before.html
第5种规则就是volatile所实现的,要达到这个目的,那又是通过什么技术手段呢?
手段就是内存屏障,内存屏障是由硬件支持的,不同的硬件平台实现内存屏障的手段不尽相同,java虚拟机屏蔽了硬件差异,统一生成内存屏障的指令。
硬件级的内存屏障分为Load Barrier和Store Barrier,说的简单点就是读、写屏障。
内存屏障的作用:
屏障前后的指令不能越过屏障重新排序,意味着屏障前的指令先执行,屏障后的指令后执行。强制刷新CPU缓存的数据至主内存。内存屏障的用法:
读指令前加Load Barrier,可以让缓存失效,读到主内存最新的数据写指令后加Store Barrier,可以穿透缓存,写到主内存去JVM虚拟机给我们提供四种内存屏障,是上面的读、写屏障的组合。
LoadLoad屏障:
用法:GETSTATIC X1; LoadLoad; GETSTATIC X2
在读取指令GETSTATIC X2要读取的数据被访问前,保证GETSTATIC X1要读取的数据被读取完毕,并且读取的是主内存数据。
StoreStore屏障:
用法:PUTSTATIC X1; StoreStore; PUTSTATIC X2
在写入指令PUTSTATIC X2写入执行前,保证PUTSTATIC X1的写入到主内存。
LoadStore屏障:
用法:GETSTATIC X1; LoadStore; PUTSTATIC X2
在写入指令PUTSTATIC X2写入前,保证GETSTATIC X1要读取的数据被读取完毕。
StoreLoad屏障:
用法:PUTSTATIC X1; StoreLoad; GETSTATIC X2
在读取指令GETSTATIC X2读取操作执行前,保证PUTSTATIC X1的写入对到主内存。
我们前面提到了happen-before规则还有内存屏障,这里总结下,规则是目的,屏障是手段!
1、volatile作用:保证线程可见性,禁止指令重排序。
2、volatile实现其作用的手段:内存屏障