Volatile关键字
volatile 是Java虚拟机提供的轻量级的同步机制。
1.基本概念
Java内存模型是围绕着在并发过程中如何处理 原子性、可见性 和 有序性 这3个特征来建立的,我们先来看一下这三个特性。
可见性:
是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。
原子性:
一个操作不可分割的,不可分离的。举个简单例子,对于变量x,进行加1,然后取到值,这一个过程尽管简单,但是却不具备原子性,因为我们要先读取x,之后进行计算,然后重新写入,其实是几个步骤。如果仅仅对x进行赋值,那么则可以认为是原子的。
有序性:
即程序执行的顺序按照代码的先后顺序执行。
2.为什么使用Volatile?
volatile 关键字有如下两个作用:
1、保证被 volatile 修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被 volatile 修饰共享变量的值,新值可以被其他线程立即得知(可见性)
2、禁止指令重排序优化(有序性)
3.volatile的可见性
public class VolatileVisibilityTest {
private static boolean flag=false;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
System.out.println("等待数据载入");
while (!flag){
}
System.out.println("==================载入成功");
}).start();
Thread.sleep(2000);
new Thread(()->prepareData()).start();
}
public static void prepareData(){
System.out.println("准备数据");
flag=true;
System.out.println("准备完成");
}
}
3.1 CPU多核缓存架构模型
首先将硬盘中的数据加载到主内存(RAM)中,然后将数据加载到CPU高速缓存中,最后CPU运行时实际上是CPU与CPU高速缓存进行数据交互的
假设i=0;执行
i=i+1;
3.2 缓存一致性协议
多核处理器架构厂商,设计之初就预测到了多线程操作导致数据不一致性的的问题,于是出现了缓存一致性协议
最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。
- Modify(修改):当缓存行中的数据被修改时,该缓存行置为M状态
- Exclusive(独占):当只有一个缓存行使用某个数据时,置为E状态
- Shared(共享):当其他CPU中也读取某数据到缓存行时,所有持有该数据的缓存行置为S状态
- Invalid(无效):当某个缓存行数据修改时,其他持有该数据的缓存行置为I状态
它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
可见,缓存一致性解决了多核硬件架构的一致性问题,JMM也遵照多核硬件架构的设计,用JAVA实现了JVM层面的”缓存一致性“。
3.3 CPU总线嗅探机制
由于 CPU 与内存之间加入了缓存,在进行数据操作时,先将数据从内存拷贝到缓存中,CPU 直接操作的是缓存中的数据。但在多处理器下,将可能导致各自的缓存数据不一致(这也是可见性问题的由来),为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,而嗅探是实现缓存一致性的常见机制。
嗅探机制工作原理:每个处理器通过监听在总线上传播的数据来检查自己的缓存值是不是过期了,如果处理器发现自己缓存行对应的内存地址修改,就会将当前处理器的缓存行设置无效状态,当处理器对这个数据进行修改操作的时候,会重新从主内存中把数据读到处理器缓存中。
3.4 JMM
JMM决定一个线程对共享变量的写入何时对另一个线程可见,JMM定义了线程和主内存之间的抽象关系:共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存保存了被该线程使用到的主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。
3.5 内存间交互操作(原子性操作)
- read(读取):从主内存中读取数据
- load(载入):将主内存读取到的数据写入工作内存
- use(使用):从工作内存中读取数据来计算
- assign(赋值):将计算好的值重新赋值到工作内存中
- store(存储):将工作内存数据写入到主内存中
- write(写入):将store过去的变量赋值给主内存中的变量
- lock(锁定):将主内存变量加锁,标识为线程独占状态
- unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量
当我们访问一个共享变量时:
,回到我们刚才的流程,如果是加入MESI缓存一致性协议和总线嗅探机制之后:
CPU1读取数据flag=false,CPU1的缓存中都有数据flag的副本,该缓存行置为(E)状态
CPU2也执行读取操作,同样CPU2也有数据flag=false的副本,此时总线嗅探到CPU1也有该数据,则CPU1、CPU2两个缓存行都置为(S)状态
CPU2修改数据flag=true,CPU2的缓存以及主内存flag=true,同时CPU2的缓存行置为(S)状态,总线发出通知,CPU1的缓存行置为(I)状态
CPU1再次读取flag,虽然CPU1在缓存中数据flag=false,但是发现状态为(I),因此直接丢弃该数据,去主内存获取最新数据
所以当我们使用volatile关键字修饰某个变量之后,就相当于告诉CPU:我这个变量需要使用MESI和总线嗅探机制处理。从而也就保证了可见性。
4.volatile的有序性
public class VolatileSerialTest {
static int x=0,y=0;
static int a=0,b=0;
public static void main(String[] args) throws InterruptedException {
Set<String> resultSet=new HashSet<>();
for (int i=0;i<10000000;i++) {
x=0;
y=0;
a=0;
b=0;
Thread one =new Thread(()->{
a=y;
x=1;
});
Thread two=new Thread(()->{
b=x;
y=1;
});
one.start();
two.start();
one.join();
two.join();
resultSet.add(" a = "+a+","+" b = "+b);
System.out.println(resultSet);
}
}
}
执行结果:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-h1CTjct8-1647249454136)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20210909154152549.png)]
4.1 指令重排序
为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入的代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,并确保这一结果和顺序执行结果是一致的,但是这个过程并不保证各个语句计算的先后顺序和输入代码中的顺序一致。这就是指令重排序。
重排序遵循
- as-if-serial
不管如何重排序,(单线程)执行结果不能被改变
当x=1时,无论执行顺序如果,不影响x的值;当x依赖于a时,线程1不进行重排序
Thread one =new Thread(()->{
a=y;
x=a;
});
执行结果:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8ayn5MAq-1647249454139)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20210909160126914.png)]
- happens-before
如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。
4.2 禁止重排序 & 内存屏障
volatile通过内存屏障可以禁止指令重排序,内存屏障是一个CPU的指令,它可以保证特定操作的执行顺序。
内存屏障分为四种:
StoreStore屏障、StoreLoad屏障、LoadLoad屏障、LoadStore屏障。
JMM针对编译器制定了Volatile重排序的规则:
首先是对四种内存屏障的理解,Store相当于是写屏障,Load相当于是读屏障。
比如有两行代码,
a=1;
x=2;
并且把x修饰为volatile
执行a=1时,它相当于执行了一次普通的写操作;
执行x=2时,它相当于执行了一次volatile的写操作;
因此在这两行命令之间,就会插入一个StoreStore屏障(前面是写后面也是写),这就是内存屏障。
a=1;//普通写
//StoreStore屏障
x=2;//volatile写
//StoreLoad屏障
这里比较有意思的是 volatile 写后面的 StoreLoad 屏障。这个屏障的作用是避免 volatile 写与后面可能有的 volatile 读/写操作重排序。
因为编译器常常无法准确判断在一个 volatile 写的后面,是否需要插入一个 StoreLoad 屏障(比如,一个volatile 写之后方法立即 return)。为了保证能正确实现 volatile 的内存语义,JMM在这里采取了保守策略:在每个 volatile 写的后面或在每个 volatile 读的前面插入一个 StoreLoad 屏障。
从整体执行效率的角度考虑,JMM选择了在每个 volatile 写的后面插入一个 StoreLoad 屏障。因为 **volatile 写-读内存语义的常见使用模式是:一个写线程写 volatile 变量,多个读线程读同一个 volatile 变量。**当读线程的数量大大超过写线程时,选择在 volatile 写之后插入 StoreLoad 屏障将带来可观的执行效率的提升。
在代码里面对a,b加上volatile关键字
static int x=0,y=0;
static volatile int a=0,b=0;
执行结果:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-D6Q6veBJ-1647249454141)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20210909164648225.png)]
5.其他关键字
关于可见性:
- Java中的volatile关键字:
- Java中的synchronized关键字
- Java中的final关键字
关于重排序:
Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性:
- volatile通过禁止重排序实现有序性;
- synchronized通过声明临界区,保证线程互斥访问,实现有序性;
synchronized和volatile的区别:
- volatile是线程同步的轻量级实现,只用于修饰变量,synchronized用于修饰方法和语句块;
- 多线程访问volatile不会发生阻塞,但是synchronized会发生阻塞;
- volatile保证数据的可见性,不保证原子性;synchronized保证数据的可见性和原子性;
- volatile强调共享变量在多线程之间的可见性,synchronized强调多线程访问资源的同步性;
6. 应用场景
volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。看下这个例子:
public class synchronizedTest extends Thread{
private static volatile int count;
@Override
public void run() {
add();
}
public void add(){
for(int i=0;i<100;i++){
count++;
}
System.out.println(count);
}
public static void main(String[] args) throws InterruptedException {
synchronizedTest[] arr = new synchronizedTest[100];
for(int i=0;i<100;i++){
arr[i] = new synchronizedTest();
}
for(int i=0;i<100;i++){
arr[i].start();
}
}
}
运行结果:
问题就出在count++这个操作上,因为count++不是个原子性的操作,而是个复合操作。我们可以简单讲这个操作理解为由这三步组成:
1.读取
2.加一
3.赋值
所以,在多线程环境下,有可能线程A将count读取到本地内存中,此时其他线程可能已经将count增大了很多,线程A依然对过期的count进行自加,重新写到主存中,最终导致了count的结果不合预期,而是小于1000。
通常来说,使用volatile必须具备以下2个条件:
1)对变量的写操作不依赖于当前值
2)该变量没有包含在具有其他变量的不变式中
实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。
6.1状态标记量
volatile boolean shutdownRequested;
public void shutdown() {
shutdownRequested = true;
}
public void doWork() {
while (!shutdownRequested) {
// do stuff
}
}
线程1执行doWork()的过程中,可能有另外的线程2调用了shutdown,所以boolean变量必须是volatile。
6.2 一次性安全发布
在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。
这就是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象。如下面介绍的单例模式。
private volatile static Singleton instace;
public static Singleton getInstance(){
//第一次null检查
if(instance == null){
synchronized(Singleton.class) { //1
//第二次null检查
if(instance == null){ //2
instance = new Singleton();//3
}
}
}
return instance;
}