带你彻底了解volatile关键字,深入了解volatile关键字的作用
volatile
作用
- 1,保证可见性
- 2,不保证原子性
- 3,防止重排序
保证可见性
/**
* @author yeming.gao
* @Description: volatile 可见性
* @date 2020/6/1 13:47
*/
public class VolatileTest {
private static volatile boolean flag = true;
public static void main(String[] args) throws InterruptedException {
//单独起一个线程修改flag的值
new Thread(() -> {
//睡眠3s
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = false;
System.out.println("flag 修改为false");
},"change_flage_thread").start();
int i = 0;
while (flag) {
i++;
}
System.out.println("第" + i + "次结束循环");
}
}
该程序当属性flag不用volatile修饰的时候,即使在线程change_flage_thread将flag的值修改为false,这个程序依旧就处于无限死循环,当flag用volatile修饰的时候;在线程change_flage_thread将flag的值修改为false的时候,该程序的主线程while程序就会跳出,这个就是可见性。可见性:就是保证多线程情况下对属性的修改后的值其他线程也可以读取到。
volatile实现可见性主要包含三个步骤:
1,volatile修饰的共享变量属性修改后立即写到主内存
2,通知其他线程工作内存,使得线程工作内存volatile修饰的共享变量属性缓存无效。
3,读取volatile修饰的共享变量若缓存无效则重新从主内存中读取。
MESI缓存一致性协议
多个线程将同一个数据读取到各自的缓存区后,某个cpu修改了缓存的数据之后,会立马同步给主存,这都是汇编语言实现的。其他cpu通过总线嗅探机制(可以理解为监听)可以感知到数据的变化从而将自己缓存里的数据失效,从而去读取主存的值。以前的CPU可见性是利用lock锁,如今CPU就是使用MESI缓存一致性协议。锁的粒度更小。也是volatile可见性底层实现的关键。
不保证原子性
原子性 程序的原子性指整个程序中的所有操作,要么全部完成,要么全部不完成,不可能停滞在中间某个环节;原子性操作指原子性在一个操作是不可中断的,要么全部执行成功要么全部执行失败。在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。
示例
/**
* @author yeming.gao
* @Description: volatile 原子性
* @date 2020/6/1 13:47
*/
public class VolatileTest {
private static volatile int a = 0;
public static void main(String[] args) {
//启动100个线程
for (int i = 1; i <= 100; i++) {
new Thread(() -> {
for (int j = 1; j <= 10000; j++) {
a++;
System.out.println(Thread.currentThread().getName() + "中a=" + a);
}
}, "change_flage_thread_" + i).start();
}
}
}
上面程序执行完毕后,a的值不一定是1000000;所以说这块代码是线程不安全的。它之所以不安全就是因为volatile不能保证原子性。
防止重排序
概念 在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。
重排序的三种类型
-
1,编译器优化的重排序
编译器在不改变单线程程序语义的前提下(代码中不包含synchronized关键字),可以重新安排语句的执行顺序。 -
2,指令级并行的重排序
现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。 -
3,内存系统的重排序。
由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
验证Java的重排序
/**
* @author yeming.gao
* @Description: 验证Java中存在指令重排序
* @date 2020/6/5 9:51
*/
public class ReorderTest {
private static int a = 0, b = 0;
private static int x = 0, y = 0;
public static void main(String[] args) throws InterruptedException {
int whileCount = 0;
do {
whileCount++;
a = 0;
b = 0;
x = 0;
y = 0;
Thread threadA = new Thread(() -> {
a = 1;
b = x;
}, "线程A");
Thread threadB = new Thread(() -> {
x = 1;
y = a;
}, "线程B");
threadA.start();
threadB.start();
threadA.join();//主线程等待threadA执行完毕
threadB.join();//主线程等待threadB执行完毕
System.out.println("循环第" + whileCount + "次b=" + b + ",y=" + y);
} while (b != 0 || y != 0);
}
}
分析:上面代码假设编译器没有指令重排序,那么b与y的值就不可能等于0;就单线程A来说,在不改变执行结果的情况下a = 1;b = x;的执行顺序可能变成b = x;a = 1;同样线程B也会发生这种情况,说一就会出现b=0;y=0的情况。这就可以说明发生了重排序。当属性a,b,x,y用关键字volatile进行修饰后禁止指令重排序后。b=0;y=0的情况就不可能出现,所以程序也就会一直循环下去。
实现方式
volatile通过加入内存屏障和禁止指令重排序优化来实现的:
- 对volatile变量执行写操作时,会在写操作后加入一条store屏障指令,这样就会把读写时得数据缓存加载到主存中;
-
对volatile变量执行读操作时,会在读操作前加入一条load屏障指令,这样就会从主内存中加载变量; 所以说,volatile变量在每次 被线程访问时,都会强迫从主内存中重读该变量的值,而当该变量发生变化时,就会强迫线程将最新的值刷新到主内存,这样任何时刻,不同的线程总能看到该变量的最新值。
线程写volatile变量的过程:
- 1,改变线程工作内存中volatile变量副本的值;
- 2,将改变后的副本的值从工作内存中刷新到主内存中
线程读volatile变量的过程:
- 1,从主内存中读取volatile变量的最新值到线程的工作内存中;
- 2,从工作内存中读取volatile变量的副本。
Java内存模型中八种原子操作
1,lock(锁定):
作用于主内存,它把一个变量标记为一条线程独占状态;2,read(读取):
作用于主内存,它把变量值从主内存传送到线程的工作内存中,以便随后的load动作使用;3,load(载入):
作用于工作内存,它把read操作的值放入工作内存中的变量副本中;4,use(使用):
作用于工作内存,它把工作内存中的值传递给执行引擎,每当虚拟机遇到一个需要使用这个变量的指令时候,将会执行这个动作;5,assign(赋值):
作用于工作内存,它把从执行引擎获取的值赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的指令时候,执行该操作;6,store(存储):
作用于工作内存,它把工作内存中的一个变量传送给主内存中,以备随后的write操作使用;7,write(写入):
作用于主内存,它把store传送值放到主内存中的变量中。8,unlock(解锁):
作用于主内存,它将一个处于锁定状态的变量释放出来,释放后的变量才能够被其他线程锁定;
下面我们分析一下双重锁的单例模式机制
/**
* @author yeming.gao
* @Description: 懒汉式单例模式-双重校验锁(线程安全);
* @date 2020/5/14 17:41
*/
public class LazySingletonDoubleLock {
private static volatile LazySingletonDoubleLock instance;
/**
* 私有化构造方法
*/
private LazySingletonDoubleLock() {
}
public static LazySingletonDoubleLock getInstance() {
if (instance == null) {
synchronized (LazySingletonDoubleLock.class) {
if (instance == null) {
instance = new LazySingletonDoubleLock();
}
}
}
return instance;
}
}
java在new 实例化对象可以主要概括为三个步骤:
- (1)分配内存空间。
- (2)初始化对象。
- (3)将内存空间的地址赋值给对应的引用。 但是在指令重排的情况下上面的步骤可能变为(概率非常小但是理论上是存在的):
- (1)分配内存空间。
- (2)将内存空间的地址赋值给对应的引用。
- (3)初始化对象 这就有可能不可预料的结果,当给需要实例化对象加上volatile修饰后,该对象实例化过程就会严格按照正常的流程进行执行。实例化过程就会正确这样就完全保证了线程安全。
as-if-serial语义
as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守as-if-serial语义。 为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。譬如下面代码:步骤1,步骤2可能发生重排,但是步骤3不会发生重排(单线程情况下)。
步骤1,a=1
步骤2,b=x
步骤3,c=a+b
happens-before
在JMM中,使用happens-before的概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。 与程序员密切相关的happens-before规则如下:
- 程序顺序规则: 一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
- 监视器锁规则:
对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
volatile变量规则:对一个volatile域的写,happens- before 于任意后续对这个volatile域的读。
- 传递性: 如果A happens- before B,且B happens- before C,那么A happens- before C。