引言
首先volatile是轻量级的同步机制,相比synchronized同步机制。因为它不会引起线程上下文的切换和调度。但是volatile 变量的同步性较差(有时它更简单并且开销更低),而且其使用也更容易出错。
这里先介绍在Java多线程的开发中有三种特性:原子性、可见性和有序性。
- 原子性:可以直接描述成一次操作中要不一次成功,要不改变。
- 可见性:当一个线程修改了线程共享变量的值,其它线程能够立即得知这个修改。下图是Java内存模型
每一个线程都有一份自己的本地内存,所有线程共用一份主内存。如果一个线程对主内存中的数据进行了修改,而此时另外一个线程不知道是否已经发生了修改,就说此时是不可见的。
那么这种模型带来一个问题就是当一线程修改了共享变量的值后,而另外一个线程的本地内存中这个共享变量仍然是原值,使得数据不同步了。所以需要有一种机制就是当一个线程修改共享变量的值后必须立刻刷新到主内存中同时将所有的本地缓存中该共享变量的值清空,然后其他线程需要该共享变量时就得从主内存中读取。
volatile关键字的作用很简单,就是一个线程在对主内存的某一份数据进行更改时,改完之后会立刻刷新到主内存。并且会强制让缓存了该变量的线程中的数据清空,必须从主内存重新读取最新数据。
- 有序性:程序执行的顺序按照代码的先后顺序执行。
Java内存模型中的有序性可以总结为:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句是指“线程内表现为串行语义”,后半句是指“指令重排序”现象和“工作内存主主内存同步延迟”现象。
在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,重排序不会影响单线程的运行结果,但是对多线程会有影响。有序性最著名的例子就是单例模式里面的DCL(双重检查锁)。
volatile不能保证原子性
下面的代码演示了++i操作
private static volatile int a=0;
//volitale无法保证原子性
public static void main(String[] args) {
Thread[] threads=new Thread[5];
for (int i=0;i<5;i++){
threads[i]=new Thread(()->{
try {
for(int j=0;j<10;j++){
System.out.println(++a);
Thread.sleep(100);
}
}
catch (Exception e){
e.printStackTrace();
}
});
threads[i].start();
}
}
结果:
从结果看到有重复的数字,我们知道++i是一个非原子操作,其实有三个步骤:读,+1,赋值。只要这三步中任何一步被其他线程接入后,都不能保证结果正确。所以volatile是无法保证原子性的。
volatile如何保证有序性
volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。volatile关键字禁止指令重排序有两层意思:
- 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
- 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
上面的两句的意思我们通过一个例子来展示:
int x=1;//语句1
int y=2;//语句2
volatile boolean flag=true;//语句3
int a=1;//语句4
int b=2;//语句5
上面的代码有4条语句,根据禁止指令重排的意思就是,加了volatile关键字的语句3既不会放在语句1和2之前执行,也不会放在语句4和5之后执行。同时语句1和2执行的结果对语句3、4、5都是可见的。
下面的代码目标是使用两个进程,一个进程加载资源,另外一个进程利用加载完成的资源做其他的事。
//线程1:
context = init(); //初始化
inited = true; //表示是否初始化完毕
//线程2:
while(!inited ){//若还初始化完成则线程2保持睡眠
sleep()
}
doSomethingwithconfig(context);//否则线程2就做别的事
乍一看,好像可以满足要求,但是实际上编译器可能将init=true
放在context=init()
之前执行,那么线程2拿着还未加载好的资源去其他的事就可能会出错。
那么解决方法就是在将init变量前用volatile修饰,禁止指令重排。
volatile实现原理
《深入理解Java虚拟机》中说“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”
lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
- 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
- 它会强制将对缓存的修改操作立即写入主存;
- 如果是写操作,它会导致其他CPU中对应的缓存行无效。
这3点其实就是为了实现可见性和禁止重排。
可见性可以理解为一个线程的写操作可以立即被其他线程得知。
这里需要引入总线嗅探机制原理:
1.每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态, 当处理器对这个数据进行修改操作的时候,会重新从系统内存中吧数据读到处理器缓存行里。
2.处理器使用嗅探技术保证它的内部缓存,系统内存和其他处理器的缓存在总线上保持一致。
volatile修饰的共享变量的写操作会触发“嗅探”,让处理器本地缓存中的volatile变量失效;
所以如果是对volatile
修饰的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在的缓存行的数据立即写回到系统内存。但是即便写回到系统内存,其他CPU中的缓存行数据还是旧的,为了保证数据一致性,其他CPU会嗅探在总线上传播的数据来检查自己的缓存行的值是否过期,当CPU发现缓存行对应的内存地址被修改,那么就会将当前缓存行设置为无效,下次当CPU对这个缓存行上 的数据进行修改时,会重新从系统内存中把数据读到处理器缓存里。
volatile使用场景
我们在设计并发程序时,就要考虑好程序的原子性、可见性和有序性,既然volatile无法保证原子性的话,而我们又有时需要使用它,那么就得自己实现程序的原子性。所以使用volatile必须具备以下2个条件:
- 对变量的写操作不依赖于当前值
- 该变量没有包含在具有其他变量的不变式中
其实就是要保证原子性。那么它很适合状态标志使用:
volatile boolean flag = false;
while(!flag){
doSomething();
}
public void setFlag() {
flag = true;
}
线程以volatile变量作为循环控制变量(例如控制线程是否继续执行,控制线程的生命周期),由另外一个线程控制该变量的值(true or false)。这种情况需要变量具有可见性,volatile变量适合。当然也可使用synchronized关键字实现,但是要麻烦得多。
上面提到可以实现单例模式下的双重锁检验也是volatile的一大实现:
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;
}
}
这里说一下instance=new Singleton()
其实发生了三个动作:
- memory=allocate();// 分配内存
- ctorInstanc(memory) //初始化对象
- instance=memory //设置s指向刚分配的地址
若不用volatile修饰instance的话,可能发生指令重排。
volatile与synchronized比较
- volatile本质是在告诉JVM当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住;
- volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的;
- volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性;
- volatile不会造成线程的阻塞,即volatile不能用来同步,因为多个线程并发访问volatile修饰的变量不会阻塞;synchronized可能会造成线程的阻塞;
- volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。