博主主页:爪哇贡尘拾Miraitow
创作时间:2022年2月20日 15:41
内容介绍: Volatile 详解
参考资料:黑马程序员JUC
⏳简言以励:列位看官,且将新火试新茶,诗酒趁年华
内容较多有问题希望能够不吝赐教
欢迎点赞 收藏 ⭐留言
JMM 即 Java Memory Model,它从java层面定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等。
从抽象角度来看:
JMM 体现在以下几个方面
原子性
- 保证指令不会受到线程上下文切换的影响(即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
)可见性
- 保证指令不会受 cpu 缓存的影响(即可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
)有序性
- 保证指令不会受 cpu 指令并行优化的影响(即程序执行的顺序按照代码的先后顺序执行
)原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
一个很经典的例子就是银行账户转账问题:
比如从账户 A 向账户 B 转 1000 元,那么必然包括 2 个操作:从账户 A 减去 1000 元,往账户 B 加上 1000 元。
试想一下,如果这 2 个操作不具备原子性,会造成什么样的后果。假如从账户 A 减去 1000 元之后,操作突然中止。然后又从 B 取出了 500 元,取出 500 元之后,再执行往账户 B 加上 1000 元 的操作。这样就会导致账户 A虽然减去了 1000 元,但是账户 B 没有收到这个转过来的 1000 元。
所以这 2 个操作必须要具备原子性才能保证不出现一些意外的问题。
先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止
Test1.java:
为什么呢?分析一下:
频繁地
从主存中读取run的值,JIT
即时编译器会将run的值缓存至自己工作内存中的高速缓存
中,减少对主存中run的访问以提高效率高速缓存中读取这个变量的值
(true),结果永远是旧值int i = 0;
boolean flag = false;
i = 1; //语句1
flag = true; //语句2
}
上面代码定义了一个 int 型变量,定义了一个 boolean 类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句 1 是在语句 2 前面的,那么 JVM 在真正执行这段代码的时候会保证语句 1 一定会在语句 2 前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)
。
下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率
,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
比如上面的代码中,语句 1 和语句 2 谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句 2 先执行而语句 1 后执行。
但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:
int a = 10; //语句1
int r = 2; //语句2
a = a + 3; //语句3
r = a*a; //语句4
这段代码有4个语句,那么可能的一个执行顺序是:
那么可不可能是这个执行顺序呢:语句2 语句1 语句4 语句3
不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令 Instruction 2 必须用到Instruction 1的结果,那么处理器会保证 Instruction 1会在 Instruction 2 之前执行。
虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面
看一个例子:
//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
上面代码中,由于语句 1 和语句 2 没有数据依赖性,因此可能会被重排序
。假如发生了重排序,在线程 1 执行过程中先执行语句 2,而此时线程 2 会以为初始化工作已经完成,那么就会跳出 while 循环,去执行doSomethingwithconfig(context)
方法,而此时 context 并没有被初始化,就会导致程序出错。
从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。
也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性
。只要有一个没有被保证,就有可能会导致程序运行不正确。
重排序也需要遵守一定规则:
①重排序操作不会对存在数据依赖关系的操作进行重排序。比如:a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。
②.重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变。比如:a=1;b=2;c=a+b这三个操作,第一步(a=1)和第二步(b=2)由于不存在数据依赖关系,所以可能会发生重排序,但是c=a+b这个操作是不会被重排序的,因为需要保证最终的结果一定是c=a+b=3。
重排序在单线程模式下是一定会保证最终结果的正确性,但是在多线程环境下,问题就出来了。解决方法:volatile
修饰的变量,可以禁用指令重排
ps:
使用synchronized并不能解决有序性问题,但是如果是该变量整个都在synchronized代码块的保护范围内,那么变量就不会被多个线程同时操作,也不用考虑有序性问题!在这种情况下相当于解决了重排序问题!
volatile 的底层实现原理是内存屏障Memory Barrier(Memory Fence)
写屏障
读屏障
如何保证可见性
写屏障(sfence)
保证在该屏障之前的,对共享变量的改动,都同步到主存当中public void actor2(I_Result r) {
num = 2;
ready = true; // ready是被volatile修饰的 ,赋值带写屏障
// 写屏障
}
读屏障(lfence)
保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据 public void actor1(I_Result r) {
// 读屏障
// ready是被volatile修饰的 ,读取值带读屏障
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
总结:
对于可见性,Java提供了
volatile
关键字来保证可见性。当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被
更新到主存,当有其他线程需要读取时,它会去内存中读取新值
。而
普通的共享变量
不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。另外,通过
synchronized
和Lock
也能够保证可见性,synchronized
和Lock
能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
public void actor2(I_Result r) {
num = 2;
ready = true; // ready是被volatile修饰的 , 赋值带写屏障
// 写屏障
}
public void actor1(I_Result r) {
// 读屏障
// ready是被volatile修饰的 ,读取值带读屏障
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
Java 内存模型具备一些先天的有序性
,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为happens-before
原则。如果两个操作的执行次序无法从 happens-before 原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地
对它们进行重排序。
下面就来具体介绍下 happens-before原则(先行发生原则):
程序次序规则
:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作锁定规则
:一个 unLock 操作先行发生于后面对同一个锁lock 操作volatile 变量规则
:对一个变量的写操作先行发生于后面对这个变量的读操作传递规则
:如果操作 A 先行发生于操作 B,而操作 B 又先行发生于操作 C,则可以得出操作 A 先行发生于操作C线程启动规则
:Thread 对象的 start() 方法先行发生于此线程的每个一个动作线程中断规则
:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生线程终结规则
:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行对象终结规则
:一个对象的初始化完成先行发生于他的finalize()方法的开始下面我们来用代码解释一下几条规则:
线程中断规则
:
线程 start() 前对变量的写,对该线程开始后对该变量的读可见
线程还没启动时, 修改变量的值, 在启动线程后, 获取的变量值, 肯定是修改过的
static int x;
x = 10;
new Thread(()->{
System.out.println(x);
},"t2").start();
线程终结规则
:
线程结束前 对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)
主线程获取的x值, 是线程执行完对x的写操作之后的值。
static int x;
Thread t1 = new Thread(()->{
x = 10;
},"t1");
t1.start();
t1.join();
System.out.println(x);
线程中断规则
:
static int x;
public static void main(String[] args) {
Thread t2 = new Thread(()->{
while(true) {
if(Thread.currentThread().isInterrupted()) {
System.out.println(x); // 10, 打断了, 读取的也是打断前修改的值
break;
}
}
},"t2");
t2.start();
new Thread(()->{
sleep(1);
x = 10;
t2.interrupt();
},"t1").start();
while(!t2.isInterrupted()) {
Thread.yield();
}
System.out.println(x); // 10
}
synchronized
关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而 volatile
关键字在某些情况下性能要优于 synchronized,但是要注意 volatile 关键字是无法替代 synchronized 关键字的,因为 volatile 关键字无法保证操作的原子性。
ps:
synchronized可以保证它的临界区的资源是 原子性、可见性、有序性的, 有序性的前提是, 在synchronized代码块中的共享变量, 不会在代码块外使用到, 否则有序性不能被保证, 只能使用volatile来保证有序性
下面代码的第二个双重检查单例, 就出现了这个问题(在synchronized外使用到了INSTANCE), 此时synchronized就不能防止指令重排, 确保不了指令的有序性.
著名的double-checked locking
(双重检查锁) 单例模式为例,这是volatile最常使用的地方。
// 最开始的单例模式是这样的
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
/*
多线程同时调用getInstance(), 如果不加synchronized锁, 此时两个线程同时
判断INSTANCE为空, 此时都会new Singleton(), 此时就破坏单例了.所以要加锁,
防止多线程操作共享资源,造成的安全问题
*/
synchronized(Singleton.class) {
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
return INSTANCE;
}
}
上面代码的效率是有问题的, 因为当我们
创建了一个单例对象
后, 又来一个线程获取到锁了,还是会加锁, 严重影响性能,再次判断INSTANCE==null
吗, 此时肯定不为null
, 然后就返回刚才创建的INSTANCE
,这样导致了很多不必要的判断;
所以要双重检查, 在第一次线程调用getInstance(), 直接在synchronized外,判断instance对象是否存在了,如果不存在, 才会去获取锁,然后创建单例对象,并返回; 第二个线程调用getInstance(), 会进行if(instance==null)的判断
, 如果已经有单例对象
, 此时就不会再去同步块中获取锁了提高效率
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
if(INSTANCE == null) { // t2
// 首次访问会同步,而之后的使用没有 synchronized
synchronized(Singleton.class) {
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
但是上面的if(INSTANCE == null)
判断代码没有在同步代码块synchronized中,不能享有synchronized保证的原子性、可见性、以及有序性。所以可能会导致指令重排
public final class Singleton {
private Singleton() { }
private static volatile Singleton INSTANCE = null;
public static Singleton getInstance() {
// 实例没创建,才会进入内部的 synchronized代码块
if (INSTANCE == null) {
synchronized (Singleton.class) { // t2
// 也许有其它线程已经创建实例,所以再判断一次
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
写屏障(sfence)
保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中读屏障(lfence)
保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据写屏障
会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后读屏障
会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前参考链接:
周日氪金之:彻底搞懂多线程中的volatile!
黑马程序员JUC