前言
在高并发实际场景下,volatile
关键字应用较多,同时,这也是面试的必问的一个知识点了。那么,valatile
关键字有什么作用呢?
内存可见性
这需要从 Java 的内存模型(JMM
)说起,JMM
规定所有的变量都需要存放在主内存中,同时,每个线程又有着自己的工作内存 (主要做高速缓存)。
这样,线程工作时需要操作变量时,需要将主内存中的数据拷贝到工作内存中。这样线程对数据的任何操作都是基于线程本身的工作内存(目的是为了提升效率),而不能去直接操作主内存以及其他线程的工作内存的数据,当线程对数据做了更新以后,还需要将更新后的数据刷新到主内存中。
以上图为例,在并发情况下,可能会出现线程 B 读取到的数据是线程 A 更新之前的数据,从而导致数据不一致。
这个时候,就需要 volatile 出场了:
当一个变量被 volatile 修饰的时候,任何线程对其做的写操作都会被立即刷新到主内存中,并且强制让那些缓存了该变量的线程内的该变量数据清空,需要从主内存中重新读取最新数据。
注意:volatile 修饰的变量,并不是让线程直接操作主内存获取数据,还是需要将变量拷贝到工作内存中。
volidate 应用 demo
我们模拟一个简单的应用场景,两个线程需要同时访问主内存中的某个标志位变量 flag
, 我们用 volidate
来修饰:
public class VolatileRunnable implements Runnable{
private static volatile boolean flag = true ;
@Override
public void run() {
while (flag) {
System.out.println(Thread.currentThread().getName()+ "正在运行。。。");
}
System.out.println(Thread.currentThread().getName()+ "执行完毕。。。");
}
public static void main(String[] args) throws InterruptedException {
VolatileRunable vr = new VolatileRunable();
new Thread(vr,"thread A").start();
System.out.println("main 线程正在运行") ;
TimeUnit.MILLISECONDS.sleep(100) ;
vr.stopThread();}
private void stopThread(){
flag = false ;
}
}
主线程在对 flag 做了修改以后,flag 会立马被刷新到主内存中,从而及时停止线程 A, 如果说 flag 没有被 volidate 修饰,就可能会出现延迟。
volidate 一定能保证线程的安全性吗?
上面说到的 volidate 能够保证被修改的变量及时被刷新到主内存中,很多人会认为这样就能保证线程的安全性。
答案是否定的。volidate 并不能保证线程的安全性!
public class VolatileInc implements Runnable{
private static volatile int count = 0 ; // 使用 volatile 修饰基本数据内存不能保证原子性
//private static AtomicInteger count = new AtomicInteger() ;
@Override
public void run() {
for (int i=0;i<10000 ;i++){
count ++ ;
//count.incrementAndGet();}
}
public static void main(String[] args) throws InterruptedException {
VolatileInc volatileInc = new VolatileInc() ;
Thread t1 = new Thread(volatileInc,"t1") ;
Thread t2 = new Thread(volatileInc,"t2") ;
t1.start();
//t1.join();
t2.start();
//t2.join();
for (int i=0;i<10000 ;i++){
count ++ ;
//count.incrementAndGet();}
System.out.println("最终 Count="+count);
}
}
上面这段代码中,三个线程对 int i
进行累加,最终的结果都会小于 30000,而不是刚好 30000!
这是什么情况?不是说 volidate 保证了内存的可见性吗?
volidate 固然保证了内存的可见性,使得每个线程都能拿到最新的值,但是 count ++ 这个操作并不是原子的,看似简单的自增加 1 的操作,实际上包含了三个操作:
-
获取值;
-
自增;
-
赋值;
但这三个操作没有原子性,并不能同时完成。
那要如何解决对没有原子性的基本数据的并发安全性呢?
-
1.用单线程串行执行(不推荐,无法充分发挥多核 CPU 的优势);
-
2.通过
synchronize
对数据上锁保证原子性; -
3.通过
Atomic
包中的 AtomicInteger 来替换int
, 它的底层利用了CAS
算法来保证原子性
validate 之指令重排
validate
除了能够保证内存的可见性,它的另一重作用是防止 JVM 进行指令重排优化。
用代码说事:
int a=1 ; // 1
int b=2 ; // 2
int c= a+b ; // 3
上面是一段基础代码,理想情况下它的执行顺序是:1 ==> 2 ==> 3
。但是在 JVM 对其进行指令重排优化后,它的执行顺序可能变为: 2 ==> 1 ==> 3
.
JVM 的指令重排优化是在保证最终结果不变的情况下进行的。
上面的代码还看不出指令重排对实际业务带来的影响,看下面的代码:
private static Map<String,String> value ;
private static volatile boolean flag = fasle ;
// 以下方法发生在线程 A 中 初始化 Map
public void initMap() {
// 耗时操作
value = getMapValue() ; // 1
flag = true ; // 2
}
// 发生在线程 B 中 等到 Map 初始化成功进行其他操作
public void doSomeThing() {
while(!flag) {
sleep();}
// dosomething
doSomeThing(value);
}
如果上面的 flag
变量没被 volidate
修饰的话,JVM 指令重排优化后,导致 value
还没有被初始化,就有可能被线程 B 使用了。
这里加上 volidate 之后可以保证业务的正确性。
volidate 防指令重排应用
比较经典的应用场景就是双重懒加载的单例模式了:
public class Singleton {
private static volatile Singleton singleton;
private Singleton(){}
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
// 防止指令重排
singleton = new Singleton();}
}
}
return singleton;
}
}
上面对 Singleton
对象添加 volatile
关键字,就是为了防止指令重排。
如果说我们不使用的话,singleton = new Singleton();
,这段代码其实是分为三步:
-
第一步:分配内存空间
-
第二步:初始化对象
-
第三步:将
singleton
对象指向分配的内存地址
加上 volidate
保证上面三步得以顺序执行,否则可能出现 第二步 在 第三步 之前执行的情况发生,就可能导致某个线程拿到的单例对象是还没有被初始化,导致程序报错。
总结
volatile 在 Java 并发应用场景有很多,比如像 Atomic
包中的 value
、以及 AbstractQueuedLongSynchronizer
中的 state
都是被定义为 volatile
来用于保证内存可见性。
将这块理解透彻对我们编写并发程序时将获益匪浅。