JAVA:volatile关键字的妙用
前言
提到volatile关键字,则Java并发编程的三大特性永远无法被绕过去:原子性,可见性,有序性。那么这三大特性分别是什么意思呢?
Java并发变成的三大特性
原子性:
人类科技发展的某个阶段,人们普遍认为原子是物质的最小组成单元,因此原子性借用了这个概念,用来表述一个操作是不可分割的,要么全部执行成功,要么全部不执行。例如i++(实际包含读取、修改、写入三步,非原子),而AtomicInteger的incrementAndGet()是原子操作。
可见性:指一个线程对共享变量的修改,其他线程能立即看到。若缺乏可见性,线程可能读取到共享变量的过期值,可通过synchronized、volatile等保证。
有序性:
指程序执行的顺序与代码的逻辑顺序一致。编译器或 CPU 可能对指令重排,需通过synchronized、volatile(禁止指令重排)等保证有序性。
为了方便一眼观全局,此处直接贴上了结论,如果不想深究的可以直接拿走。
你所理解的i++,真的是一行代码吗?
i++在java代码中就是简单的一行,我们习惯性认为,每行代码都是对应了一次指令运行 ,但如果将“显微镜”倍数放大,就能发现其实并不是这样的。
Java中的代码落到JVM后,会涉及底层指令集,实际的指令比我们想象的更加复杂。
关于底层代码指令查看的方法,请参考此处传送门:JAVA:如何查看代码在JVM底层的指令集?
原子性带来的问题
D:\Data\JAVA\code\demo1>javap -c JavaPDemo.class
Compiled from "JavaPDemo.java"
public class JavaPDemo {
public JavaPDemo();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public void incr();
Code:
0: aload_0
1: dup
2: getfield #2 // Field i:I
5: iconst_1
6: iadd
7: putfield #2 // Field i:I
10: return
}
一行简单的i++代码,其实内部被分为了多条指令,如果是在多线程高并发的情况下,对同一个变量进行操作,执行的顺序自然就无法确定了。比如线程1执行了getfield(此时i=0) ,在执行iadd的时候,另一个线程也刚好拿到了CPU资源,同样来执行getfield(此时i=0) ,那么两个线程执行完成之后,预期i=2,但实际上输出结果为i=1,这就非原子性在多线程下暴露出来的问题。
怎么保证原子性
以下是一个多线程对同一个变量i的++过程,main方法中注册了五个线程进行i++,主线程中对五个线程执行join,等待五个线程都执行完成后,执行i的输出。
public class JavaPDemo {
private static int i = 0;
private static void incr(){
i++;
}
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[5];
for (int i = 0; i < 5; i++) {
threads[i] = new Thread(()-> {
for (int j = 0; j < 2000; j++) {
incr();
}
});
threads[i].start();
}
for (int j = 0; j < 5; j++) {
threads[j].join();
}
System.out.println(i);
}
}
输出结果:
8171
这个结果并不是永恒不变的,每次执行可能结果都不一样。
加锁
基于非原子性导致问题的原因,我们很容易想到对应的解决方法:让执行这一操作(例如i++)的过程变成串行化,也就是这个操作只能单线程执行,即便是在多线程的情况下,同一时间要保证所有的指令都先全部被执行完后,下一个线程才能进入。最直接的方法就是加锁。
private synchronized static void incr(){
i++;
}
结果就会变成符合预期的10000,可用以上代码验证,改动处仅是incr方法加了synchronized关键字
使用原子类定义变量i
用AtomicInteger类来代替int定义i。
相应的还有各个基本数据类型对应的Atomic类,这些类本身就是线程安全的。
而这些类实现原子性的原因就在内部的value上:
private volatile int value;
AtomicInteger 能保证原子性,核心在于其内部使用了 CAS(Compare And Swap,比较并交换)机制,配合 volatile 关键字保证可见性,从而实现了无需加锁(无锁机制)的线程安全操作。
具体原理:
CAS 机制的底层实现AtomicInteger 内部维护了一个 volatile int value 变量(用 volatile 保证可见性),所有操作都围绕这个变量展开。以自增操作 incrementAndGet() 为例,其核心逻辑依赖 Unsafe 类的 native 方法(直接操作内存的底层指令):
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
// Unsafe 类的核心方法(native 实现)
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
// 1. 读取当前内存中的 value 值(v)
v = getIntVolatile(o, offset);
// 2. CAS 尝试更新:若内存中的值仍为 v,则更新为 v + delta
} while (!compareAndSwapInt(o, offset, v, v + delta));
// 3. 若更新失败(被其他线程修改),则循环重试,直到成功
return v;
}
compareAndSwapInt 是原子操作(由 CPU 指令 cmpxchg 保证),能判断内存中的值是否与预期值 v 一致:
- 一致则更新为新值(v + delta),返回 true,退出循环;
- 不一致则说明被其他线程修改,返回 false,继续循环重试(自旋)。
无锁机制的优势传统的 synchronized 或 Lock 是悲观锁(假设会冲突,直接加锁阻塞线程),而 CAS 是乐观锁(假设冲突概率低,通过自旋重试避免阻塞)。因此,AtomicInteger 在低并发场景下性能更优,避免了线程上下文切换的开销。
为什么能保证原子性?
CAS 操作由 CPU 底层指令保证原子性,不会被中断;循环重试机制确保即使被其他线程干扰,最终也能成功更新;
volatile 保证了变量的可见性,确保线程能读取到最新值。
有序性带来的问题
public class SeqDemo {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
int i = 0;
for (; ; ) {
i++;
x = 0;
y = 0;
a = 0;
b = 0;
Thread t1 = new Thread(() -> {
a = 1;
x = b;
});
Thread t2 = new Thread(() -> {
b = 1;
y = a;
});
/**
* 可能的结果:
* 1和1
* 0和1
* 1和0
* ----
* 0和0
*/
t1.start();
t2.start();
t1.join();
t2.join();
String result="第"+i+"次("+x+","+y+")";
if(x==0&&y==0){
System.out.println(result);
break;
}else{
}
}
}
}
代码输出:
第651530次(0,0)
代码解释:
这是一个无限循环,每次循环都对x,y,a,b进行置0的操作,而两个线程分别x,y,a,b进行赋值,通过无限循环的方式,找出一次x=y=0的记录,比如:
看下面的解释之前,需要先弄清楚,此处指的重排序,不是指的排序t1和t2的指令顺序,而是指的【t1中的 a = 1; x = b;两行代码的指令顺序】和【t2中的b = 1; y = a;两行代码的顺序】
在这个程序中,各种结果出现的原因:
(1,1):t1 先执行完 a=1; x=b(此时 b 已被 t2 设为 1),t2 执行完 b=1; y=a(此时 a 已被 t1 设为 1)。
(0,1):t1 先执行 a=1; x=b(b 尚未被 t2 修改,故 x=0),t2 后执行 b=1; y=a(a 已被修改,故 y=1)。
(1,0):与上相反,t2 先执行导致 y=0,t1 后执行导致 x=1。
(0,0):因指令重排序,t1 先执行 x=b(b=0),t2 先执行 y=a(a=0),随后才分别修改 a 和 b。
如果没有看懂,我们从另一个方向看:
- 线程(t1和t2)指令的交替执行(与重排序无关)
多线程环境中,t1 和 t2 的指令本身就可能交替执行(由 CPU 调度调度决定),这是线程并发的天然特性,与重排序无关。例如:
t1 执行 a=1 → t2 执行 b=1 → t1 执行 x=b → t2 执行 y=a
结果是 (x=1, y=1),这是线程交替执行的结果,而非重排序。- 重排序(单线程内的指令顺序调整)
重排序是指单个线程内部,编译器或 CPU 为优化性能,对没有数据依赖的指令调整执行顺序。例如:
t1 的代码原本是 a=1; x=b;,可能被重排序为 x=b; a=1;(因为 a 和 b 无依赖)。
t2 的代码原本是 b=1; y=a;,可能被重排序为 y=a; b=1;(因为 b 和 a 无依赖)。
这种单线程内的指令顺序调整,才是导致 (x=0, y=0) 的核心原因:t1 先执行 x=b(此时 b=0 → x=0),再执行 a=1。 t2 先执行 y=a(此时 a=0 → y=0),再执行 b=1。
即使 t1 和 t2 的指令没有交替执行(比如 t1 先执行完重排序后的指令,再执行 t2),也会出现 (0,0)。
如果两种说法都没有理解的话,欢迎留言,大家一起交流:三个臭皮匠,胜过诸葛亮
怎么保证有序性
用 volatile 修饰共享变量
修改代码中 a 和 b 的定义,添加 volatile 关键字:
public class FixedSeqDemo {
private static int x = 0, y = 0;
// 用volatile修饰a和b,禁止重排序
private static volatile int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
int i = 0;
for (; ; ) {
i++;
x = 0;
y = 0;
a = 0;
b = 0;
Thread t1 = new Thread(() -> {
a = 1; // 步骤1:对volatile变量的写操作
x = b; // 步骤2:读取volatile变量b
});
Thread t2 = new Thread(() -> {
b = 1; // 步骤3:对volatile变量的写操作
y = a; // 步骤4:读取volatile变量a
});
t1.start();
t2.start();
t1.join();
t2.join();
String result = "第" + i + "次(" + x + "," + y + ")";
if (x == 0 && y == 0) {
System.out.println(result); // 若volatile生效,此句永远不会执行
break;
}
}
}
}
原理说明:
volatile 解决有序性的核心是 “内存屏障”(CPU 指令),它会阻止编译器和 CPU 对 volatile 变量前后的指令进行重排序:
对于 t1 中的 a = 1; x = b;:
a 是 volatile 变量,写操作 a = 1 后会插入内存屏障,禁止将 x = b 重排序到 a = 1 之前。
对于 t2 中的 b = 1; y = a;:
b 是 volatile 变量,写操作 b = 1 后会插入内存屏障,禁止将 y = a 重排序到 b = 1 之前。
效果
添加 volatile 后,t1 和 t2 中的指令无法重排序,因此永远不会出现 x=0 且 y=0 的结果,只会出现 (1,1)、(0,1)、(1,0) 三种符合逻辑顺序的结果。
其他解决方案:
除了 volatile,也可以用 synchronized 或 Lock 等同步机制,通过加锁保证代码块内的指令有序执行(同步机制天然禁止重排序),但 volatile 是更轻量的解决方案。
可见性带来的问题
public class VolatileDemo {
private static boolean stop = false;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
while(!stop) {
}
System.out.println("线程执行完成...");
});
t1.start();
Thread.sleep(5000);
stop = !stop; // 改变stop的值,试图让线程结束
System.out.println("主线程执行结束...");
}
}
输出结果:
主线程执行结束...
这段代码将会永远不结束,因为线程看到的stop永远是false。也就是说,main线程中改变stop的值,子线程内永远感知不到。
想要了解为什么会有可见性问题,那就少不了要了解CPU的三级缓存机制:
CPU 的三级缓存(L1、L2、L3 Cache)是位于 CPU 与主内存(RAM)之间的高速缓存层级,用于解决 CPU 运算速度与主内存访问速度不匹配的问题(CPU 速度远快于内存),通过临时存储频繁使用的数据来提升整体性能。
注意:下方的内容可做了解,不影响后续理解,无兴趣则可以略过。
L1 缓存(一级缓存)
位置:直接集成在 CPU 核心内部,与核心绑定。
特点:速度最快(纳秒级,接近 CPU 主频速度),容量最小(每个核心通常 32KB-256KB)。
细分:通常分为 L1i(指令缓存) 和 L1d(数据缓存),分别存储指令和数据,减少指令与数据的访问冲突。
L2 缓存(二级缓存)
位置:仍属于 CPU 核心内部,但距离核心稍远(或与核心共享一个模块)。
特点:速度比 L1 慢(约是 L1 的 3-5 倍),容量更大(每个核心通常 256KB-2MB)。
作用:作为 L1 缓存的 “后备”,存储 L1 未命中时的数据,减少对 L3 或内存的依赖。
L3 缓存(三级缓存)
位置:通常位于 CPU 核心外部,由多个核心共享(部分 CPU 为每个核心单独配置)。
特点:速度比 L2 慢(约是 L2 的 2-3 倍),容量最大(主流 CPU 通常 4MB-128MB)。
作用:作为 L2 缓存的 “后备”,在多核心场景下共享数据,减少核心间的通信开销(避免频繁访问主内存)。
注意:下方的内容需要理解。
缓存的工作原理(局部性原理)
CPU 访问数据时,会按 L1 → L2 → L3 → 主内存 的顺序查找:
若数据在 L1 中(命中),直接读取,耗时最短;
若 L1 未命中,查找 L2,以此类推;
若所有缓存都未命中(未命中),则访问主内存(耗时是 L1 的 100 倍以上)。
这种设计基于程序的 “局部性原理”:程序运行时,大概率会频繁访问最近使用过的数据或相邻数据(时间局部性和空间局部性),缓存能将这些数据暂存,大幅减少内存访问次数。
为什么需要三级缓存?
速度差异:CPU 主频通常为 3-5GHz(每秒数十亿次操作),而主内存访问延迟约 60-100 纳秒,若直接依赖内存,CPU 会大量空闲等待。
成本平衡:缓存由高速 SRAM 芯片组成(成本高、容量小),主内存是 DRAM(成本低、容量大)。三级缓存通过 “多级分层”,在速度、容量和成本之间取得平衡。
对编程的影响
多线程编程中,缓存可能导致 “可见性问题”:线程操作共享变量时,数据会先加载到 CPU 缓存中,修改后可能未及时同步到主内存; 其他线程可能读取到主内存中的 “旧值”,导致数据不一致。
怎么保证可见性
Java 中的 volatile、synchronized 等机制,本质是通过 “内存屏障”(Memory Barrier)强制线程工作内存与主内存(RAM)同步,解决因 CPU 缓存导致的可见性问题。
现代计算机中,线程操作变量的流程是:
主内存 → CPU 缓存(线程工作内存)
寄存器可见性问题的根源是:
线程修改变量后,可能只更新了 CPU 缓存,未同步到主内存;其他线程仍从自己的缓存读取旧值。
内存屏障的作用是:
强制将 CPU 缓存中的修改 “刷新到主内存”(写操作时);
强制从主内存 “重新加载最新值到 CPU 缓存”(读操作时)。
使用volatile关键字修饰共享变量
public class VolatileDemo {
private static volatile boolean stop = false;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
while(!stop) {
}
System.out.println("线程执行完成...");
});
t1.start();
Thread.sleep(5000);
stop = !stop; // 改变stop的值,试图让线程结束
System.out.println("主线程执行结束...");
}
}
输出:
主线程执行结束...
线程执行完成...
Process finished with exit code 0
使用synchronized
public class VolatileDemoWithoutVolatile {
private static boolean stop = false;
private static final Object lock = new Object(); // 共享锁对象
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
// 通过同步块读取stop,保证可见性
while (true) {
synchronized (lock) {
if (stop) {
break;
}
}
}
System.out.println("线程执行完成...");
});
t1.start();
Thread.sleep(5000);
// 修改stop时也加锁,保证写入主内存
synchronized (lock) {
stop = !stop;
}
System.out.println("主线程执行结束...");
}
}
使用Lock
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class VolatileDemoWithLock {
private static boolean stop = false;
private static final Lock lock = new ReentrantLock(); // 显式锁
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (true) {
lock.lock();
try {
if (stop) {
break;
}
} finally {
lock.unlock();
}
}
System.out.println("线程执行完成...");
});
t1.start();
Thread.sleep(5000);
lock.lock();
try {
stop = !stop;
} finally {
lock.unlock();
}
System.out.println("主线程执行结束...");
}
}
这些枷锁的方案,
性能略低于 volatile(因为涉及锁竞争),但在无法使用 volatile 的场景下是有效的替代方案。
核心是通过同步机制强制变量在主内存中读写,本质上与 volatile 解决可见性的思路一致,只是实现方式不同。
CPU层面的优化:MESI协议+总线锁
MESI 协议是 CPU 缓存一致性协议(Cache Coherence Protocol)的一种,用于解决多核心 CPU 中缓存数据不一致的问题(当多个 CPU 核心的缓存中存在同一主内存数据的副本时,确保它们的一致性)。
核心作用
在多核心 CPU 中,每个核心可能缓存主内存中的同一变量。当其中一个核心修改了该变量,其他核心的缓存副本必须感知到这个变化,否则会出现数据不一致(例如线程可见性问题)。MESI 协议通过定义缓存行(Cache Line)的四种状态及状态转换规则,保证缓存副本的一致性。
缓存行的四种状态(MESI 名称来源)
Modified(已修改)
缓存行中的数据被修改过,与主内存中的数据不一致。
该缓存行只存在于当前核心的缓存中(独占),需在被其他核心读取前写回主内存。
Exclusive(独占)
缓存行中的数据与主内存一致,但仅存在当前核心的缓存持有该数据(未被其他核心缓存)。
可直接修改为Modified状态(无需通知其他核心)。
其他核心要取这个值,则缓存命中不了,自然会去去主内存的值后自动缓存到其他核心。
Shared(共享)
缓存行中的数据与主内存一致,且可能被多个核心的缓存持有(共享状态)。若要修改,需先通知其他核心将该缓存行置为Invalid(无效),再转为Modified。
Invalid(无效)
缓存行中的数据无效(已过期或被其他核心修改),需从主内存或其他核心的缓存重新加载。
MESI协议的工作过程
场景设定
假设有两个 CPU 核心(Core 1、Core 2)和主内存,共同操作变量x(初始值为 0),模拟 “Core 1 修改x,Core 2 读取x” 的过程。
步骤 1:初始状态(所有缓存为空)
主内存:x = 0
Core 1 缓存:无x的副本(空)
Core 2 缓存:无x的副本(空)
步骤 2:Core 1 读取x(加载到缓存)
Core 1 需要读取x,发现本地缓存没有,向主内存请求。
主内存将x=0发送给 Core 1。
Core 1 缓存x=0,状态标记为Exclusive(独占,因为只有它持有副本)。
状态:
Core 1 缓存:x=0(Exclusive)
Core 2 缓存:无
主内存:x=0
步骤 3:Core 2 读取x(共享副本)
Core 2 需要读取x,向主内存请求。
主内存发现 Core 1 已缓存x,通知 Core 1“有其他核心要读取x”。
Core 1 将自己的x副本状态改为Shared(共享),并将x=0发送给 Core 2。
Core 2 缓存x=0,状态标记为Shared。
状态:
Core 1 缓存:x=0(Shared)
Core 2 缓存:x=0(Shared)
主内存:x=0
步骤 4:Core 1 修改x(触发 Invalidate 请求)
Core 1 要将x改为 1,发现当前状态是 Shared(不能直接修改)。
Core 1 向所有持有x副本的核心(这里是 Core 2)发送Invalidate(失效)请求。
Core 2 收到请求后,将自己的x副本状态改为Invalid(无效),并回复 “已失效”。
Core 1 收到确认后,将自己的x副本状态改为Modified(已修改),并执行修改(x=1)。
状态:
Core 1 缓存:x=1(Modified)
Core 2 缓存:x=0(Invalid)
主内存:x=0(尚未同步)
步骤 5:Core 2 再次读取x(重新加载最新值)
Core 2 要读取x,发现本地缓存是 Invalid(无效)。
Core 2 向主内存请求x,主内存发现 Core 1 的x是 Modified(已修改)。
主内存通知 Core 1 “将修改后的值写回主内存”。
Core 1 将x=1写回主内存,自己的状态改为Shared(因为有其他核心要读取)。
主内存将x=1发送给 Core 2,Core 2 缓存x=1,状态为Shared。
最终状态:
Core 1 缓存:x=1(Shared)
Core 2 缓存:x=1(Shared)
主内存:x=1
附:MESI协议的模拟神器:
https://www.scss.tcd.ie/Jeremy.Jones/vivio/caches/MESI.htm
引入MESI协议后,其实也带来了新的问题,有兴趣的可以去百度搜索,这里就不延伸了,望见谅~
happends-before规则
在 Java 并发编程中,“happens-before” 是 Java 内存模型(JMM)中用于定义多线程之间操作顺序的规则集合。
如果操作 A happens-before 操作 B,那么 A 的执行结果对 B 可见,并且在 B 执行之前 A 必须已经完成。
这并不意味着前一个操作必须要在后一个操作之前执行,而是一种 “逻辑先行” 关系,即使 A 在物理时间上后于 B 执行,只要 A happens-before B,A 的结果就必须对 B 可见。
JMM 定义了多条核心的 happens-before 规则,常见的有:
- 程序顺序规则:在同一个线程中,按照代码顺序,前面的操作 happens-before 后面的操作。
as-serial-if规则:
int a =1;
int b = 2;
int c = a + b;
int a = 1;和 int b = 2;这两行代码可以重排序,但是int c = a + b;这行代码必须在两行代码之后执行。
start规则:
如果线程A执行操作ThreadB.start(),那么线程A的ThreadB.start()之前的操作happens-before线程B中的任意操作。
public StartDemo{
int x=0;
Thread t1 = new Thread(()->{
// 主线程调用 t1.start() 之前
// 所有对共享变量的修改,此处皆可见
// 此例中,x==10
});
// 此处对共享变量 x修改
x = 10;
// 主线程启动子线程
t1.start();
}
join规则
如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功的返回。
Thread t1 = new Thread(()->{
// 此处对共享变量 x 修改
x= 100;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程 t1 可见
// 主线程启动子线程
t1.start();
t1.join()
// 子线程所有对共享变量的修改
// 在主线程调用 t1.join() 之后皆可见
// 此例中,x==100
- 监视器锁规则:对一个锁的解锁操作 happens-before 后续对同一个锁的加锁操作。
先加锁的代码结果对后枷锁的代码可见。
- volatile 变量规则:对一个 volatile 变量的写操作 happens-before 后续对同一个 volatile 变量的读操作。
pre-valotile对next-valotile可见
- 传递性规则:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
happens-before 规则的价值在于屏蔽了底层硬件和编译器的复杂细节,如重排序、缓存等,为开发者提供了简单清晰的可见性判断标准。开发者只需关注是否满足 happens-before 规则,无需关心底层如何实现可见性,JVM 则需保证在满足规则的情况下,底层优化不破坏可见性。
Studying will never be ending.
▲如有纰漏,烦请指正~~
更多推荐

所有评论(0)