volatile关键字
volatile关键字提供最轻量级的同步机制,但是想完全正确的理解该关键字你需要知道的东西有很多。比如先行并发原则、指令重排序以及最重要的JMM。下面一起来看看吧。
前言
一、看一下这段代码?
public class Test extends Thread {
//定义一个变量来控制循环结束
static boolean falg =true;
@Override
public void run() {
System.out.println("该线程启动了");
while(falg) {
}
//如果上边是一个死循环,下边这句话将不会打印
System.out.println("该线程停止了");
}
public static void main(String[] args) throws InterruptedException {
Test test = new Test();
test.start();
//自定义的线程先启动,后修改变量的值
Thread.sleep(100);
falg = false;
}
}
我们知道上述的代码是不会停止的,因为Java内存模型的规则导致这种现象的发生。
如果你一眼看不出或者对JMM不是很清楚,那么请务必看一下我写的另一篇文章JMM(Java内存模型)这对你理解volatile关键字很有帮助。
内存模型图:
内存模型规则
1.我们所有定义的变量(除局部变量)都存放在主内存(Main Memory)
2.每个线程都有自己的工作内存,工作内存中存放需要使用的变量的主内存副本!
3.线程对所有变量的操作都必须在工作内存中进行。不能直接在主内存中操作。
4.不同线程之间的无法直接访问对方工作内存中的变量。线程间变量值的传递需要通过主内存进行传递。
现在我们应该知道为什么会出现上述情况了吧。我们定义的变量falg是存放在主内存中的,而线程使用的变量是自己工作内存中的变量(该变量是主内存中变量的一个副本)。根据上述规则当一个线程更改变量的值后,需要通过主内存来进行值的传递,但是另一个线程并没有及时获取该变量当前最新值
volatile关键字保证线程中变量的可见性:即变量在修改后将新值同步回主内存,在变量读取之前从主内存刷新变量值
问:volatile关键字怎么保证变量对线程的可见性?
得益于:先行发生原则
先行发生原则可以作为判断数据是否存在竞争,线程是否安全的一种手段
先行发生原则是Java内存模型中定义的两项操作之间的偏序关系。比如说操作1
先行发生于操作2,操作1的影响能被操作2所观察到(影响包括对变量的修改,发送
消息,调用方法)等等
举个例子:
操作1:
i = 1;
操作2:
j = i;
分析:
假设操作1发生在操作2之前,那么我们可以根据先行发生原则来断定j的值就是1
此时在上述情况满足的情况下我们又添加一个操作3
操作3:
i = 2;
这时我们的j值就可能是1或者2.原因是可能操作2在操作3之前就执行了,或者
说是先执行了操作3后执行了操作2.即操作3对变量i的影响有可能被操作2观察到,
但是有可能没有观察到。此时操作2有可能读取到过期数据,因此是线程不安全的。
Java内存模型中已定义的8种先行发生规则
仅展示部分常见规则
1.管程锁定规则:一个unlock操作先行发生与对同一个锁的lock操作
2.volatile变量原则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作
3.线程启动规则:线程的start()方法会先执行在线程的每一个动作之前
4.线程终止规则:线程中所有的操作都先行发生在此线程的终止检测。
我们在衡量并发安全问题的时候不要受时间顺序的干扰,一切必须以先行发生原则为准。
那么我们在什么时候使用该关键字呢,有两点:
问题来了:volatile关键字可以保证线程安全吗?
看一段代码:
public class Test {
static volatile int i = 0;
//定义一个方法做累加
public static void add() {
i++;
}
public static void main(String[] args) throws InterruptedException {
//做50个循环,创建50个线程,每个线程做50次累加
for(int i = 0 ; i < 50; i++) {
new Thread(new Runnable() {
@Override
public void run() {
for(int j = 0; j < 50;j++) {
add();
}
}
}).start();
}
Thread.sleep(3000); //当前线程睡眠3秒
System.out.println(i);
}
}
//打印结果:2498
从上述的结果中我们可以看出结果并不是我们预知的2500而是偏小了。所以我们知道volatile关键字是不能保证线程安全的。不能!不能!不能!
上述结果简单的进行分析:为什么会出现这种情况呢
首先我们要知道i++不是一个原子性操作,此时i++在我们的字节码指令中是四条指令。
拿到我们的i值将他放入操作数栈的栈顶中,此时栈顶的i值在读取的时候一定是最新的值,但是在做iadd操作的
时候,也许别的线程已经将i的值+1了,这样会导致此时的操作数栈顶的元素要小于此时i的实际大小。最后导致
整体的结果偏小。
那么我们什么时候使用该关键字呢,一般我们有两个前提:
①:运算结果并不依赖当前变量的值,或者说只有一个线程可以修改变量的值
②变量不需要与其它状态的变量共同参与不变约束
volatile关键字可以禁止指令重排序
什么是指令重排序?
在不改变程序运行结果的前提下(单线程)对指令的顺序进行重新排序,从而优化运行
效率的一种手段。
举个例子:
x + 3;
y + 4;
x + 5;
y + 6;
优化后:
x + 3;
x + 5;
y + 4;
y + 6;
这样做的目的可以减少读取变量x,y的次数
我们要知道指令重排序对与单线程来说是没有什么线程安全问题,但是在多线程前提下就会造成线程安全问题,那么volatile关键字是怎么做到禁止指令重排序呢? —> 内存屏障
内存屏障是一种屏障指令,它使cpu或者编译器在屏障之前和之后的内存指令进行
排序约束,这就表明在屏障之前的指令一定会在屏障之后的指令先执行!!!
内存屏障的四种类型
LoadLoad屏障:
抽象场景:Load1; LoadLoad; Load2
Load1 和 Load2 代表两条读取指令。在Load2要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:
抽象场景:Store1; StoreStore; Store2
Store1 和 Store2代表两条写入指令。在Store2写入执行前,保证Store1的写入操作对其它处理器可见
LoadStore屏障:
抽象场景:Load1; LoadStore; Store2
在Store2被写入前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:
抽象场景:Store1; StoreLoad; Load2
在Load2读取操作执行前,保证Store1的写入对所有处理器可见。StoreLoad屏障的开销是四种屏障中最大的
那么volatile关键字是怎么做的?
1.在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障。
2.在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障。
总结
提到volatile关键字我们要理解Java内存模型和可见性,简单了解指令重排序和内存屏障。