Volatile的用法
volatile通常被比喻成“轻量级的synchronized”,也是java并发编程中比较重要的一个关键字,和synchronized不同,volatile是一个变量修饰符,只能用户来修饰变量,无法修饰方法及代码块等。volatile的用法比较简单,只需要在声明一个可能被多线程访问的变量时,使用volatile修饰就可以。
以下代码,是一个比较典型的使用双重锁校验(DCL)实现的单例模式,其中使用volatile关键字修饰可能被多个线程访问到的实例。
/**
* @Auther: yjntue
* @Date: 2020/3/2 15:16
* @Description: DCL双重校验+volatile
*/
public class SingletonTest {
private volatile static SingletonTest singletonTest ; //volatile可以禁止指令重排序
private SingletonTest(){};
public static SingletonTest getInstance(){
if(singletonTest == null){
synchronized(singletonTest){
if(singletonTest == null){
singletonTest = new SingletonTest();
}
}
}
return singletonTest;
}
}
Volatile的原理
为了提高处理器的执行速度,在处理器与内存之间增加了多级缓存来提升,但是由于多级缓存的引入,就存在缓存数据不一致的情况,对于volatile变量,当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统的主存中。
通过利用工具获得class文件的汇编代码,会发现,标有volatile的变量在进行写操作时,会在前面加上lock质量前缀。
而lock指令前缀会做如下两件事
-
将当前处理器缓存行的数据写回到内存。lock指令前缀在执行指令的期间,会产生一个lock信号,lock信号会保证在该信号期间会独占任何共享内存。lock信号一般不锁总线,而是锁缓存。因为锁总线的开销会很大。
-
将缓存行的数据写回到内存的操作会使得其他CPU缓存了该地址的数据无效。
但是就算回写到主存当中,如果其他处理器的缓存中的值还是旧的,再执行计算操作也还是有问题,所以在多处理器的情况下,为了保证各个处理器的缓存一致,就需要实现缓存一致性协议。
缓存一致性协议:每个处理器都会嗅探在总线上的传输数据来检查自己缓存中的数据是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前的缓存行设置为无效状态,当处理器要对这个数据进行修改的时候,会强制重新从内存中读取数据到处理器的缓存中。
所以,如果一个变量被volatile修饰的话,在每次数据发生变化 后,会强制刷新到主存中,而其他处理器由于遵循了缓存一致性协议,也会把这个变量从主存中加载到自己的缓存中。这就保证了一个变量在被volatile修饰后,其值在多个处理中的缓存中都是可见的。
Volatile与可见性
可见性是指一个线程修改了内存中的副本变量时,其他线程能立即看到修改后的值。
Java内存模型规定了所有的变量都存储在主内存中,每个线程还有自己的工作内存,线程的工作内存中保留了该线程用到的变量的主内存拷贝。线程的所有操作都是在工作内存中,不能直接读写主内存。不同的线程也无法访问对方工作内存中的变量,线程间的变量传递都需要与主内存进行同步,所以就有可能出现线程A修改了某个变量值,但是线程2不可见的情况。
在前面的volatile的原理中,被volatile修饰的变量,在发生变化时,都会立即同步到主内存中,然后通过缓存一致性协议,其他线程在使用该变量时,就能立即同步到线程的缓存副本当中。因此,可以使用volatile修饰符来保证多线程变量的可见性。
volatile与有序性
有序性指程序执行的顺序按照代码的先后顺序执行。
除了引入了时间片以后,由于处理器优化和指令重排等,CPU还可能会对输入代码进行乱序执行。比如:load->add->save有可能被优化成:load->save->add,这样是可能存在有序性问题。而volatile除了保证数据的可见性之外,还有一个强大的功能,那就是他可以禁止CPU指令重排。
volatile可以禁止指令重排,这就保证了代码的程序会严格按照代码的顺序执行,就这保证了有序性。
volatile与原子性
原子性是指一个操作是不可中断的,要么全部执行完成,要么都不执行。
线程是CPU调度的基本单位,CPU有时间片的概念,会根据不同的调度算法进行线程调度。当一个线程获得时间片之后开始执行,在时间片耗尽之后,会失去CPU的使用权。所以在多线程的场景下,由于时间片在线程之间轮换,就会了生原子性的问题。
为了保证原子性,需要通过字节码指令monitorenter和moniterexit,但是volatile和这两个指令没有任何关系,所以volatile是不能保证原子性的。
在以下两个场景中可以使用volatile来代替synchronized:
1、运算结果并不依赖变量的当前值,或者能够确保只有单一的线程会修改变量的值。
2、变量不需要与其他状态变量共同参与不变约束。
除以上场景外,都需要使用其他方式来保证原子性,如synchronized或者concurrent包。
我们来看一下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);
}
}
以上代码比较简单,就是创建10个线程,然后分别执行1000次i++操作。正常情况下,程序的输出结果应该是10000,但是,多次执行的结果都小于10000。原因是inc++是一个几个步骤的操作,无法保证这几个步骤的原子性的问题。
为什么会出现这种情况呢,那就是因为虽然volatile可以保证inc在多个线程之间的可见性。但是无法inc++的原子性。
总结与思考
synchronized可以保证原子性、有序性和可见性。
volatile却只能保证有序性和可见性。
参考:https://blog.youkuaiyun.com/zezezuiaiya/article/details/81456060