文章目录
- Volatile原理
- 1.Volatile语义中的内存屏障
- 1.1.volatile写操作的内存屏障
- 1.1.1.StoreStore 屏障
- 1.1.2.StoreLoad 屏障
- 1.2.volatile读操作的内存屏障
- 1.2.1.LoadStore屏障
- 1.2.2.LoadLoad屏障
- 2.volatile不具备原子性
- 2.1.原理
Volatile原理
1.Volatile语义中的内存屏障
在Java代码中,volatile关键字主要又两层语义
- 不同线程对volatile变量的值具有内存可见性,就是一个线程修改了某个volatile变量的值,该值对其他线程立即可见。
- 禁止指令重排序
同时volatile关键字不仅能保证可见性,还能保证有序性,保证有序性是通过内存屏障指令来确保的。
JVM编译器会在生成字节码文件时,会在指令序列中插入内存屏障来禁止特定类型的CPU重排序。
JVM在处理volatile
关键字修饰的变量时,会采取保守策略来确保内存可见性和有序性,这涉及到内存屏障(Memory Barrier)的使用。内存屏障是一种硬件层面的指令,用于确保某些内存访问操作的执行顺序,防止CPU的乱序执行对并发程序的正确性产生影响。对于volatile
变量的读写,JVM会分别在读和写操作前后插入适当类型的内存屏障,确保以下几点:
- 全局可见性: 保证对
volatile
变量的写操作能立即对其他线程可见,即使在不同的CPU缓存中也是如此。这意味着写操作之后,修改的值会立刻刷出到主内存中。 - 禁止重排序: 防止编译器和CPU对涉及
volatile
变量的代码进行不必要的重排序,确保它们按照程序员指定的顺序执行。这对于依赖于特定顺序的并发控制逻辑至关重要。
基于保守策略的volatile
操作内存屏障插入策略主要包括以下方面:
- 写屏障(Store Memory Barrier): 在写入
volatile
变量之后插入。它的作用是确保在该屏障之前的所有普通写操作(非volatile
)都已完成,并且将当前线程的工作内存中的volatile
变量值刷新到主内存中。这样,任何读取该volatile
变量的线程都能看到最新值。- 在每个volatile
写
操作前
面插入一个StoreStore
屏障 - 在每个volatile
写
操作后
面插入一个StoreLoad
屏障
- 在每个volatile
- 读屏障(Load Memory Barrier): 在读取
volatile
变量之前插入。它的作用是确保读取操作之后的加载不会被重排序到该屏障之前,并且使CPU读取主内存中的最新值,而不是使用缓存中的旧值,从而确保读取到的是最近一次写入的值,无论这个写入操作发生在哪个线程中。- 在每个volatile
读
操作前
面插入一个LoadLoad
屏障 - 在每个volatile
读
操作后
面插入一个LoadStore
屏障
- 在每个volatile
这些屏障的联合使用确保了对volatile
变量的读写操作具有原子性和全局有序性,尽管它们不保证复合操作(如count++
)的原子性。这就是为什么即使在没有锁的情况下,volatile
也能作为轻量级的同步机制,用于状态标记、双重检查锁定模式等场景。
1.1.volatile写操作的内存屏障
volatile写操作的内存屏障插入策略为:在每个
volatile
写操作前插入SotreStore(SS)
屏障,在写操作之后加上StoreLoad
屏障
1.1.1.StoreStore 屏障
定义与作用: StoreStore
屏障主要用于确保一个存储(写)操作在另一个存储操作之前完成。换句话说,它强制所有在该屏障之前的存储操作在该屏障之后的存储操作之前完成。这种屏障通常用于避免写-写冲突导致的数据不一致问题,尤其是在处理器有乱序执行能力的体系结构中。
- 前面的写入不会重排序到后面
- 前面的写指令完成后,高速缓存数据刷入主存
- 后面的写操作不会排序到前面
应用场景: 例如,在实现某些类型的锁释放操作时,可能需要确保解锁操作前的所有写操作已经完成,以免新获得锁的线程看到不一致的状态。
1.1.2.StoreLoad 屏障
定义与作用: StoreLoad
屏障是Java内存模型中最强大的一种屏障,它确保在屏障之前的所有写操作(存储操作)在屏障之后的任何读操作(加载操作)之前完成。这意味着不仅要求写操作完成,而且要确保这些写操作对所有线程可见。因此,StoreLoad
屏障通常用于实现volatile
变量的写操作后,以确保写入的值对其他线程立即可见。
- 前面的写不会重排序到后面
- 前面的写指令操作完成后,高速缓存数据立即刷入主存
- 让高速缓存的数据失效,重新从主存中加载数据,保证内核的高速缓存数据一致
- 后面的读操作不会重排序到前面
应用场景: StoreLoad
屏障直接关联于Java中volatile
字段的写操作实现。当一个线程修改了一个volatile
变量的值,JVM会在写操作之后插入一个StoreLoad
屏障,以确保该写入的值能够立即对其他线程可见,同时刷新处理器的缓存,避免数据的脏读。此外,它也常用于锁释放操作后的内存可见性保障,确保解锁前的内存更改对后续可能获得锁的线程是可见的。
总结来说,StoreStore
屏障关注于维持存储操作之间的顺序,而StoreLoad
屏障则进一步确保了写操作的全局可见性,并在写-读操作间建立了一个顺序关系,这对于维护多线程程序的一致性和正确性至关重要。
1.2.volatile读操作的内存屏障
volatile读操作的内存屏障插入策略为:在每个volatile读操作后面插入
LoadLoad(LL)
屏障和LoadStore
屏障,禁止后面的普通读、普通写、和前面的volatile读操作发生重排序
1.2.1.LoadStore屏障
定义与功能: LoadStore
屏障,也称为读写屏障,其主要作用是确保屏障之后的读操作不会被重排序到屏障之前,且屏障之后的写操作不会被重排序到屏障之前的读操作之前。这意味着它不仅确保了读操作不会提前,还阻止了读之后的写操作与读操作之前的任何写操作发生乱序。这在volatile读操作的上下文中,意味着确保了读取到的volatile变量的值不会被之后的写操作所覆盖或影响,保持了读取操作的确定性。
- 前面的读操作不会排到后面
- 让高速缓存中的数据失效,重新从主存中加载数据
- 后面的写操作不会排列到前面
在volatile读操作中的应用: 当执行volatile读操作时,Java虚拟机(JVM)会在读取操作之后插入一个LoadStore
屏障。这个屏障的目的是确保当前线程的任何后续写操作不会与刚完成的volatile读操作交错,保证了volatile读的值不会因为之后的写而变得无效或不一致。同时,这也间接帮助确保了volatile读取操作后的写操作不会与之前的volatile读操作或普通读操作发生冲突,维护了操作的顺序性。
1.2.2.LoadLoad屏障
定义与功能: LoadLoad
屏障,或称为读读屏障,它的主要职责是防止屏障之后的读操作被重排序到屏障之前的任何读操作之前。这意味着它确保了在屏障之后执行的任何读操作不会因为CPU的乱序执行优化而提前到屏障之前执行。尽管LoadLoad
屏障本身在某些JMM的描述中不常直接提及,但讨论内存屏障时,其概念往往隐含在维护读操作顺序性的讨论中。
- 前面的读操作不会被排到后面
- 让高速缓存中的数据失效,重新从主存中加载数据
- 后面读操作不会排列到前面
在volatile读操作中的应用: 虽然直接提及LoadLoad
屏障在volatile读操作后插入的情况较少见,通常强调的是LoadStore
和StoreLoad
屏障的作用,但理解其概念对于全面把握内存屏障如何维护顺序性是有帮助的。在volatile读操作的上下文中,可以抽象理解为,屏障的逻辑效果确保了读取volatile变量的值不会被之后的其他读取操作提前,保证了读取volatile变量的顺序性。不过,实际中,volatile读操作的关键在于通过StoreLoad
屏障确保了对其他线程写入volatile变量的值立即可见,同时防止了volatile读与普通读写操作的不恰当重排序。
2.volatile不具备原子性
volatile能保证数据的可见性,但是volatile并不能完全保证数据的原子性。对于volatile类型的变量进行符合操作例如(i++),仍然会存在线程不安全的问题
/**
* 使用 10个线程,每个线程进行1000次 ++操作,来观察成员变量的结果是否符合我们的预期
*/
public class VolatileAddDemo {
private volatile int num = 0;
@Test
@DisplayName("测试并发情况下 volatile原子性")
public void testVolatileAdd() {
CountDownLatch latch = new CountDownLatch(10);
ExecutorService executorService = Executors.newFixedThreadPool(10);
for (int i = 0; i < 10; i++) {
executorService.submit(() -> {
for (int j = 0; j < 1000; j++) {
num++;
}
// 每次执行完毕 -1
latch.countDown();
});
}
// 等待全部线程执行完毕
try {
latch.await();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("最终的结果:" + num);
System.out.println("预期的结果:10000");
System.out.println("相差:" + (10000 - num));
}
}
2.1.原理
首先来看一下JMM对变量进行读取和写入的操作流程
对于非volatile修饰的普通变量来说,在读取变量的时候,JMM要求需要 报纸read
、load
的顺序即可
但是,从主存中读取 x 、y 两个变量的值,可能的操作是 read x
-> read y
-> load y
-> load x
,它并不要求操作是连续的
对于关键字 volatile修饰的内存可见变量而言,具有两个重要的语义
- 使用volatile修饰的变量在变量值发生改变时,会
立即同步到主存
,并且让其他线程的变量副本失效
- 禁止指令重排序:用volatile修饰的变量在硬件层面上会通过在指令前后加入内存屏障来实现,编译器通过以下规则来进行实现的。
- 使用volatile修饰的变量 **read(读取),load(加载),use(使用)**都是连续出现的,所以每次使用变量时都要从主存读取最新的变量值,替换私有内存的变量副本值
- 对于同一个变量的**assign(赋值),store(存储),write(主存)**操作都是连续出现,所以每次对变量的修改都会立即同步到主存中
?但是思考一下,单线程下**( read,load,use),(assign,store,write)**同时出现没什么问题,但是在多线程并发执行的情况下,因为单个操作具备原子性,但是多个组合的话就不具备原子性了,还是有可能会出现脏数据。
下面通过图来了解一下 并发时可能发生产生脏数据的场景
对于复合操作,volatile变量是无法保证其原子性的,如果想要保证复合操作的原子性,那么就需要使用锁,并在在高并发场景下,volatile变量一定要和Java显示锁结合使用
这里补充介绍一下 JMM内存模型的 8个 操作
操作 | 描述 | 作用的对象 |
---|---|---|
read | 读取 | 把一个变量的值从主内存或高速缓存读到线程的工作内存中,准备下一步的load操作 |
load | 加载入 | 把read操作从主内存读取的变量值放入线程的工作内存中的变量副本中,此时变量才对线程可见 |
use | 使用 | 把工作内存中变量的值传递给执行引擎,作为运算的输入 |
assign | 赋值 | 把执行引擎计算出的结果赋值给工作内存中的变量 |
store | 存储 | 把工作内存中修改后的变量值写回到主内存中 |
write | 写出 | 把store操作从工作内存中变量的值写入到主内存,使得其他线程可见 |
lock | 加锁 | 作用于主内存的变量,标记变量为线程独占,确保同一时刻只有一个线程能执行lock和unlock之间的操作 |
unlock | 解锁 | 释放锁,作用于主内存的变量,允许其他线程获取该变量的锁并进行操作 |