一、Java内存模型
要理解volatile如何确保可见性,需理解Java的内存模型。
Java内存模型规定了所有的变量都存储在主内存中。
每条线程都有自己的工作内存,线程私有的工作内存保存着线程自己需要使用的变量(其变量是对主内存的变量的拷贝)。
当线程对变量的所有操作(读取、赋值)都必须在其私有的工作内存中进行,线程间无法直接访问对方的工作内存,只能通过线程将工作内存的变量写回主存来实现变量的传递。(图片来源于:github.com/LRH1993/and…)
因而基于这种情况,在多线程环境下会产生数据“脏读”的情况。
因而保证共享变量在多线程访问时能够正确输出的结果之前需先介绍,并发编程的三大概念:原子性、有序性、可见性。
二、原子性
原子性:即一个操作或多个操作,要么全部执行,要么全部不被执行,执行过程中不可被中断。
在java中,对基本数据类型的变量的读写和赋值操作都是原子性操作。
先举个例子进行分析:
x = 1; //语句1
y = x; //语句2
x ++; //语句3
x = x + 1;//语句4在这四个语句中,只有第一个语句赋值操作为原子操作,其余三个则不是。
语句二:先读取x的值,再赋值给y,即将x的值写入到对应的线程的工作内存中。
语句三和语句四:先读取x的值,再对x进行加1操作,再写入新值。
所谓原子操作,只有简单的读取、赋值(而且必须是将某个数字赋值给某个变量,变量之间的相互赋值不属于原子操作)才是原子操作。
三、可见性
可见性,指当多个线程访问同一变量时,一个线程修改该变量的值,其他线程能够立即看到修改变量的值。
在java中,volatile关键字保证可见性。
当一个共享变量别volatile修饰时,它会保证修饰的值会立即被更新到主存,而不是写回线程私有的工作内存中。其他线程读取时,它会从主存读取新值。
而普通的共享变量不能保证可见性,因为普通共享变量被修改后,被写入主存的时间是不确定的,当其他线程取读取是,此时内存中可能还是原来的旧值,无法保证可见性。
此外,通过synchronized和Lock也能够保证可见性,因为synchronized和Lock只允许一个线程在一个时刻获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存中。
四、有序性
有序性,即程序执行的顺序按照代码的先后顺序执行。
一般来说,处理机为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码的顺序一致,但它会保证程序最终执行结果和代码顺序执行的结果一致。
指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。
即要想并发程序正确地执行,必须要保证原子性、可见性和有序性。只要有一个没有被保证,就有可能导致程序运行不正确。
在Java内存模型中,允许编译器和处理器对指令进行重排序,但重排序过程不会影响到到单线程程序的执行,却会影响到多线程并发执行的正确性。
保证有序性的有volatile、synchronized和Lock。
Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能得到保证的有序性,这个通常也称为happens-before原则。如果两个操作的执行次序无法从happens-before原则推导出来,则他们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
下面就来具体介绍下happens-before原则(先行发生原则):
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
这8条规则中,前4条规则是比较重要的,后4条规则都是显而易见的。
五、深入理解volatile关键字
1、volatile关键字
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰后,那么具备了两层语义:
(1)保证了不同线程对该变量进行操作时的可见性,即一个线程修改了某个变量的值,这哥新值对其他线程都是可见的。
(2)禁止指令重排序
注意:volatile不保证原子性!!!
具体看如下例子(程序来源于:https://github.com/LRH1993/android_interview/blob/master/java/concurrence/volatile.md)
public class Nothing {
private volatile int inc = 0;
private volatile static int count = 10;
private void increase() {
++inc;
}
public static void main(String[] args) {
int loop = 5;
Nothing nothing = new Nothing();
while (loop-- > 0) {
nothing.operation();
}
}
private void operation() {
final Nothing test = new Nothing();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000000; j++) {
test.increase();
}
--count;
}).start();
}
// 保证前面的线程都执行完
while (count > 0) {
}
System.out.println("最后的数据为:" + test.inc);
}
}输出结果:
最后的数据为:5919956
最后的数据为:3637231
最后的数据为:2144549
最后的数据为:2403538
最后的数据为:1762639在这个例子中,因为volatile不能保证原子性,所以输出的结果不为1000000。
具体是因为volatile只能保证读到的数据是可见的,但是因为++inc前文说过不是操作,当一个线程读取该数据后被阻塞,另外一个线程读取该数据并进行加1操作,而该线程因为读取数据,直接加1,就会出现inc只能变成inc+1,而不是inc+2。
在Java 1.5的java.util.concurrent.atomic包下提供了一些原子操作类,即对基本数据类型的自增、自减,以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作都是原子操作。atomic是利用CAS(Compare And Swap)来实现原子操作的。
3、volatile保证有序性
volatile关键字禁止指令重排序有两层意思:
- 当程序执行到volatile变量的读操作或写操作时,在其前面的操作更改肯定全部已经进行,且结果已经对后面的操作可见,在其后面的操作肯定还没有进行。
- 在进行指令优化时,不能将在对volatile变量的读操作或者写操作的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
如下面例子:
// x、y为非volatile变量
// flag为volatile变量
x = 2; //语句1
y = 0; //语句2
flag = true; //语句3
x = 4; //语句4
y = -1; //语句5因为flag变量被volatile关键字修饰,因而在指令重排序的过程中,不能将语句3放在语句1、语句2之前,也不能放在语句4、语句5之后。但注意语句1和语句2的顺序,语句4和语句5的顺序不能做任何保证。
六、volatile的实现原理
1、可见性
对声明了volatile变量进行写操作时,JVM会向处理器发送一条Lock前缀的指令,将这个变量所在工作内存的数据写回到主内存。这一步确保了如果有其他线程对声明了volatile变量进行修改,则立即更新主内存的数据。
但这时候其他处理器的缓存还是旧的,所以在多处理器环境下,为了保证各个处理器缓存一致,每个处理会通过嗅探在总线上传播的数据来检查自己的缓存是否过期,若过期则会从主存中读取新数据到工作内存。
(这部分知识,可以参考《深入理解Java虚拟机》第二版中12.3.3节,对于volatile型变量的特殊规则。)
2、有序性
Lock前缀执行实际上相当于一个内存屏障,它确保:指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。
七、volatile的应用场景
synchronized关键字是防止多个线程同时执行一段代码,但因为synchronized是重量级锁,影响程序执行效率,而volatile关键字在某些情况下性能优于synchronized,但注意volatile关键字无法替代synchronized关键字,因为volatile无法保证原子性!!!
通常来说,使用volatile必须具备以下2个条件:
- 对变量的写操作不依赖于当前值
- 该变量没有包含在具有其他变量的不变式中
记住在单例模式的双检锁时,单例静态成员对象需要使用volatile关键字修改修饰!!!