Volatile的用法
volatile关键字是Java虚拟机提供的最轻量级的同步机制,作为一个修饰符出现,用来修饰变量,但不包括局部变量。
先来一段demo代码:
public class Test {
public static void main(String[] args) {
Aobing a =new Aobing();
a.start();
for(;;){
if(a.isFlag()){
System.out.println("冲冲冲");
}
}
}
}
class Aobing extends Thread{
private boolean flag =false;
public boolean isFlag(){
return flag;
}
@Override
public void run(){
try{
Thread.sleep(1000);
}catch (InterruptedException e){
e.printStackTrace();
}
flag =true;
System.out.println("flag="+flag);
}
}
在主程序中,有一个死循环来监控flag的状态,当状态变为true时,输出冲冲冲,按理说只要子线程中打印出flag的状态来就表明flag变为true,冲冲冲就会被打印出来。
但是在实例运行过程中是不会输出冲冲冲的。那又是为什么呢。
原因是flag虽然发送了变化,但是由于flag的不可见,导致第一个线程不知道flag发生了变化所以一直未输出。针对这个问题可以在变量上加上volatile,来让这个变量具有可见性,从而让第一个线程来跳出循环。
Volatile修饰变量的作用
由上面的例子我们可以得知Volatile具有让变量对所有线程可见性的能力,除此之外还有如下的能力。
- 保证变量对所有线程可见性
- 禁止指令重排序
- 不保证原子性
为了更好的理解Volatile,我们有必要先来看一下计算机内存模型以及JMM。
现代计算机内存模型
由于现在存储设备与处理器的运算速度差距太大,所以需要加入高速缓存(cache)来作为内存与处理器之间的缓冲。这样处理器无需等待缓慢的内存读写了
在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享一个主内存
虽然高速缓存很好的解决了处理器与内存的速度矛盾,但是也引入了一个新的问题:缓存一致性。
对此有两个方案来改变:
- 在总线上加LOCK锁
- 通过缓存一致性协议
总线加LOCK锁
CPU和其他功能组件是通过总线通信的,如果在总线加LOCK锁,那么在锁住总线期间,其他CPU是无法访问内存的。虽然能解决缓存一致性问题,但是会导致效率低下
MESI协议
为了解决一致性问题,还可以通过缓存一致性协议,即各个处理器访问缓存时都遵循统一的协议,在读写时要根据协议来进行操作。其核心思想就是:
当CPU写数据时,如果发现操作的变量是共享变量,就会发出通知其他CPU将该缓存行置位无效状态。
CPU每个缓存行标记的四种状态(M、E、S、I)
缓存状态 描述 M(被修改) 该缓存行只被该CPU缓存,与主存的值不同,会在它被其他CPU读取之前写入内存,并设置为Shared E(独享的) 该缓存行只被该CPU缓存,与主存的值相同,被其他CPU读取时置为Shared,被其他CPU写时置为Modified S(共享的) 该缓存行可能被多个CPU缓存,各个缓存中的数据与主存数据相同 I(无效的) 该缓存行数据是无效的,需要时需要从主存载入
MESI协议的实现以及保证当前处理器的内部缓存、主内存和其他处理器的缓存数据在总线上的一致性需要依靠多处理器总线嗅探
嗅探技术
在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己的缓存值是否过期。如果处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据库读到处理器缓存中。
JMM
- JMM:Java内存模型,是java虚拟机规范中所定义的一种内存模型,屏蔽掉各种硬件和操作系统的内存访问差异。以实现让java查询在各种平台上都能达到一致的内存访问效果。
- 描述了java查询中各种变量(线程共享变量)的访问规则,以及在JVM中将变量,存储到内存和从内存中读取变量的底层细节
JMM规定:
- 所有的共享变量都存储于主内存
这里的变量指的时候实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题
- 每一个现在还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本
线程对变量的所有的操作(读、取)都必须在工作内存中完成,而不能直接读写主内存中的变量
- 不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成
由于这种机制才导致可见性问题的存在,下面就讨论有关可见性的解决方案。
并发编程的三个特性
原子性
指操作是不可中断的,要么执行完成,要么不执行,基本数据类型的访问和读写都是具有原子性的。
可见性
- 当一个线程修改了共享变量的值后,其他线程能立即得知这个修改
- java内存模型是通过在变量修改后将内置同步回主内存,在变量读取前从主内存刷新变量值。
- Volatile变量保证了新值能立即同步会主内存,以及每次使用前立即从主内存刷新。
- Synchronized和Lock也能保证可见性,线程在释放锁之前,会把共享变量的值都刷回主内存。final也可以实现可见性
有序性
如果在本线程内观察,所有的操作都是有序的;如果在一个线程内观察另外一个线程,所有的操作都是无序的
即在java内存模型中,荀彧编译器和处理器对执行进行重排序,会影响多线程并发执行的正确性,但不管怎么重排序,单线程的执行结果不会改变
重排序:编译器和处理器为了提高并行度。
CPU重排序包括指令并行重排序和内存系统重排序
- 过程:
Volatile底层原理
通过上面我们得知Volatile的语义就是保证变量对所有线程可见性以及禁止指令重排优化。那么是如何保证的呢?这都与内存屏障有关
保证可见性
Volatile能保证修饰的flag变量后,可以立即同步回主内存中。并且每次在使用前立即先从主内存刷新最新的值。
内存屏障
java编译器会在生成指令系列时在适当的位置会插入内存屏障来禁止特定类型的处理器重排序
需要注意的是:volatile写 是在前面和后面分别插入内存屏障,而volatile读 是在后面插入两个内存屏障
内存屏障保证了:
- 重排序时不能把后面的指令重排序到内存屏障之前的位置
- 将本处理器的缓存写入内存
- 如果是写入动作,会导致其他处理器中对应的缓存无效
Volatile的典型场景
通常来说,使用Volatile必须具备以下两个条件
- 对遍历的写操作不依赖当前的值
- 该变量没有包含在具有其他变量的不变式中
实际上Volatile场景一般就是状态标志以及DCL单例模式
状态标志
Map configOptions;
char[] configText;
// 此变量必须定义为 volatile
volatile boolean initialized = false;
// 假设以下代码在线程 A 中运行
// 模拟读取配置信息, 当读取完成后将 initialized 设置为 true 以告知其他线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
// 假设以下代码在线程 B 中运行
// 等待 initialized 为 true, 代表线程 A 已经把配置信息初始化完成
while(!initialized) {
sleep();
}
// 使用线程 A 中初始化好的配置信息
doSomethingWithConfig();
DCL单例模式
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;
}
}
volatile和synchronized的区别
- volatile只能修饰实例变量和类变量,而Synchronized可以修饰方法以及代码块
- volatile保证数据的可见性,禁止重排序,但是不保证原子性(多线程进行写操作,不保证线程安全);Synchronized是一种排他(互斥)机制,保证原子性。
- volatile用于禁止指令重排序,可以解决单例双重检查对象初始化代码执行乱序问题。
- volatile可以看做是一个轻量级的Synchronized,volatile不保证原子性(不保证线程执行的有序性),但是如果是对一个共享变量进行多个线程的赋值,没有其他的操作,就可以用volatile来代替Synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就可以保证线程安全了
- Volatile不会造成线程阻塞,Synchronized会造成线程阻塞,需要锁优化
volatile总结
- 禁止进行指令重排序(实现有序性)
- 可见性:volatile修饰符适用于以下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,比如booleanflag;或者作为触发器,实现轻量级同步。
- volatile属性的读写操作都是无锁的,它不能替代synchronized,因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁上,所以说它是低成本的。
- volatile只能作用于属性,我们用volatile修饰属性,这样compilers就不会对这个属性做指令重排序。
- volatile提供了可见性,任何一个线程对其的修改将立马对其他线程可见,volatile属性不会被线程缓存,始终从主 存中读取。
- volatile提供了happens-before保证,对volatile变量v的写入happens-before所有其他线程后续对v的读操作。
- volatile可以使得long和double的赋值是原子的。
- volatile可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性。
Volatile相关面试题
Volatile的特性
- 保证变量对所有线程的可见性
- 禁止指令重排序
- 不保证原子性
内存语义
- 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
- 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
并发三大特性
- 原子性
- 可见性
- 有序性
什么是内存可见性与指令重排序
- 可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。
- 指令重排是指JVM在编译Java代码的时候,或者CPU在执行JVM字节码的时候,对现有的指令顺序进行重新排序。
Volatile如何解决可见性
- 底层是通过内存屏障实现的哦,volatile能保证修饰的变量后,可以立即同步回主内存,每次使用前立即先从主内存刷新最新的值。
Volatile如何防止指令重排序
-
也是内存屏障哦,跟面试官讲下Java内存的保守策略:
-
在每个volatile写操作的前面插入一个StoreStore屏障。
-
在每个volatile写操作的后面插入一个StoreLoad屏障。
-
在每个volatile读操作的后面插入一个LoadLoad屏障。
-
在每个volatile读操作的后面插入一个LoadStore屏障。
再讲下volatile的语义哦,重排序时不能把内存屏障后面的指令重排序到内存屏障之前的位置
Volatile可否解决原子性
不可以,原子性需要synchronzied或者lock保证
最后
- 如果觉得看完有收获,希望能给我点个赞,这将会是我更新的最大动力,感谢各位的支持
- 欢迎各位关注我的公众号【java冢狐】,专注于java和计算机基础知识,保证让你看完有所收获,不信你打我
- 如果看完有不同的意见或者建议,欢迎多多评论一起交流。感谢各位的支持以及厚爱。