volatile 原理
可见性问题
代码示例
public class Main {
// 共享变量
private static boolean temp = false;
public static void main(String[] args) {
// 线程1如果发现 temp 是 true 就结束
new Thread(() -> {
while (!temp){
}
System.out.println(Thread.currentThread().getName() + "结束");
}, "thread1").start();
// 1 秒后
LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(1));
// 线程2把 temp 改为 true
new Thread(() -> {
temp = true;
System.out.println(Thread.currentThread().getName() + "修改为true");
}, "threa2").start();
}
}
结果是线程1不会结束,运行结果如下:
threa2修改为true
解决方案也很简单,只需要给 temp 变量增加 volatile 关键字修饰
private static volatile boolean temp = false;
产生原因
线程内部的数据不能感知到主存数据已经变化了
volatile 如何保证可见性
要知道 JMM 和 MESI 才能明白
JMM 内存模型
别和 JVM 运行时数据区搞混了,JVM 运行时数据区:程序计数器、本地方法栈、虚拟机栈、方法区、堆
JMM 是 java 内存模型,是规定了线程访问内存的工作模式,有如下指令:
- read(读取):从主存读取数据
- load(载入):将读取的主存数据写入工作内存(线程内部,也就是CPU缓存中)
- use(使用):从工作内存读取数据来计算(线程内部使用变量进行运算)
- assign(赋值):将计算好的值重新写入工作内存中(还是在线程内部,运算结束先修改内部的值)
- store(存储):将工作内存的数据写入主内存(值写入主内存了,但是还未赋值给主内存的变量)
- write(写入):将 store 的值写入主内存中的变量(这时主内存中的值才真正被修改)
- lock(锁定):将主内存变量加锁,表示为线程独占状态
- unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量
MESI 缓存一致性协议
MESI 是一种缓存一致性协议,用于多核系统中保持各个核心的缓存数据一致。它将每个缓存行(Cache Line)标记为以下四种状态之一:
- Modified (M):缓存行已被修改,与主存不同,且只有当前核心有最新数据
- Exclusive (E):缓存行与主存一致,且仅当前核心持有
- Shared (S):缓存行与主存一致,但可能被多个核心共享
- Invalid (I):缓存行无效(不可用)
核心作用:通过状态转换和总线嗅探机制,确保多核读写时数据一致,避免脏读或冲突
volatile 原理
知道了 JMM 和 MESI 这时才能搞清楚 volatile 的原理
先说说不使用 volatile 的多线程如果访问共享数据,还是使用上面的示例来分析
-
thread1 和 thread2 都在访问共享变量 temp,所以两个线程内部都会有 temp 的副本,都是通过 JMM 读取的
- 先 read,读取主存的数据,这时是 false
- 然后 load,线程内部就有 temp 变量了(fasle)
- 这时两个线程就可以内部使用了(这时都是使用的各自内部的副本)
-
thread 1 无限循环判断 temp 是否是 false,如果是 true 就会跳出循环
- 会使用 use 指令,要使用变量嘛,进行判断也是使用
- thread1 永不结束,因为在 thread1 中 temp 本来就是 false
-
一秒后,thread2 把值改为 true
- 先 use
- 然后 assign,自己内部 temp 已经是 true 了
- 然后 store,true 已经存储在主内存中了(但是还未赋值给主内存的 temp 变量)
- 然后 write,true 赋值给主内存的 temp
- 线程 2 结束了
thread1 感知不到 thread2 把值改了,所以 thread1 永不结束,这就是可见性造成的问题
使用 volatile 后,当 thread2 把值改了会利用 MESI 使 thread1 的缓存数据置为 Invalid 状态
- thread1 是无限循环,所以 thread1 会一直使用 use 指令,
- 发现自己内部的变量是无效状态,所以又会重新进行 read、load、use
- 重新读取时 thread1 就能感知 temp 已经变化了
再深入一点就是 thread2 怎么把 threa1 内部的缓存置为 Invalid 状态?
不同处理器实现方式不一样,这个硬件相关了
inter X86 的是会发送一个 lock 指令,具体怎么证明的话就要看对应的汇编代码了,是能看到 lock 指令的
汇编不能跨平台,要想知道 lock 干了什么可以去查对应的 cpu 的汇编官方文档,inter 和 amd 都有
总结一下 lock 对应的汇编代码作用是利用 bus 总线感知数据变化,如果发生变化会使缓存失效
学海无涯,差不多得了,了解到这里还要咋样?面试官你还要深入问吗?那就反口一句你说说为啥1+1=2?
有序性问题
代码示例
public class Main {
static int x = 0, y = 0;
static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
HashSet<String> res = new HashSet<>();
for (int i = 0; i < 1000000; i++) {
x = 0;
y = 0;
a = 0;
b = 0;
Thread thread1 = new Thread(() -> {
a = y;
x = 1;
}, "thread1");
Thread thread2 = new Thread(() -> {
b = x;
y = 1;
}, "thread2");
thread1.start();
thread2.start();
thread1.join();
thread2.join();
res.add("a=" + a + ", b=" + b); // 两个 join 的作用是:运行这行代码时两个线程都执行结束了
}
System.out.println(res);
}
}
thread1 和 thread2 会并行,两个线程运行结束后再添加结果到 set 中,然后输出 set,分析下代码:
- thread1 先运行结束,结果是:a=0,b=1,set 保存
a=0,b=1 - thread2 先运行结束,结果是:b=0,a=1,set 保存
a=1,b=0 - thread1 和 thread2 不是先后运行,并行运行,运行到中途另一个线程运行
- thread1 运行到
a=y这时 a=0(thread1 不执行了),接着 thread2 运行到b=y这时 b=0 - 然后 thread1 和 thread2 继续执行,这时谁先谁后不重要了,因为a、b 已经去完值了
- set 保存
a=0,b=0
- thread1 运行到
所以运行结果就是三种情况 [ a=0,b=1、 a=1,b=0、 a=0,b=0]
但是程序真正运行结果还有第四种情况 a=1、b=1,感觉这是不可能出现的,因为要 a =1 必须要 thread2 先运行结束,但是 thread2 如果先运行结束那么 b 就是 0。这就是有序性的问题
产生原因
计算机真正运行的指令顺序可能不是我们编写代码的顺序
上面的示例可能会重排序成这样:
Thread thread1 = new Thread(() -> {
x = 1;
a = y;
}, "thread1");
Thread thread2 = new Thread(() -> {
y = 1;
b = x;
}, "thread2");
天塌了?计算机要是把代码重排序不就太乱了吗,执行顺序不是代码顺序,还能写代码吗?
也没那么严重,计算机会遵循两个原则的前提下重排序:as-if-serial、happens-before
为什么会重排序?,计算机有自己的一套优化逻辑,计算机觉得我这样优化后效率更高
就好比 mysql 优化器,mysql 觉得自己写的 sql 和优化后的 sql 查询结果一致,但是优化后的 sql 查询效率更高
volatile 如何保证有序性
as-if-serial 原则
顺序一致原则,指不管怎么重排序,要保证单线程的结果是一致的
Thread thread1 = new Thread(() -> {
x = 1;
a = y;
}, "thread1");
上面的代码先执行 a = y; 再执行 x = 1; 在单线程情况下结果是不影响的,这里就有可能重排序
如果改成下面这样,明显有依赖关系了,重排序后肯定结果不一致,所以就不会重排序
Thread thread1 = new Thread(() -> {
x = a;
a = y;
}, "thread1");
为什么保证单线程结果一致而不是多线程呢?笛卡尔积,好几十个线程的情况下,等你处理好黄花菜都凉了
happens-before 原则
某些代码一定要发生于另一些代码之前,内容如下:
- 程序顺序规则:一个线程内必须保证语义串行性
- 锁规则:一个 unlock 操作先于后续对同一个锁的 lock 操作
- volatile 变量规则:对一个 volatile 变量的写操作先发生于读操作,简单点理解就是:
1)当 volatile 变量每次读都强迫从主内存中读
2)当 volatile 变量每次写都强迫刷回主内存(其实是使其他线程的缓存失效) - 线程启动规则
- 线程终止规则
- 线程中断规则
- 对象终结规则
- 传递性规则
// 举例锁规则
ReentrantLock reentrantLock = new ReentrantLock();
reentrantLock.lock();
// TODO 业务
reentrantLock.unlock();
reentrantLock.lock();
// TODO 业务
reentrantLock.unlock();
// 不能重排序为
ReentrantLock reentrantLock = new ReentrantLock();
reentrantLock.lock();
reentrantLock.lock(); // 结果可能没问题,就锁重入嘛,但是不会这样重排序
// TODO 业务
reentrantLock.unlock();
// TODO 业务
reentrantLock.unlock();
DCL 双检查锁案例
// 使用volatile关键字修饰单例实例
// 1. 保证可见性:当一个线程完成instance的初始化后,其他线程能立即看到
// 2. 禁止指令重排序:防止new Singleton()的指令被重排序导致其他线程获取到未初始化的对象
private static volatile Singleton instance;
// 私有构造函数,防止外部通过new创建实例
// 这是单例模式的关键,确保只能通过getInstance()方法获取唯一实例
private Singleton() {}
// 获取单例实例的公共静态方法
public static Singleton getInstance() {
// 第一次检查(无锁检查)
// 这个检查是为了提高性能:如果instance已经初始化,就直接返回,避免进入同步块
if (instance == null) {
// 同步代码块,锁定Singleton类对象
// 使用类对象作为锁,确保在多线程环境下只有一个线程能进入
synchronized (Singleton.class) {
// 第二次检查(有锁检查)
// 这个检查是必要的,因为可能有多个线程同时通过了第一次检查
// 在等待锁的过程中,其他线程可能已经完成了初始化
if (instance == null) {
// 创建单例实例
// 这一行代码实际上包含三个步骤:
// 1. 分配内存空间
// 2. 初始化对象
// 3. 将引用指向分配的内存地址
// 如果没有volatile,2和3可能会被重排序,导致其他线程获取到未初始化的对象
instance = new Singleton();
}
}
}
// 返回单例实例
return instance;
}
不加 volatile 对于 instance = new Singleton(); 这行代码来说可能先分配对象赋值给 instance,然后再初始化
问题是为什么加了 volatile 就能保证赋值给 instance 的对象是已经初始化过的呢?内存屏障
内存屏障
先说说内存屏障是什么,下面的示例中加的 标识 就是内存屏障
Thread thread1 = new Thread(() -> {
a = y;
// 标识(和操作系统提前约定好,碰到这个标识时,这个标识的前后的代码不要重排序)
x = 1;
}, "thread1");
java 规范定义的 4 种内存屏障如下:
| 屏障 | 指令示例 | 解释 |
|---|---|---|
| LoadLoad | Load1; LoadLoad; Load2; |
保证 Load1 先完成然后再 Load2 |
| StoreStore | Store1; StoreStore; Store2; |
保证 Store1 先完成然后再 Store2 |
| LoadStore | Load; LoadStore; Store; |
保证 Load 先完成然后再 Store |
| StoreLoad | Store; StoreLoad; Load; |
保证 Store 先完成然后再 Load |
示例
int x = 0;
int y = 0;
// 比如想要 a = x; b = y; 不重排序,因为都是读操作,所以可以在中间加入 LoadLoad 屏障来保证
int a = x; // load
LoadLoad
int b = y; // load
// 都是写操作,加 StoreStore 屏障
x = 1;
StoreStore
y = 1;
// 先读后写,加 LoadStore 屏障
int c = x;
LoadStore
y = 2;
// 先写后读,加 StoreLoad 屏障
x = 2;
LoadStore
int d = y;
volatile 原理
- 前面说了代码可能在遵循
as-if-serial和happens-before原则的前提下重排序 - JDK 规范中明确了:对于
volatile变量的读写操作还要添加不同的内存屏障
以上两点保证了 volatile 变量的有序性,再回到 DCL 的问题 “为什么加了 volatile 就能保证赋值给 instance 的对象是已经初始化过的?” 就应该能明白了
因为对于 volatile 的变量修改和读会加内存屏障,所以不会重排序,所以赋值给 instance 的对象是初始化过的
在底层一点就要去看 jvm 源码了(Oracle 的 JDK 源码不公开,要看的话只能看 open JDK 的源码),JVM 是 C++ 写的,再深一点找到 C++ 对应的汇编代码,能看到是加了 lock 指令,别再深究了,“阿祖,收手吧”

浙公网安备 33010602011771号