一文详解 Java volatile关键字
- 1. JMM(Java Memory Model)
- 1.1 现代计算机的内存模型
- 1.2 JMM 简介
- 1.3 JMM 的三大特性
- 1.4 指令重排
- 1.5 happens-before
- 1.5.1 happens-before 规则
- 1.5.2 总结
- 1.6 as-if-serial
- 2. volatile 关键字
- 2.1 volatile 的内存语义
- 2.2 volatile 的三大特性
- 2.2.1 保证可见性
- 2.2.2 不保证原子性
- 2.2.3 禁止指令重排(保证有序性)
- volatile 有关禁重排的行为:
- 内存屏障四大指令插入情况
- 2.3 volatile 适用场景
- (1)单一赋值可以,但是含复合运算赋值不可以(i++ 之类)
- (2)状态标志,判断业务是否结束
- (3)开销较低的读,写锁策略
- (4)DCL 双重检查锁
先一起看一段代码:
/**
* @author pointer
* @date 2023-04-15 15:16:47
*/
public class NewThread extends Thread {
private boolean flag = false;
public boolean isFlag() {
return flag;
}
@Override
public void run() {
try {
sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
System.out.println("flag = " + flag);
}
public static void main(String[] args) {
NewThread thread = new NewThread();
thread.start();
while (true) {
if (thread.flag) {
System.out.println("我已经是 true 了");
}
}
}
}
你会发现,永远都不会输出 “我已经是 true 了”,讲道理线程改了flag变量为 true,主线程是可以访问的到的!
为会出现这个情况呢?那就不能不说一下JMM(Java内存模型)
了
1. JMM(Java Memory Model)
1.1 现代计算机的内存模型
其实早期计算机中CPU和内存的速度是差不多的,但在现代计算机中,CPU的指令速度远超内存的存取速度。由于主存与 CPU 处理器的运算能力之间有数量级的差距,所以现代计算机系统不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(CPU Cache Memory)
来作为内存与处理器之间的缓冲。
将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。
基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(CacheCoherence)
。
在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)
。
1.2 JMM 简介
Java 内存模型(Java Memory Model,JMM),是一种抽象的模型,并不真实存在。
Java 内存模型是一种规范,定义了线程和主内存之间的抽象关系:
-
所有的共享变量都存储在主内存(Main Memory)中,这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
-
每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的拷贝副本。
-
线程对变量的所有操作(读/写)都必须在本地内存中进行,而不能直接读写主内存。
-
不同的线程之间无法直接访问对方本地内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。
Java 内存模型的抽象图:
为了支持 JMM,Java 定义了8种原子操作,用来控制主内存与工作内存之间的交互:
-
lock - 锁定:作用于主内存的变量,把一个变量标识为一条线程独占状态。
-
unlock - 解锁:作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
-
read - 读取:作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
-
load - 载入:作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
-
use - 使用:作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
-
assign - 赋值:作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
-
store - 存储:作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
-
write - 写入:作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
1.3 JMM 的三大特性
原子性、有序性、可见性是并发编程中非常重要的基础概念,JMM的很多技术都是围绕着这三大特性展开。
-
原子性:原子性指的是一个操作是不可分割、不可中断的,要么全部执行并且执行的过程不会被任何因素打断,要么就全不执行。
-
可见性:可见性指的是一个线程修改了某一个共享变量的值时,其它线程能够立即知道这个修改。
-
有序性:有序性指的是对于一个线程的执行代码,从前往后依次执行,单线程下可以认为程序是有序的,但是并发时有可能会发生
指令重排
。
原子性、可见性、有序性都应该怎么保证呢?
-
原子性:JMM只能保证基本的原子性,如果要保证一个代码块的原子性,需要使用
synchronized
。 -
可见性:Java 是利用
volatile
关键字来保证可见性的,除此之外,final
和synchronized
也能保证可见性。 -
有序性:
synchronized
或者volatile
都可以保证多线程之间操作的有序性。
1.4 指令重排
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。
-
编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
-
指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应 机器指令的执行顺序。
-
内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从 Java 源代码到最终实际执行的指令序列,会分别经历下面3种重排序,如图:
指令重排也是有一些限制的,有两个规则 happens-before
和 as-if-serial
来约束。
1.5 happens-before
JMM 向程序员做出保证:一个操作 happens-before 于另一个操作,那么第一个操作的执行结果将对第二个执行结果可见,而且第一个操作的执行顺序排在第二个顺序之前。
两个操作之间存在 happens-before 规则,Java 平台并不一定按照规则定义的顺序来执行。 这么做的原因是因为,我们程序员并不关心两个操作是否被重排序,只要保证程序执行时语义不能改变就好了。
happens-before 这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
1.5.1 happens-before 规则
-
程序顺序规则:一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。
-
监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。
-
volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
-
传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
-
start() 规则:这条是关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。
-
join() 规则:如果线程A执行操作 ThreadB.join() 并成功返回,那么线程B中的任意操作 happens-before 于线程A从 ThreadB.join() 操作成功返回。
1.5.2 总结
在 Java 语言里面,Happens-Before 的语义本质上是一种可见性,A Happens-Before B 意味着 A 事件对 B 事件来说是可见的,无论 A 事件和 B 事件是否发生在同一个线程里。例如 A 事件发生在线程 1 上,B 事件发生在线程 2 上,Happens-Before 规则保证线程 2 上也能看到 A 事件的发生。
JMM 的设计分为两部分,一部分是面向我们程序员提供的,也就是 happens-before 规则,它通俗易懂的向我们程序员阐述了一个强内存模型,我们只要理解 happens-before 规则,就可以编写并发安全的程序了。 另一部分是针对 JVM 实现的,为了尽可能少的对编译器和处理器做约束,从而提高性能,JMM 在不影响程序执行结果的前提下对其不做要求,即允许优化重排序。 我们只需要关注前者就好了,也就是理解 happens-before 规则。毕竟我们是做程序员的,术业有专攻,能写出安全的并发程序就好了。
1.6 as-if-serial
as-if-serial 语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守 as-if-serial 语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例:
double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C
编译器可以把 A、B 的执行顺序颠倒,但不可把 C 的执行优先级提到 A 和 B 之上。
2. volatile 关键字
2.1 volatile 的内存语义
当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值立即刷新回主内存中。
当读一个 volatile 变量时,JMM 会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量。
所以 volatile 的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取。
2.2 volatile 的三大特性
2.2.1 保证可见性
相比 synchronized 的加锁方式来解决共享变量的内存可见性问题,volatile 就是更轻量的选择,它没有上下文切换的额外开销成本。
volatile 可以确保对某个变量的更新对其他线程马上可见,一个变量被声明为 volatile 时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存,当其它线程读取该共享变量 ,会从主内存重新获取最新值,而不是使用当前线程的本地内存中的值。
例如,我们声明一个 volatile 变量 volatile int x = 0,线程A修改 x = 1,修改完之后就会把新的值刷新回主内存,线程B读取 x 的时候,就会清空本地内存变量,然后再从主内存获取最新值。
2.2.2 不保证原子性
原子性指的是一个操作是不可分割、不可中断的,要么全部执行并且执行的过程不会被任何因素打断,要么就全不执行。
多线程环境下,“数据计算” 和 “数据赋值” 操作可能多次出现,即操作非原子。若数据在加载之后,若主内存count变量发生修改之后,由于线程工作内存中的值在此前已经加载,从而不会对变更操作做出相应变化,即私有内存和公共内存中变量不同步,进而导致数据不一致。
对于 volatile 变量,JVM 只是保证从主内存加载到线程工作内存的值是最新的,也就是数据加载时是最新的。由此可见 volatile 解决的是变量读时的可见性问题,但无法保证原子性,对于多线程修改共享变量的场景必须使用加锁同步
。
以 i++ 为例,不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上 1,分3步完成。如果第二个线程在第一个线程读取旧值和写回新值期间(上图所指三步期间)读取 i 的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败,因此对于 add 方法必须使用 synchronized 修饰,以便保证线程安全。
2.2.3 禁止指令重排(保证有序性)
重排序
:是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段,有时候会改变程序语句的先后顺序。不存在数据依赖关系,可以重排序;存在数据依赖关系,禁止重排序。但重排后的指令绝对不能改变原有的串行语义。
数据依赖性
:若两个操作访问同一变量,且这两个操作中有一个为写操作,此时两操作间就存在数据依赖性。
具体解释见本文 1.4
节。
volatile 有关禁重排的行为:
-
当第一个操作为 volatile 读时,不论第二个操作是什么,都不能重排序。这个操作保证了 volatile 读之后的操作不会被重排到 volatile 读之前。(volatile 读之后的操作,都禁止重排序到 volatile 之前)
-
当第二个操作为 volatile 写时,不论第一个操作是什么,都不能重排序。这个操作保证了 volatile 写之前的操作不会被重排到 volatile 写之后 。 (volatile 写之前的操作,都禁止重排序到 volatile 之后)
-
当第一个操作为 volatile 写时,第二个操作为 volatile 读时,不能重排。(volatile 写之后 volatile 读,禁止重排序的)
内存屏障四大指令插入情况
-
在每个 volatile 写操作的前面插入一个 StoreStore 屏障,保证在 volatile 写之前,其前面的所有普通写操作都已经刷新到主内存中。
-
在每个 volatile 写操作的后面插入一个 StoreLoad 屏障,避免 volatile 写与后面可能有的 volatile 读/写操作重排序。
-
在每个 volatile 读操作的后面插入一个 LoadLoad 屏障,禁止处理器把上面的 volatile 读与下面的普通读重排序。
-
在每个 volatile 读操作的后面插入一个 LoadStore 屏障,禁止处理器把上面的 volatile 读与下面的普通写重排序。
来源:https://blog.csdn.net/weixin_43899792/article/details/124492448
2.3 volatile 适用场景
(1)单一赋值可以,但是含复合运算赋值不可以(i++ 之类)
volatile int a = 10;
(2)状态标志,判断业务是否结束
使用:作为一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或任务结束。
理由:状态标志并不依赖于程序内任何其他状态,且通常只有一种状态转换。
例子:判断业务是否结束 volatile boolean flag = false
(3)开销较低的读,写锁策略
使用:当读远多于写,结合使用内部锁和 volatile 变量来减少同步的开销。
理由:利用 volatile 保证读取操作的可见性;利用 synchronized 保证复合操作的原子性。
// 当读远多于写,结合使用内部锁和 volatile 变量来成少同步的开销
public class Counter{
private volatile int value;
public int getValue(){
// 利用 volatile 保证读取操作的可见性
return value;
}
public synchronized int increment(){
// 利用 synchronized 保证复合操作的原子性
return value++;
}
}
(4)DCL 双重检查锁
隐患: 多线程环境下,由于重排序,该对象可能还未完成初始化就被其他线程读取。
class SafeDoubleCheckSingleton{
private static SafeDoubleCheckSingleton singleton;
// 私有化构造方法
private SafeDoubleCheckSingleton(){}
// 双锁设计
public static SafeDoubleCheckSingleton getSingleton(){
if(singleton == null){
// 1. 多线程并非创建对象时,会通过加锁保证只有一个线程能创建对象
synchronized (SafeDoubleCheckSingleton.class){
if(singleton == null){
singleton = new SafeDoubleCheckSingleton();
}
}
}
// 2. 对象创建完毕后,执行 getInstance() 将不需要获取锁,直接返回创建对象
return singleton;
}
}
修正方法1:加 volatile(也即正确的DCL)
原理:利用 volatile禁止 “初始化对象” 和 “和设置singleton指向内存空间” 的重排序
// 通过 volatile 声明,实现线程安全的延迟初始化
private volatile static SafeDoubleCheckSingleton singleton;
修正方法2:静态内部类
// 静态内部类
class SingletonDemo{
private SingletonDemo(){
}
// 私有的静态内部类,独有一份
private static class SingletonDemoHandler{
private static SingletonDemo instance = new SingletonDemo();
}
// 外部暴露
public static SingletonDemo getInstance(){
return SingletonDemoHandler.instance;
}
}