并发编程的三大特性为:原子性、可见性、有序性,下面将从这三个特性来阐述volatile的作用,先说结论:volatile能够保证可见性和有序性,但不保证原子性。
一、Java的内存模型
要想搞明白volatile的作用,需要首先了解Java的内存模型。
计算机内存模型
首先从计算机CPU和内存说起。我们都知道CPU的速度非常快,而内存的速度远不如CPU,但CPU和内存之间需要进行频繁的数据交换,这样一来就降低了机器的性能,为解决这一问题引入了高速缓存用来缓解这一冲突。
如今的计算机都是多核协作来进行并行计算,需要多个处理器协同工作。引入高速缓存后很好的解决了处理器与内存速度不匹配的问题,但也引入了新的问题:缓存一致性问题。在多处理器系统中,每个处理器都有自己的高速缓存,而他们又共享同一主存,当多个处理器都涉及同一块内存区域时,就会发生缓存不一致的现象。为了解决这一问题,需要各个处理器运行时都遵循一些协议,在运行时需要用这些协议保证数据的一致性。
缓存一致性协议中最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存设置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中该变量是无效状态,那么它就会从内存重新读取
此处知识可以参考操作系统中的三级存储结构
Java内存模型
接下来介绍Java内存模型
JMM与上面的结构十分类似
Java的内存模型和上面的结构还是挺相似的,此时在看工作内存和主内存关系,从逻辑上,高速缓存对应工作内存,每个线程分配到CPU时间片时,独自享有高速缓存的使用能力。主内存对应存储的物理内存。特别注意,这只是逻辑上的对等关系,物理的上具体对应关系十分复杂,这里不讨论。
二、volatile的作用
volatile可以保证可见性、有序性,但不能保证原子性
可见性
- 可见性是指当多个线程访问同一变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
- 实现方式(类比上面提到的 缓存一致性协议)
(1) 当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去。
(2) 这个写操作会导致其他线程中的volatile变量缓存无效
(3) 当读取一个变量时如果内存失效则重新从主存中读取
public class MyVolatile {
public static volatile boolean flag = false;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while(!flag) {
}
System.out.println("threadB end!");
}).start();
Thread.sleep(1000);
new Thread(()-> {
flag = true;
System.out.println("threadA end!");
}).start();
}
}
输出结果为:
threadA end!
threadB end!
如果去掉flag前面的volatile修饰符则输出结果为:
threadA end!
可见threadB并没有感知到flag变化
有序性
首先我们要明白为什么会出现无序。
编译器和处理器为了优化程序性能而对执行序列进行排序的一种手段。重排序需要遵守一定的规则
(1) 重排序操作不会对存在数据依赖关系的操作进行重排序。
(2) 重排序是为了优化性能,无论如何重排序单线程下执行结果不能被改变。
注意上面所说的是指令重排需要保证单线程下执行结果不能被改变,并不能保证多线程下执行结果不变。
举个例子:
public class TestVolatile{
int a = 1;
boolean status = false;
//状态切换为true
public void changeStatus{
a = 2; //1
status = true; //2
}
//若状态为true,则为running
public void run(){
if(status){
System.out.println(a); //4
}
}
}
依次调用changeStatus与run,单线程环境下能够保证执行顺序为:1->2->3(->4),或者2->1->3(->4)
即保证输出a时,a = 2。
但多线程下由于1与2发生重排,另一个线程读取到status = true 时输出a时 a = 1。
此外还有 双重检测锁版本的单例模式:
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {}
public static Singleton getInstance() {
if (uniqueInstance == null) {
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
uniqueInstance = new Singleton(); 该行代码实际上分为三步:
- 1 创建对象
- 2 初始化对象
- 3 将对象的引用赋值给uniqueInstance
不加volatile关键字会导致上述三个步骤重排,若2与3发生重排,其他线程使用时发现uniqueInstance != null 对其进行操作,但此时很可能没有执行2进行初始化,此时就会发生异常
- 实现方式
使用volatile关键字修饰共享变量便可以禁止这种重排序。若用volatile修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,volatile禁止指令重排序也有一些规则:
a.当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
b.在进行指令优化时,不能将对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。即执行到volatile变量时,其前面的所有语句都执行完,后面所有语句都未执行。且前面语句的结果对volatile变量及其后面语句可见。
原子性
原子性即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
public class MyVolatile {
public static volatile int inc = 0;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 5; i++) {
new Thread(() -> {
for (int j = 0 ; j < 1000; j++) {
inc++;
}
}).start();
}
while(activeCount() > 1) {
Thread.yield();
}
System.out.println(inc);
}
}
执行结果不一定是 5000
因为inc++不是一个原子性操作,可以由读取、加、赋值3步组成
解决方案:可以通过synchronized或lock,进行加锁,来保证操作的原子性。也可以通过使用AtomicInteger