volatile关键字是与Java的内存模型有关的,因此需要先了解一下与内存模型相关的概念和知识,再去分析volatile关键字的实现原理和应用场景。
发音:英[ˈvɒlətaɪl] | 美[ˈvɑ:lətl] | |
|
|
|
1 内存模型
1.1 内存模型
计算机执行每条指令都是在CPU中执行的,肯定涉及到了数据的读取和写入。
这时就存在一个问题:由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,两者协调工作会大大降低指令执行的速度,因此引入了高速缓存的概念。
也就是,当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。
两种实现方式:
1)Lock锁的方式:阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存
2)缓存一致性协议:Intel 的MESI协议:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,当其他CPU读取这个变量时,发现自己缓存中变量副本是无效的,则从内存重新读取。
1.2 并发编程的三个概念
1.原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
2.可见性:指当多个线程访问共享变量,一个线程修改了这个变量的值,其他线程能够立见,并获取最新值。否则,应用到的数据就是错误的脏数据。
3.有序性:即程序执行的顺序按照代码的先后顺序执行。
处理器为了提高程序运行效率,可能会对输入代码进行优化,比如重排序,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。但是,多线程中的重排序不能保证和执行的结果一致。
即,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。
总结:要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。
1.3 Java内存模型
在Java虚拟机规范中试图定义一种Java内存模型(JavaMemory Model,JMM)来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
那么Java内存模型规定了哪些东西呢,它定义了程序中变量的访问规则,往大一点说是定义了程序执行的次序。注意,为了获得较好的执行性能,Java内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。也就是说,在java内存模型中,也会存在缓存一致性问题和指令重排序的问题。
Java内存模型规定所有的变量都是存在主存当中(类似于物理内存),每个线程都有自己的工作内存(类似于高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。
1.原子性
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
x = 10; //语句1:原子 写值
y = x; //语句2:非原子 先读取,后写值
x++; //语句3:非原子 先读取,后累加,然后写值
x = x + 1; //语句4:非原子 先读取,后累加,然后写值
Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
2.可见性
Java提供了volatile关键字来保证可见性。 当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
另外,通过synchronized和Lock也能够保证可见性,保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
3.有序性
Java内存模型允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
在Java里面,可以通过volatile关键字来保证一定的“有序性”。
另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
另外,Java内存模型也可以利用happens-before 原则保证先天的“有序性”。
happens-before原则(先行发生原则):
1). 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
2). 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
3). volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
4). 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
5). 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
6). 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
7). 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
8). 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
2 volatile关键字
1.volatile关键字保证了操作时变量的可见性
一个被volatile修饰的共享变量(类的成员变量、类的静态成员变量)具备两层语义:
1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
2)禁止进行指令重排序。
先看一段代码,假如线程1先执行,线程2后执行:
//线程1
boolean stop = false;
while(!stop){
doSomething();
}
//线程2
stop = true;
上述典型代码:大多数时候,线程可以发生中断,但是也有可能无法中断(小概率事件,但是会造成严重的死锁后果)。
无法中断的可能原因:线程1在运行的时候,会将stop变量的值拷贝放入自己的工作内存;当线程2更改了stop值后,还没及时写入主存中就转去做其他事情了,那么线程1仍然会按照之前的stop值来判断是否继续循环下去。
But,用volatile修饰:
第一:使用volatile关键字会强制将修改的值立即写入主存;
第二:使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取(线程1发现自己使用的volatile缓存变量无效,等待缓存行对应的主存地址被更新之后,然后从对应主存重新获值)。
2.volatile无法保证操作的原子性
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) {
final Test test = new Test();
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.inc);
}
}
这段程序事实上运行后会发现每次运行结果都不一致,都是一个小于10000的数字。
前面知道,volatile保证了可见性,如果变量值发生了改变,会立即读取最新结果,那么为什么还保证不了他的正确性呢?
比如:有没有这样一种可能?A线程读取了inc,并进行了自增操作得到结果11,因为某种原因暂时处于阻塞状态然后B线程紧接着读取了inc进行自增操作,并将结果11写进去,然后线程A脱离阻塞状态,将结果写入内存。这里就发生了错误,明明又两次自增 操作,但是内存中却是只有一次自增操作的结果。
原因是:volatile关键字能在这里保证了可见性没有错,但是没有保证原子性。前面说到,自增操作不是原子操作,volatile关键字没有让自增操作的读取/自增/写值三个步骤一气呵成,导致结果发生意外。
可以通过如下三种方式保证操作的原子性:
1)采用synchronized:
public synchronized void increase() { inc++; }
2)采用Lock:
Lock lock = new ReentrantLock();
public void increase() {
lock.lock();
try {
inc++;
} finally{
lock.unlock();
}
}
3)采用AtomicInteger:
public void increase() {
inc.getAndIncrement();
}
java 1.5的java.util.concurrent.atomic包下提供了一些原子操作类,即对基本数据类型的 自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。atomic是利用CAS来实现原子性操作的(Compare And Swap),CAS实际上是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG指令是一个原子性操作。
3.volatile能保证有序性吗?
volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。
两层意思:
1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见,在其后面的操作肯定还没有进行;
2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
4.volatile的原理和实现机制
volatile到底如何保证可见性和禁止指令重排序的?
“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”--- 《深入理解Java虚拟机》
lock前缀指令相当于一个内存屏障(也成内存栅栏),会提供3个功能:
1)确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
2)它会强制将对缓存的修改操作立即写入主存;
3)如果是写操作,它会导致其他CPU中对应的缓存行无效。
3 volatile关键字使用场景
synchronized关键字可以是防止多个线程同时执行一段代码,但是很影响程序执行效率;而volatile关键字在某些情况下性能要优于synchronized,但无法替代synchronized,因为volatile关键字无法保证操作的原子性。
使用volatile必须具备2个条件:
1)对变量的写操作不依赖于当前值
2)该变量没有包含在具有其他变量的不变式中
实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。
几个volatile的几个场景:
1. 状态标记量
volatile boolean flag = false;
while(!flag){
doSomething();
}
public void setFlag() {
flag= true;
}
volatile boolean inited = false;
//线程1:
context =loadContext();
inited = true;
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
2. double check
class Singleton{
private volatile static Singleton instance = null;
private Singleton() { }
public static Singleton getInstance() {
if(instance==null){
synchronized (Singleton.class) {
if(instance==null)
instance= new Singleton();
}
}
return instance;
}
}