Java多线程之Volatile变量
目录
JAVA内存模型(JMM)
要想深入地了解Volatile变量必须先了解Java内存模型。因此在介绍volatile变量之前,我们先简单的了解下Java内存模型。在此我们将物理机(共享内存多核系统)的内存模型和JMM对比起来看,因为二者之间有很强的相似性。
物理机内存模型
我们知道基于高速缓存的存储交互很好的解决了处理器与内存速度之间的矛盾,但也为计算机系统带来了更高的复杂度,即缓存一致性问题,当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。如果发生这种情况,那同步回到主内存时该以谁的缓存数据为基准呢?为了解决这个问题,需要各个处理器访问缓存时都遵守一些协议,在这里就不一一介绍,这里我们仅仅是为了更好的理解JMM。
JMM(Java内存模型)
从这两张图可以看出Java内存模型的架构和物理机的高度相似。
Java内存模型规定
1、所有的变量(a) 都存储在 主内存(b) 中
a. 此处的变量与我们平时在程序中所说的变量有些许区别,这里的变量包括了实例字段、静态字段和构成数组对象 (可能被多个线程访问的变量)的元素,但是不包括局部变量和方法参数(因为他们时线程私有的,不会被共享)
b. 此处的主内存与物理机的主内存虽然名字一样,但是不是同一个概念,此处的主内存仅仅是分配给Java虚拟机内存的一部分
2、每条线程还有自己的工作内存(a)
a. 线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的数据
主内存副本:如果线程访问一个很大的对象,线程不会把整个对象复制到自己的工作内存中,可能会复制这个对象的引用、对象中被访问到的字段
主内存和工作内存的交互
(该部分摘抄自《深入理解Java虚拟机》,自己写一遍加深下理解,如果有时间可以细品下这部分,还是很有助于理解java内存模型的工作方式的,如果没有时间,就跳过这部分,有用到这部分的地方再回来看看,这并不会影响我们对volatile变量的理解)
了解了Java内存模型的基本架构,很自然地会想到一个问题,主内存和工作内存之间具体是如何交互的,如何避免出现不一致情况的呢?下面我们就来介绍一下主内存和工作内存之间的交互。
Java内存模型定义了以下8种原子操作(long、double型的变量有例外)来完成主内存和工作内存之间的交互
- lock(锁定):作用于主内存中的变量,把一个变量标识为一个线程独占的状态
- unlock(解锁):作用于主内存中的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
- read(读取):作用于主内存中的变量,把一个变量中的值从主内存传输到线程的工作内存中
- load(载入):作用于工作内存中的变量,把read操作从主内存中得到的变量值放入工作内存的变量副本中
- use(使用):作用于工作内存中的变量,把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作
- assign(赋值):作用于工作内存中的变量,把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时将会执行这个操作
- store(存储):作用于工作内存中的变量,把工作内存中一个变量的值传送到主内存中
- write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中
- 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起了回写但主内存不接受
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存
- 一个新的变量只能在主内存中诞生,对一个变量实施use、store之前,必须先执行load和assign操作
- 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁
- 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)
- 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量
Volatile变量的特性及使用场景
通过以上对Java内存模型的简单了解,我们开始介绍Volatile变量
Volatile变量的两项特性
- 可见性:当一条线程修改了这个volatile变量,这个volatile变量的值会立即写入主存中,且其他线程每次使用这个volatile变量之前都会从主存中刷新这个新值
- 禁止指令重排序优化
指令重排序:是指为了提高性能,在保证数据依赖性的条件下对指令进行的重排序。
a = 1;
b = 2;
c = 3;
d = 4; //d为volatile型变量
e = 5;
f = 6;
上面这个例子中,第4行(对volatile型变量赋值)以后的代码不能在第四行以前执行,第四行以前的代码不能在第四行以后执行,其他行可以重排序,这就是volatile型变量的禁止指令重排序特性
下面我们对这两个特性举例解释
可见性
public class Test{
private boolean flag = false;
public void change(){
flag = true;
}
public void doWork(){
while(!flag){
............
}
}
}
如果A线程正在执行doWork,B线程执行了change将flag的状态改为true,这时,A线程并不会立即退出循环,因为B线程对flag的修改是在它的工作内存中进行的,并不会立即写回主存
解决方案:将flag定义为volatile型变量
public class Test{
private volatile boolean flag = false;
public void change(){
flag = true;
}
public void doWork(){
while(!flag){
............
}
}
}
指令重排序
public class Test{
private char[] configText;
private boolean init = false;
//假设以下代码在线程A中执行
public void configer(){
configText = readConfigFile(); //代码1
init = true; //通知其他线程配置可用 代码2
}
//假设以下代码在线程B中执行
public void work() throws Exception{
while(!init){
Thread.sleep(10000);
}
use(configText);
}
}
上面这段程序中代码1和代码2这两行的实际执行顺序可能会发生交换,这种情况就会导致配置信息还未完全配置好时,其他线程就开始使用这个配置信息,这显然是不正确的
解决方案:将init变量定义为volatile型的
public class Test{
private char[] configText;
private volatile boolean init = false;
//假设以下代码在线程A中执行
public void configer(){
configText = readConfigFile(); //代码1
init = true; //通知其他线程配置可用 代码2
}
//假设以下代码在线程B中执行
public void work() throws Exception{
while(!init){
Thread.sleep(10000);
}
use(configText);
}
}
值得注意的是,在多个线程对volatile型变量进行非原子操作时,也不能保证并发安全
设想一个场景
启动10个线程,每个线程对volatile型变量inc执行1000次递增,并添加一个计时线程,预期效果应为10000,而实际输出值为6880,是一个小于10000的值,并未达到预期效果
看代码
package hgh0808;
public class Test {
public static void main(String[] args){
for(int i = 0;i < 10;i++){
Thread th = new Thread(new CThread());
th.start();
}
TimeThread tt = new TimeThread();
tt.start();
try{
Thread.sleep(21000);
}catch(Exception e){
e.printStackTrace();
}
System.out.println(INS.inc);
}
}
---------------------------------------------------------------------
package hgh0808;
import java.util.concurrent.atomic.*;
public class TimeThread extends Thread{
@Override
public void run(){
int count = 1;
for(int i = 0;i < 20;i++){
try{
Thread.sleep(1000);
}catch(Exception e){
e.printStackTrace();
}
System.out.println(count++);
}
}
}
---------------------------------------------------------------------
package hgh0808;
public class CThread implements Runnable{
@Override
public void run(){
for(int j = 0;j < 1000;j++){
INS.increase();
}
}
}
---------------------------------------------------------------------
package hgh0808;
public class INS{
public static volatile int inc = 0;
public static void increase(){
inc++;
}
}
=====================================================================
执行结果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
6880
出现这种情况的原因是,volatile关键字仅仅保证每个线程在开始使用inc时的值是正确的,但是由于自增操作不是原子的,可能在执行自增操作的过程中,其他线程已经将inc的值修改了
通过以上例子,可以知道只有在符合以下两个条件的场景中使用volatile型变量时不需要通过加锁来保证原子性
- 对变量的写入操作不依赖变量的当前值,或者只有单个线程对变量的值进行修改
- 该变量不会与其他状态变量一起纳入不变性条件中
Volatile变量两个特性的底层实现原理
上面我们介绍了Volatile变量的两个特性以及一些常见的使用场景,下面我们探讨一下Java究竟是怎样实现volatile变量的这两个特性的。
为了更好的理解,我们引入一个例子
public class Single {
private volatile static Single instance;
private Single(){}
public static Single getInstance(){
if(instance == null){
synchronized (Single.class){
if(instance == null){
instance = new Single();
}
}
}
return instance;
}
public static void main(String[] args){
getInstance();
}
}
从汇编语言中可以看到在对volatile变量赋值后会加一条lock addl $0x0,(%rsp)
指令
lock addl $0x0,(%rsp)
,这个操作的作用相当于一个内存屏障
- lock前缀的作用是将本处理器的缓存写入内存,该写入动作也会引起别的处理器或者别的内核无效化其缓存。所以通过这样一个操作,可以让某个线程对volatile变量的修改对其他处理器立即可见,这便是可见性的原理
- 指令重排序是指处理器必须能正确处理指令依赖情况保证程序能得出正确的执行结果。
例如,下面这两条语句的顺序是不能交换的,会影响程序的正确性
b+=1;
b*=2;
所以在同一个线程中,重排序过的代码看起来依然是有序的。因此lock addl $0x0,(%rsp)
指令把修改写入到内存中,意味着所有之前的操作都已经执行完成,这样便形成了“指令无法越过内存屏障”的效果,这就是禁止指令重排序的原理