本章内容,建议和JMM详解一起看
volatile的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
先说结论:
要了解volatile,就要先对CPU,CPU cache和主存有所有了解。
主存就是我们平时说的内存,它的读写速度很慢,而CPU运算速度很快,为了弥补二者的差异,在CPU和主存之间引入了CPU Cache,
现在很多CPU都会有三层Cache,分别称为L1,L2和L3,其中L1最靠近CPU,缓存速度也最快,L1这块缓存被分为了两个部分,一部分是指令缓存,称为L1i,一部分是数据缓存,称为L1d.
只要有缓存,就会出现缓存不一致的问题,比如i++这个操作。
在有缓存时候的计算流程是:
这样的模型在单线程下没有任何问题,但是在多线程中,就会出现问题,比如在步骤二中执行,此时有一个线程直接给i重新赋值了,这时候就会出现问题。
解决这个问题的一个有效办法就是缓存一致性协议,这个协议大体思路是:
那么我们接下来看一下Java的内存模型,Java的内存模型一个很重要的作用就是规定了:一个线程对共享变量(主内存中的变量)的修改,何时对其他线程可见。具体可看:JMM详解
具体来说就是:
需要注意的是,JMM是个抽象的内存模型,所以所谓的本地内存,主内存都是抽象概念,并不一定就真实的对应cpu缓存和物理内存
接下来了解一下并发编程必须要掌握的几个概念:
一组操作要么不做,要么保证全部做完,不会被中断
volatile关键字无法保证原子性,synchronized可以保证原子性
一个线程对一个变量进行了修改,另一个线程可以马上感知到这种修改。
volatile关键字可以保证可见性,synchronized和final也可以保证可见性
主要就是防止进行指令重排,因为为了优化执行顺序,JVM并不一定会按照书写的顺序去执行,会进行优化,优化的原则是:不保证执行的顺序,只保证重排之后的执行结果和重排之前一样。
public void actor2(I_Result r) {
num = 2;
ready = true; // ready是被volatile修饰的 ,赋值带写屏障
// 写屏障
}
public void actor1(I_Result r) {
// 读屏障
// ready是被volatile修饰的 ,读取值带读屏障
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
看一个例子:
public class TestVolatile implements Runnable{
private boolean flag = true;
public void setFlag(boolean flag) {
this.flag = flag;
}
@Override
public void run() {
System.out.println("我run了");
while(flag){}
System.out.println("我out了");
}
public static void main(String[] args) {
try {
TestVolatile testVolatile = new TestVolatile();
new Thread(testVolatile).start();
Thread.sleep(1000);
testVolatile.setFlag(false);
System.out.println("main end");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
结果:
可以看到程序仍然在执行,并且没有输出停止语句。可是明明已经设置成false了,为什么还没有停止?
根据JMM,Java中有一块主内存,不同的线程有自己的工作内存(JIT高速缓存),同一个变量值在主内存中有一份,如果线程大量用到了这个变量的话,自己的工作内存中有一份一模一样的拷贝。每次进入线程从主内存中拿到变量值,每次执行完线程将变量从工作内存同步回主内存中。
出现打印结果现象的原因就是主内存和工作内存中数据的不同步造成的。因为执行run()方法的时候拿到一个主内存isRunning的拷贝,而设置isRunning是在main函数中做的,换句话说 ,设置的isRunning设置的是主内存中的isRunning,更新了主内存的isRunning,线程工作内存中的isRunning没有更新,当然一直死循环了,因为对于线程来说,它的isRunning依然是true。
解决这个问题很简单,给isRunning关键字加上volatile。加上了volatile的意思是,每次读取isRunning的值的时候,都先从主内存中把isRunning同步到线程的工作内存中,在当前时刻最新的isRunning。看一下给isRunning加了volatile关键字的运行效果:
看到这下线程停止了,因为从主内存中读取了最新的isRunning值,线程工作内存中的isRunning变成了false,自然while循环就结束了。
volatile的作用就是这样,被volatile修饰的变量,保证了每次读取到的都是最新的那个值。
线程安全围绕的是可见性和原子性这两个特性展开的,volatile解决的是变量在多个线程之间的可见性,但是无法保证原子性。
synchronized除了保障了原子性外,其实也保障了可见性。因为synchronized无论是同步的方法还是同步的代码块,都会先把主内存的数据拷贝到工作内存中,同步代码块结束,会把工作内存中的数据更新到主内存中,这样主内存中的数据一定是最新的。
普通的变量仅会保证在该方法的执行过程中所有依赖赋值的结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的顺序一致。
private boolean isInit = false;
private Manager mManger;
public Manager getManager(){
if(!isInit){
mManger = initManager();
isInit = true;
}
return mManager;
}
上面那个例子中,单线程情况下不会有任何问题,在多线程情况下,如果不进行指令重排也不会有问题。
但是如果进行了指令重排,比如指令重排之后,把isInit = true放在了mManager = initManager()之上,很可能在多线程的情况下出现mManager为空的情况,从而出现空指针异常。而且这种异常还很难发现,通常大家都是一脸懵逼,说mManager肯定不可能为空呀。
那现在我们把多线程情况下的指令重排造成的mManager为空的情况说一下:
我们拿常用的双检索的单例模式举例:
public final class Singleton {
private Singleton() { }
private static volatile Singleton INSTANCE = null;
public static Singleton getInstance() {
if(INSTANCE == null) { // t2
// 首次访问会同步,而之后的使用没有 synchronized
synchronized(Singleton.class) {
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
可以看到定义的单例对象上加了volatile关键字。
如果不加volatile关键字,我们查看下从if条件判断开始的这段代码的字节码:
0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
// ldc是获得类对象
6: ldc #3 // class cn/itcast/n5/Singleton
// 复制操作数栈栈顶的值放入栈顶, 将类对象的引用地址复制了一份
8: dup
// 操作数栈栈顶的值弹出,即将对象的引用地址存到局部变量表中
// 将类对象的引用地址存储了一份,是为了将来解锁用
9: astore_0
10: monitorenter
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
// 新建一个实例
17: new #3 // class cn/itcast/n5/Singleton
// 复制了一个实例的引用
20: dup
// 通过这个复制的引用调用它的构造方法
21: invokespecial #4 // Method "":()V
// 最开始的这个引用用来进行赋值操作
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn
其中:
17 表示创建对象,将对象引用入栈 // new Singleton
20 表示复制一份对象引用 // 复制了引用地址
21 表示利用一个对象引用,调用构造方法 // 根据复制的引用地址调用构造方法
24 表示利用一个对象引用,赋值给 static INSTANCE //设置instance指向刚分配的地址
也许 jvm 会优化为:先执行 24,再执行 21。如果两个线程 t1,t2 按如下时间序列执行:
有些人会问,不是synchronized可以阻止重排序么?synchronized并不能阻止重排序,volatile才可以,在这里synchronized内部的代码是串行的,但是synchronized并不能阻止t2线程执行if条件判断。
这个问题的出现是因为instance是可以被2个线程访问,同时,synchronized锁内部发生了指令重排序导致的。
需要volatile关键字的原因是,在并发情况下,如果没有volatile关键字,第五行会出现问题。INSTANCE = new Singleton();可以分解为3行伪代码:
上面的代码在编译运行时,可能会出现重排序从a-b-c排序为a-c-b。在多线程的情况下会出现以下问题:
// -------------------------------------> 加入对 INSTANCE 变量的读屏障
0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 // class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter -----------------------> 保证原子性、可见性
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 // class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 // Method "":()V
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
// -------------------------------------> 加入对 INSTANCE 变量的写屏障
27: aload_0
28: monitorexit ------------------------> 保证原子性、可见性
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn
如上面的注释内容所示,读写 volatile 变量操作(即getstatic操作和putstatic操作)时会加入内存屏障(Memory Barrier(Memory Fence)),保证下面两点:
加入了volatile保证了写屏障之前代码都是有序的,所以在put之前的代码都是有序执行的,t1已经正确的初始化示例了。而读屏障保障保证,读取到的数据已经是最新的数据了。
像之前出现的先get再put并不会导致问题了,因为此时get到的数据是null
上述说到了volatile能够保证变量的一致性,但是并不能保证volatile变量的运算在并发下是一定安全的。
代码示例:
public class TestVolatile1 {
public static volatile int race = 0;
public static void increase() {
race++;
}
private static final int THREADS_COUNT = 10;
public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(() -> {
for (int k = 10000; k > 0; k--) {
increase();
}
});
threads[i].start();
}
// 等待所有累加线程都结束
// activeCount方法返回活动线程的当前线程的线程组中的数量。
// IDEA用户此处不能写大于1,因为IDE会自动创建自动创建一个名为Monitor Ctrl_Break的线程
while(Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(race);
}
}
按正常情况,这个输出结果应该是10万,可是无论我们运行多少次,输出结果都是一个小于10万的数,而且几乎每次都不一样。这是为什么呢?
之前无论是学习JVM还是多线程,包括上文我们提到过++或者–操作都不是个原子性的操作,通过反编译文件,我们可以看到race++实际上是四个字节码指令:
0 getstatic #7 //
3 iconst_1
4 iadd //执行
5 putstatic #7 <com/yhx/juc/Volatile/TestVolatile1.race>
8 return
从字节码可以看出:
虽然volatile只能保证可见性不能保证原子性,但用volatile修饰long和double可以保证其操作原子性。
所以从Oracle Java Spec里面可以看到:
将volatile那段代码编译成汇编语言:
通过对比方法在赋值后(mov %eax…即赋值语句)多执行了一个:lock addl $0x0,(%esi)
的操作,这个操作的作用相当于一个内存屏障,指重排序时不能把后面的指令重排序到内存屏障之前的位置。
这句指令中的"addl $0x0,(%esp)
"(把esp寄存器的值加0)显然是一个空操作(采用这个空操作而不是空指令nop是因为IA32手册规定lock前缀不允许配合nop指令使用),关键在于lock前缀,查询IA32手册,它的作用是将本CPU的Cache写入了内存,该写入动作也会引起别的CPU或者别的内核无效化其Cache(意思是其他的缓存无效),这种操作相当于对Cache中的变量做了一次"store和write"操作,所以通过这样一个空操作,可让前面volatile变量的修改对其他CPU立即可见。
上面这段话整理一下如下:
happens-before规定了对共享变量的写操作对其他线程的读操作可见,它是可见性与有序性的一套规则总结。
抛开以下happens-before规则,JMM并不能保证一个线程对共享变量的写,对于其他线程对该共享变量的读可见。
static int x;
static Object m = new Object();
new Thread(()->{
synchronized(m) {
x = 10;
}
},"t1").start();
new Thread(()->{
synchronized(m) {
System.out.println(x);
}
},"t2").start();
volatile static int x;
new Thread(()->{
x = 10;
},"t1").start();
new Thread(()->{
System.out.println(x);
},"t2").start();
static int x;
x = 10;
new Thread(()->{
System.out.println(x);
},"t2").start();
static int x;
Thread t1 = new Thread(()->{
x = 10;
},"t1");
t1.start();
t1.join();
System.out.println(x);
static int x;
public static void main(String[] args) {
Thread t2 = new Thread(()->{
while(true) {
if(Thread.currentThread().isInterrupted()) {
System.out.println(x);
break;
}
}
},"t2");
t2.start();
new Thread(()->{
sleep(1);
x = 10;
t2.interrupt();
},"t1").start();
while(!t2.isInterrupted()) {
Thread.yield();
}
System.out.println(x);
}
对变量默认值(0,false,null)的写,对其它线程对该变量的读可见
具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z ,配合 volatile 的防指令重排,有下面的例子
volatile static int x;
static int y;
new Thread(()->{
y = 10;
x = 20;
},"t1").start();
new Thread(()->{
// x=20 对 t2 可见, 同时 y=10 也对 t2 可见
System.out.println(x);
},"t2").start();
深入研究底层原理,请看:https://www.cnblogs.com/xrq730/p/7048693.html