volatile 详解
本文共4230字,阅读本文大概需要8~14分钟
引言
有多个线程,共享一个变量。其中一个线程修改这个变量,另一个线程读取这个值,这个时候有没有什么问题?
在实际的系统运行过程中,可能会产生一个问题。若有一个变量 i 为 0,当 Thread1 修改变量的值,把 i 修改为 1,Thread0 在一段时间内,还是读到了 i = 0,读到的仍然是一个旧值
volatile 这个东西,看似很高深,其实很简单,它是并发编程里非常常见的一种东西,在大量开源项目项目中,你会发现会大量运用 volatile 来编程。
只要开了多个线程,一定会有这种问题,某个线程修改一个变量值,其他线程要立刻感知到这个值的变化,如果使用 volatile,那么会马上感知到这个值的变化
public class VolatileDemo{
volatile boolean running = true;
void m() {
System.out.println("m start");
while(running) {
}
System.out.println("m end!");
}
public static void main(String[] args) {
VolatileDemo t = new VolatileDemo();
new Thread(t::m, "t1").start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
t.running = false;
}
}
CPU 缓存模型
现代的计算机技术,内存的读写速度没什么突破,CPU 如果要频繁的读写主内存的话,会导致性能较差,计算机性能就会降低,现代的计算机为了解决这一问题,换了一种玩法,给 CPU 加了几层缓存,CPU 可以直接操作自己对应的数据缓存,不需要直接频繁的跟主内存通信,这个是现代计算机技术的一个进步,这样可以保证 CPU 的计算的效率非常的高
总线加锁机制和 MESI 缓存一致性协议
CPU 在它和主内存之间加了一层缓存,主内存的数据会被加载到 CPU 本地缓存里去
CPU 缓存模型,其实默认情况下是有问题的,特别是多线程并发运行的时候,导致各个 CPU 的本地缓存跟主内存没有同步,一个数据在各个地方可能都不一样,就会导致数据的不一致
- 总线加锁机制
大概意思是说,某个 CPU 如果要读一个数据,会通过一个总线,对这个数据加一个锁,其他 CPU 就没法去读和写这个数据了,只有当这个 CPU 修改完成以后,其他 CPU可以读到最新的数据,这个总线加锁机制效率太差了,一旦说多个线程出现对某个共享变量的访问之后,机会导致串行化的问题,现在已经弃用了
- MESI 缓存一致性协议
MESI 协议,可以保证在 CPU 缓存模型下,不会出现多线程并发读写变量,没有办法感知到的一整套机制
java的内存模型
java 内存模型是跟 CPU 缓存模型类似的,基于 CPU 缓存模型来建立的 java内存模型,只不过 java 内存模型是标准化的,屏蔽掉底层不同的计算机的区别
线程的工作内存和主内存
- read(从主存读取)
- load(将主存读取到的值写入工作内存)
- use(从工作内存读取数据来计算)
- assign(将计算好的值重新赋值到工作内存中)
- store(将工作内存数据写入主存)
- write(将store 过去的变量赋值给主存中的变量)
并发编程三大特性
在并发编程过程中,可能会产生三类问题,可见性、原子性、有序性
可见性
一个线程对共享变量的修改,另一个线程不可感知到,这就是可见性问题。
- volatile是如何保证可见性的
如上图,只要 flag 变成了 1,然后线程不是要将 flag = 1写会工作内存吗?assign操作。此时,若 flag 变量上加了 volatile 关键字的话,那么此时会强制保证 assign 之后就立马执行 store + write,刷回到主内存里去,保证只要工作内存一旦变为 flag = 1,主内存立马变成 flag = 1
此外,如果这个变量加了 volatile 关键字的话,此时他就会让其他线程工作内存中的这个 flag 变量的缓存,使其强制过期掉,其他线程再从工作内存中读取 flag 变量的值时,发现它已经过期了,此时就会重新从主内存里加载这个flag=1
通过 volatile 关键字,可以实现的一个效果是,有一个线程修改了值,其他线程可以立马感知到这个值
原子性
如下图所示,对于一个 i++ 操作,只要是多个线程并发运行来执行这行代码,其实都是不保证原子性的,如果保证原子性,第一个线程 i++,i=1,第二个线程i++,i=2
- volatile 不保证原子性
volatile是不保证原子性的,如图,线程1变为 flag=1并写会到主内存了,线程2中工作内存,感知到被改变,会使 flag = 0过期掉,重新读到flag=1,此时线程2已经计算完flag=1,assign到主内存同样是1,最后写回到主内存
有序性
对于代码,还有一个问题是指令重排序,编译器和指令器,有的时候为了提高代码执行效率,会将指令重新排序,比如说下面的代码
flag = false;
// 线程1
prepare(); //准备资源
flag = true;
//线程2
while(!flag) {
Thread.sleep(1000);
}
execute(); //基于准备好的资源执行操作
重新排序之后,让 flag = true 先执行了,会导致线程2 直接跳过 while 等待,执行某段代码,结果 prepare() 方法还没执行,资源还没准备好呢,此时就会导致代码逻辑出现异常
- 基于 happens-before原则保证有序性
编译器、指令器可能会对代码重排序,但要遵守一定的规则,happens-before 原则。只要符合 happens-before 原则,那么就不能胡乱排序,如果不符合这些规则的话,那就可以自己排序
程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作,volatile变量写,再是读,必须保证是先写,再读
传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
上面这8条原则的意思很显而易见,就是程序中的代码如果满足这个条件,就一定会按照这个规则来保证指令的顺序。但是如果没满足上面的规则,那么就可能会出现指令重排,就这个意思。这8条原则是避免说出现乱七八糟扰乱秩序的指令重排,要求是这几个重要的场景下,比如是按照顺序来,但是8条规则之外,可以随意重排指令。
//线程1:
prepare(); // 准备资源
volatile flag = true;
//线程2:
while(!flag){
sleep()
}
execute(); // 基于准备好的资源执行操作
比如这个例子,如果用volatile来修饰flag变量,一定可以让prepare()指令在flag = true之前先执行,这就禁止了指令重排。因为volatile要求的是,volatile前面的代码一定不能指令重排到volatile变量操作后面,volatile后面的代码也不能指令重排到volatile前面。
volatile 底层原理
- lock指令
对 volatile 修饰的变量,执行写操作的话,JVM 会发送一条 lock 前缀指令给 CPU,CPU在计算完之后会立即将这个值写会主内存,同时因为有 MESI 缓存一致性协议,所有各个 CPU 都会对总线进行嗅探,自己本地缓存中的数据是否被别人修改,如果发现修改了某个缓存的数据,那么CPU 就会将自己本地缓存的数据过期掉,然后这个CPU 上执行的线程在读取那个变量的时候,就会从住内存重现加载最新的数据
- 内存屏障:禁止指令重排
load1:
int localVar = this,variable
load2:
int localVar = this.variable2
LoadLoad屏障:Load1;LoadLoad;Load2。确保 Load1 数据的装载先于 Load2 后所有装载指令。意思是,Load2对应的代码和Load2对应的代码,是不能指令重排的
Store1:
this.variable = 1
StoreStore屏障
Store2:
this.variable2 = 2
StoreStore屏障:Store1;StoreStore;Store2。确保Store1的数据一定刷回主存,对其他cpu可见,先于Store2以及后续指令
LoadStore屏障:Load1;LoadStore;Store2。确保Load1指令的数据装载,先于Store2以及后续指令
StoreLoad屏障:Store1;StoreLoad;Load2。确保Store1指令的数据一定刷回主存,对其他cpu可见,先于Load2以及后续指令的数据装载
对于 volatile 修饰变量的读写操作,都会加入内存屏障
每个 volatile 写操作前面,加 StoreStore屏障,禁止上面的普通写和它重排,每个 volatile 写操作后面,加StoreLoad屏障,禁止跟下面的 volatile 读/写重排
每个 volatile 读操作后面,加 LoadLoad 屏障,禁止下面的普通读和 volatile 读重排,每个 volatile 读操作后面,加LoadStore屏障,禁止下面的普通写和 volatile 读重排
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南