volatile 关键字防止指令重排

本文详细解析了 Java 中 volatile 关键字的工作原理,包括其如何确保内存可见性和禁止指令重排优化,以及在实际开发中的应用场景。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >


前言

不管是在面试还是实际开发中 volatile 都是一个应该掌握的技能。

首先来看看为什么会出现这个关键字。

内存可见性

由于 Java 内存模型(JMM)规定,所有的变量都存放在主内存中,而每个线程都有着自己的工作内存(高速缓存)。

线程在工作时,需要将主内存中的数据拷贝到工作内存中。这样对数据的任何操作都是基于工作内存(效率提高),并且不能直接操作主内存以及其他线程工作内存中的数据,之后再将更新之后的数据刷新到主内存中。

这里所提到的主内存可以简单认为是堆内存,而工作内存则可以认为是栈内存

如下图所示:

所以在并发运行时可能会出现线程 B 所读取到的数据是线程 A 更新之前的数据。

显然这肯定是会出问题的,因此 volatile 的作用出现了:

当一个变量被 volatile 修饰时,任何线程对它的写操作都会立即刷新到主内存中,并且会强制让缓存了该变量的线程中的数据清空,必须从主内存重新读取最新数据。

volatile 修饰之后并不是让线程直接从主内存中获取数据,依然需要将变量拷贝到工作内存中

内存可见性的应用

当我们需要在两个线程间依据主内存通信时,通信的那个变量就必须的用 volatile 来修饰:


     
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

     
public class Volatile implements Runnable{
private static volatile boolean flag = true ;
@Override
public void run() {
while (flag){
System.out.println(Thread.currentThread().getName() + "正在运行。。。");
}
System.out.println(Thread.currentThread().getName() + "执行完毕");
}
public static void main(String[] args) throws InterruptedException {
Volatile aVolatile = new Volatile();
new Thread(aVolatile, "thread A").start();
System.out.println( "main 线程正在运行") ;
TimeUnit.MILLISECONDS.sleep( 100) ;
aVolatile.stopThread();
}
private void stopThread(){
flag = false ;
}
}

主线程在修改了标志位使得线程 A 立即停止,如果没有用 volatile 修饰,就有可能出现延迟。

但这里有个误区,这样的使用方式容易给人的感觉是:

对 volatile 修饰的变量进行并发操作是线程安全的。

这里要重点强调,volatile 并不能保证线程安全性!

如下程序:


     
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

     
public class VolatileInc implements Runnable{
private static volatile int count = 0 ; //使用 volatile 修饰基本数据内存不能保证原子性
//private static AtomicInteger count = new AtomicInteger() ;
@Override
public void run() {
for ( int i= 0;i< 10000 ;i++){
count ++ ;
//count.incrementAndGet() ;
}
}
public static void main(String[] args) throws InterruptedException {
VolatileInc volatileInc = new VolatileInc() ;
Thread t1 = new Thread(volatileInc, "t1") ;
Thread t2 = new Thread(volatileInc, "t2") ;
t1.start();
//t1.join();
t2.start();
//t2.join();
for ( int i= 0;i< 10000 ;i++){
count ++ ;
//count.incrementAndGet();
}
System.out.println( "最终Count="+count);
}
}

当我们三个线程(t1,t2,main)同时对一个 int 进行累加时会发现最终的值都会小于 30000。

这是因为虽然 volatile 保证了内存可见性,每个线程拿到的值都是最新值,但 count ++ 这个操作并不是原子的,这里面涉及到获取值、自增、赋值的操作并不能同时完成。

  • 所以想到达到线程安全可以使这三个线程串行执行(其实就是单线程,没有发挥多线程的优势)。

  • 也可以使用 synchronize 或者是锁的方式来保证原子性。

  • 还可以用 Atomic 包中 AtomicInteger 来替换 int,它利用了 CAS 算法来保证了原子性。

指令重排

内存可见性只是 volatile 的其中一个语义,它还可以防止 JVM 进行指令重排优化。

举一个伪代码:


     
1
2
3

     
int a= 10 ; //1
int b= 20 ; //2
int c= a+b ; //3

一段特别简单的代码,理想情况下它的执行顺序是:1>2>3。但有可能经过 JVM 优化之后的执行顺序变为了 2>1>3

可以发现不管 JVM 怎么优化,前提都是保证单线程中最终结果不变的情况下进行的。

可能这里还看不出有什么问题,那看下一段伪代码:


     
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

     
private static Map<String,String> value ;
private static volatile boolean flag = fasle ;
//以下方法发生在线程 A 中 初始化 Map
public void initMap(){
//耗时操作
value = getMapValue() ; //1
flag = true ; //2
}
//发生在线程 B中 等到 Map 初始化成功进行其他操作
public void doSomeThing(){
while(!flag){
sleep() ;
}
//dosomething
doSomeThing(value);
}

这里就能看出问题了,当 flag 没有被 volatile 修饰时,JVM 对 1 和 2 进行重排,导致 value 都还没有被初始化就有可能被线程 B 使用了。

所以加上 volatile 之后可以防止这样的重排优化,保证业务的正确性。

指令重排的的应用

一个经典的使用场景就是双重懒加载的单例模式了:


     
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

     
public class Singleton {
private static volatile Singleton singleton;
private Singleton() {
}
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
//防止指令重排
singleton = new Singleton();
}
}
}
return singleton;
}
}

这里的 volatile 关键字主要是为了防止指令重排。

如果不用 ,singleton = new Singleton();,这段代码其实是分为三步:

  • 分配内存空间。(1)
  • 初始化对象。(2)
  • 将 singleton 对象指向分配的内存地址。(3)

加上 volatile 是为了让以上的三步操作顺序执行,反之有可能第二步在第三步之前被执行就有可能某个线程拿到的单例对象是还没有初始化的,以致于报错。

总结

volatile 在 Java 并发中用的很多,比如像 Atomic 包中的 value、以及 AbstractQueuedLongSynchronizer中的 state 都是被定义为 volatile 来用于保证内存可见性。

将这块理解透彻对我们编写并发程序时可以提供很大帮助。

<think>我们正在讨论volatile关键字如何防止指令重排。根据提供的引用,我们可以总结如下: 1. volatile关键字通过内存屏障(Memory Barrier)来禁止指令重排序。 2. 内存屏障是一个CPU指令,它有两个作用:保证特定操作的顺序和保证某些变量的内存可见性。 3. 在Java中,volatile变量的写操作会插入一个写屏障(Store Barrier),确保在写操作之前的所有操作都已完成,并且写操作的结果对其他处理器可见。 4. volatile变量的读操作会插入一个读屏障(Load Barrier),确保在读操作之后的所有操作都不会被重排序到读操作之前。 具体来说,volatile禁止指令重排是通过在编译器和处理器两个层面实现的: - 编译器层面:编译器在生成字节码时,会在volatile变量操作前后插入内存屏障指令,禁止编译器重排序。 - 处理器层面:内存屏障指令会阻止处理器进行重排序,确保屏障前后的指令不会跨屏障重排。 引用[3]中详细解释了volatile如何通过内存屏障实现禁止重排序:当执行到volatile变量的写操作时,会插入一条lock addl指令(相当于一个内存屏障),这个操作会将当前处理器缓存行的数据写回主内存,并且这个写操作会导致其他处理器中缓存了该内存地址的数据无效。同时,lock指令还起到了屏障的作用,使得之前的操作不能重排序到屏障之后。 引用[2]和引用[5]中给出了代码示例,说明在多线程环境下,如果没有禁止指令重排,可能会出现线程安全问题(如单例模式中的双重检查锁定问题)。使用volatile修饰变量可以防止指令重排,从而避免这种问题。 总结:volatile关键字通过插入内存屏障来禁止指令重排,确保多线程环境下程序的正确性。 相关问题: 1. 内存屏障具体有哪些类型,它们的作用分别是什么? 2. 除了volatile,Java中还有哪些方式可以防止指令重排? 3. 在单例模式中,为什么双重检查锁定需要使用volatile关键字? 4. 指令重排可能带来哪些并发问题?请举例说明。</think>### volatile关键字如何防止指令重排 volatile关键字通过**内存屏障(Memory Barrier)** 机制实现禁止指令重排序,具体原理如下: #### 1. **内存屏障的作用** - **禁止重排序**:在volatile变量的**写操作前**插入`StoreStore`屏障,在**写操作后**插入`StoreLoad`屏障;在**读操作前**插入`LoadLoad`屏障,在**读操作后**插入`LoadStore`屏障[^1][^3]。 - **保证可见性**:强制将工作内存的修改刷新到主内存,并使其他线程的缓存失效[^1][^4]。 #### 2. **屏障类型与功能** | 屏障类型 | 作用 | |----------------|----------------------------------------------------------------------| | `StoreStore` | 禁止上方普通写与下方volatile重排序 | | `StoreLoad` | 禁止volatile写与后续可能出现的volatile读/写重排序(全能屏障) | | `LoadLoad` | 禁止下方普通读与上方volatile重排序 | | `LoadStore` | 禁止上方volatile读与下方普通写重排序 | #### 3. **底层实现原理** - **硬件层面**:通过CPU指令(如x86的`lock addl $0x0, (%esp)`)实现屏障效果,该指令会锁定内存总线,确保之前的操作全部完成[^3]。 - **编译器层面**:禁止编译器对volatile变量相关的代码进行重排序优化[^2][^4]。 #### 4. **示例说明** ```java // 双重检查锁定单例模式 private volatile static Singleton instance; public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { if (instance == null) { // 第二次检查 instance = new Singleton(); // volatile写:屏障阻止重排序 } } } return instance; } ``` - **未用volatile的风险**:`new Singleton()`可能被重排序为: 1. 分配内存空间 2. 将引用指向内存(此时`instance != null`) 3. 初始化对象 - 若步骤2和3重排,其他线程可能拿到未初始化的半成品对象[^2]。 - **volatile的作用**:屏障确保`instance = new Singleton()`的步骤3不会重排到步骤2之前[^2][^3]。 #### 5. **重排序的触发场景** - **处理器优化**:CPU为提高效率可能调整无关指令顺序[^3][^5]。 - **编译器优化**:Java编译器在生成字节码时可能调整指令[^4]。 - **案例**:若两个线程分别执行: ```java // 线程1 a = 1; // 普通写 x = b; // 普通读 // 线程2 b = 1; // 普通写 y = a; // 普通读 ``` 可能因重排序导致`x == 0 && y == 0`(实际a,b已赋值)[^5]。若将`a`和`b`声明为`volatile`,可避免此问题。 ### 总结 volatile通过**内存屏障**在编译器和处理器层面禁止重排序,确保: 1. 写操作前的所有修改对后续操作可见。 2. 读操作后的指令不会重排到读操作前。 3. 多线程环境下操作的有序性和可见性[^1][^3][^4]。 --- ### 相关问题 1. **内存屏障的具体类型有哪些?各自的应用场景是什么?** 2. **除了volatile,Java中还有哪些机制可以防止指令重排(如`final`字段)?** 3. **在单例模式的双重检查锁定中,为什么volatile是必需的?不用会导致什么后果?** 4. **指令重排可能引发哪些典型的并发问题?请举例说明。** [^1]: Volatile通过内存屏障保证可见性并禁止重排序。 [^2]: 单例模式中volatile防止拿到半初始化对象。 [^3]: volatile通过CPU指令实现"屏障效应",阻止重排序越过内存屏障。 [^4]: volatile用于并发场景(如Atomic类)确保内存可见性。 [^5]: 指令重排可能导致多线程出现违反直觉的结果。
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值