还记得上一篇文章当中提到的内存屏障(Memory Fence)吗?其实Volatile的实现原理就是通过内存屏障来实现的。
-
对于volatile修饰的变量:
- 在该变量的写指令后,会加入写屏障
- 在该变量的读指令前,会加入读屏障
上面先放个结论,后面我们逐步的看它是什么意思。
我们看下有如下的代码,主要是为了理解写屏障和读屏障是如何添加,且填在的位置在何处:
public class VolatileTest {
/**
* 定义一个volatile修饰的共享变量
*/
volatile static boolean flag = false;
/**
* 定义全局变量num
*/
static int num = 0;
public static void test1() {
num = 2;
// 此处修改数据ready,会增加一个写屏障,从而num、ready在修改数据后,都会添加到主存当中
flag = true;
}
public static void test2() {
// 此处读取数据ready,会增加一个读屏障,保证后面的ready和num都会从主存当中获取数据
if (flag) {
System.out.println(num);
}
}
public static void main(String[] args) {
new Thread(() -> {
test1();
}, "t1").start();
new Thread(() -> {
test2();
}, "t2").start();
}
}
如上所示,有volatile修饰的变量flag,假设上述代码t1先执行,t2后执行,会有如下过程:
t1执行test1方法,此时将num赋值称为2,num此时可能没有推送到主存当中。之后又执行了对flag赋值的操作,因为flag是volatile修饰的,所以一定会将flag更新到主存,同时将num也会更新到主存。
t2执行test2方法时,首先会读取flag的值,由于flag是有volatile修饰,此时会从主存拉取flag的值,同时num也会从主存获取。
一、可见性如何保证?
前文说到,写屏障对于共享变量的所有修改,在写屏障前的所有共享变量,都需要同步到主内存当中。
读屏障对于共享变量的所有修改,在读屏障后的所有共享变量,都需要同从主存当中获取。
在文章开始的例子当中已经阐述了流程:
在修改flag的值时,所依靠的是写屏障,会在flag被修改后的位置添加一个写屏障,在写屏障之前的的num、和flag修改后的值都会同步到主存当中。
在读取flag的值时,所依靠的是读屏障,在flag读取之前增加一份读屏障,在读屏障后读取的flag和num都会从主存当中获取。
二、有序性如何保证?
写屏障保证在发生指令重排序时,不会将写屏障之前的代码放在写屏障之后。
读屏障会确保指令重排序时,不会将读屏障后的代码放在读屏障之前。
假设在volatile关键字之前有多个变量被修改的语句,那么volatile是不能保证其执行的顺序,能保证的仅仅是在写屏障前的所有代码都执行完毕,并且写屏障前的修改对于读屏障后代码一定是可见的。
假如读取在写屏障之前,那么则不能保证了。
另外需要注意的是,有序性只保证在当前线程内的代码不被重排序。
三、happens-before原则
happens-before 规定了对共享变量的写操作对其它线程的读操作可见,可以说它是可见性与有序性的一套规则总结。
JMM(java memory model,java内存模型)在以下的情况可以保证,线程对共享变量的写,对于其他线程是读可见的,最常见的有以下两种:
-
使用synchronized关键字
前面的文章提到过,当使用重量级锁时,对于共享变量的修改时要同步到主存的。
使用volatile修饰的共享变量
还有以下场景(更多的不在下面举例了):
当线程修改共享变量的值,其结束后,其他线程对于修改后的值是可见的。
线程start()之前,对于变量修改后的值,对其是可见的。
线程t1修改变量的值,随后对正在读取该变量的t2进行打断,此时t1打断线程t2,则t2对于修改后的变量读可见。
四、Double-Checked Locking
相信同学们都学习过单例模式,应该都知道其有很多种实现方式,其中有一种就是double-checked locking(双重检查锁)的方式,如下所示:
public class Singleton {
/**
* volatile 解决指令重排序导致的问题
*/
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
private Singleton() {
}
}
通过我们的尝试知道DCL一定要加上volatile关键字去修饰实例变量instance,那么是为什么呢?
我们先假设没有加volatile关键字的情况,这种情况下砸多线程情况下是会存在问题的。
如下所示,是在没有添加volatile关键字时的字节码文件:
public class com.cloud.bssp.designpatterns.singleton.lazy.dcl.Singleton {
public static com.cloud.bssp.designpatterns.singleton.lazy.dcl.Singleton getInstance();
Code:
0: getstatic #1 // Field instance:Lcom/cloud/bssp/designpatterns/singleton/lazy/dcl/Singleton;
3: ifnonnull 37
6: ldc #2 // class com/cloud/bssp/designpatterns/singleton/lazy/dcl/Singleton
8: dup
9: astore_0
10: monitorenter
11: getstatic #1 // Field instance:Lcom/cloud/bssp/designpatterns/singleton/lazy/dcl/Singleton;
14: ifnonnull 27
17: new #2 // class com/cloud/bssp/designpatterns/singleton/lazy/dcl/Singleton
20: dup
21: invokespecial #3 // Method "<init>":()V
24: putstatic #1 // Field instance:Lcom/cloud/bssp/designpatterns/singleton/lazy/dcl/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #1 // Field instance:Lcom/cloud/bssp/designpatterns/singleton/lazy/dcl/Singleton;
40: areturn
Exception table:
from to target type
11 29 32 any
32 35 32 any
}
我们需要了解的是,jvm创建一个完整的对象实例需要两个步骤:
实例化一个对象,即new 出来的对象,此时是一个默认的空对象,其属性等并没有赋值,只是创建了引用,我们可以认为此时是一个半初始化对象。
初始化步骤,此时需要去调用对象的构造方法,完成属性的赋值等操作,只有经过此步骤才是一个完成的对象。
对应到上面的字节码文件,分别是以下的代码:
- 17:创建一个引用,将引用入栈
- 20:复制地址引用,用于后面使用
- 21:通过前面复制的地址引用,调用对象的构造方法
- 24:将引用赋值到静态变量instance上
相信同学们应该能够对应的上的。
在jvm中呢,如果完全按照上面的步骤执行则不会有问题,但是jvm会优化为先执行24步骤,再执行21步骤,那么结果可想而知,此时静态变量是一个半初始化的对象。
当另外的线程来执行getInstance方法时,获取静态实例对象instance,即字节码文件的第0行,此行代码是在锁synchronized(管程monitorenter)之外,谁来都可以执行,那么获取到了就是半初始对象,不是null,那么一定是有问题的。
通过我们前面的学习,就可以用volatile来解决DCL的这个问题:
这个volatile关键字在字节码是体现不出来的,但是手动标记一下它的位置,只保留主要位置:
0: getstatic #1 // Field instance:Lcom/cloud/bssp/designpatterns/singleton/lazy/dcl/Singleton;
--------------------- 此处加入读屏障 --------------------
3: ifnonnull 37
6: ldc #2 // class com/cloud/bssp/designpatterns/singleton/lazy/dcl/Singleton
8: dup
9: astore_0
10: monitorenter
11: getstatic #1 // Field instance:Lcom/cloud/bssp/designpatterns/singleton/lazy/dcl/Singleton;
14: ifnonnull 27
17: new #2 // class com/cloud/bssp/designpatterns/singleton/lazy/dcl/Singleton
20: dup
21: invokespecial #3 // Method "<init>":()V
24: putstatic #1 // Field instance:Lcom/cloud/bssp/designpatterns/singleton/lazy/dcl/Singleton;
--------------------- 此处加入写屏障 --------------------
27: aload_0
28: monitorexit
但是根据我们前面学习的,写屏障似乎并不能保证21和24的顺序不变啊,因为都是在写屏障之前,它只能保证写屏障之前的代码不会被放到写屏障后。那么它是如何解决的呢?
其实在更加底层volatile转成汇编语言,是在该代码上增加了lock前缀,此时会将其之前的代码锁住,直到执行到这个lock,此时前面的代码都一定执行完了。
从根本说volatile的实现是是一条CPU原语 lock addl。
太过底层就不多赘述了,毕竟我也没学到位呢!!!!