主要内容
本文介绍并发编程中常用的volatile关键字。主要介绍两方面内容
- volatile有哪些特性,可以用来做什么
- volatile实现原理
1 保证可见性
volatile保证了不同线程对volatile修饰变量进行操作时的可见性。
对一个volatile变量的读,(任意线程)总是能看到对这个volatile变量最后的写入
- 一个线程修改volatile变量的值时,该变量的新值会立即刷新到主内存中,这个新值对其他线程来说是立即可见的。
- 一个线程读取volatile变量的值时,该变量在本地内存中缓存无效,需要到主内存中去读取。
举例
中断线程时常采用这种标记方法。
boolean stop = false; //是否中断线程1标志
//Thread1
new Thread(){
public void run(){
while(!stop){
doSomething();
}
};
}.start();
//Thread2
new Thread(){
public void run(){
stop = true;
};
}.start();
目的: Thread2设置stop=true时,Thread1读取到stop=true,Thread1中断执行。
问题: 虽然大多数时候可以达到中断线程1的目的,但是有可能发生Thread2设置stop=true后,Thread1未被中断的情况,而且这种情况引发的都是比较严重的线上问题,排查难度很大
问题分析: Thread2设置stop=true时,并未将stop=true刷到主内存,导致Thread1到主内存中读取到的仍是stop=false,Thread1就会继续执行。也就是有内存可见性问题。
解决: stop变量用volatile修饰。
Thread2设置stop=true时,立即将volatile修饰的变量stop=true刷到主内存;Thread1读取stop的值时,会到主内存读取最新的stop值。
2 保证有序性
volatile关键字能禁止指令重排序,保证了程序会严格按照代码的先后顺序执行,即保证了有序性。
volatile的禁止重排序的规则:
1) 当第二个操作时volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
2)当第一个操作时volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保了volatile读之后的操作不会被编译器排序到volatile读之前/
3)当第一个操作是volatile写时,第二个操作师voaltile读时,不能重排序。
举例:
boolean inited = false; // 初始化完成标志
//线程1: 初始化完成,设置inited-true
new Thread(){
public void run(){
context = loadContext(); // 语句1
inited = true; // 语句2
};
}.start();
//线程2: 每隔1s检查是否完成初始化,初始化完成之后执行doSomething()方法
new Thread(){
public void run(){
while(!inited){
Thread.sleep(1000);
}
doSomething();
};
}.start();
目的: 线程1初始化配置,初始化完成,设置inited=true。线程2每个1s检查是否完成初始化,初始化完成之后执行doSomething()方法。
问题: 线程1中,语句1和语句2之间不存在数据依赖关系。JMM允许这种重排序。如果在程序执行过程中发生重排序,先执行语句2后执行语句1,会发生什么情况
当线程1先执行语句2时,配置并未加载,而inited=true设置初始化完成了。线程2执行时,读取到inited=true,直接执行doSomething方法,而此时配置未加载,程序执行就会有问题。
解决: volatile修饰inited变量
volatile修饰inited,“当第二个操作时volatile写时,不管第一个操作是什么,都不能重排序。”,保证线程1中语句1与语句2不能重排序。
3 不保证原子性
volatile是不能保证原子性的
原子性是指一个操作不能中断的,要全部执行完成,要不就都不能执行。
举例
public class VolatileTest{
public volatile int a = 0;
public void increase(){
a++;
}
public static void main(String[] args){
final VolatileTest test = new VolatileTest();
for(int i=0;i<10;i++){
new Thread(){
public void run(){
for(int j=0;j<1000;j++){
test.increase();
}
};
}.start();
}
while(Thread.activeCount()>1){
//保证前面的线程都执行完
Thread。yield();
}
System.out.println(test.a);
}
}
目的: 10个线程将a加到10000;
结果: 每次运行,得到的结果都小于10000;
原因分析:
首先来看a++操作,其实包括三个操作:
1 读取a=0;
2 计算0+1=1;
3 将1赋值给a;
保证a++的原子性,就是保证这三个操作在一个线程没有完成执行之前,不能被其他线程执行。
一个可能的执行时序图如下:
关键一步,线程2在读取a的值时,线程1还没有完成a=1的赋值操作,导致线程2读取到当前a=0,所以线程2的计算结果也是a=1.
问题在于没有保证a++操作的原子性。如果保证a++的原子性,线程1在执行完三个操作之前,线程2不能执行a++,那么就可以保证线程2执行a++时,读取到a=1,从而得到正确的结果。
解决:
- synchronized保证原子性,用synchronized修饰increase()方法
- CAS来实现原子性操作,AtomicInteger修饰变量a。
4 volatile实现原理
volatile保证有序性原理
前文介绍过,JMM通过插入内存屏障指令来禁止特定类型的重排序。
java编译器在生成字节码时,在volatile变量操作前后的指令序列中插入内存屏障来禁止特定类型的重排序
volatile内存屏障插入策略:
Store:数据对其他处理器可见(即:刷新到内存中) Load:让缓存中的数据失效,重新从内存加载数据
volatile保证可见性原理
volatile内存屏障插入策略中有一条,“在每个volatile写操作的后面插入一个StoreLoad屏障”。
StoreLoad屏障会生成一个Lock前缀的指令,Lock前缀的指令在多核处理器下会引发两件事:
- 将当前处理器缓存行的数据写回系统内存。
- 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
volatile内存可见的写读过程
- volatile修饰的变量进行写操作。
- 由于编译期间JMM插入一个StoreLoad内存屏障,JVM就会向处理器发送一条Lock前缀的指令。
- Lock前缀的指令将该变量所在缓存行的数据写回到主内存中,并使其他处理器中缓存了该变量内存地址的数据失效。
- 当其他线程读取volatile修饰的变量时,本地内存中的缓存失效,就会到到主内存中读取最新的数据。
总结
并发编程中,常用volatile修饰变量以保证变量的修改对其他线程可见。
volatile可以保证可见性和有序性,不能保证原子性。
volatile时通过插入内存屏障禁止重排序来保证可见性和有序性的。
转载于java进阶架构师公众号