volatile 的作用
- 保证变量的内存可见性
- 禁止指令重排序
1.保证此变量对所有的线程的可见性,当一个线程修改了这个变量的值,volatile 保证了新值能立即同步到主内存,其它线程每次使用前立即从主内存刷新。 但普通变量做不到这点,普通变量的值在线程间传递均需要通过主内存来完成。
2.禁止指令重排序优化。有volatile修饰的变量,赋值后多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置)。
可见性
在理解 volatile 的内存可见性前,我们先来看看这个比较常见的多线程访问共享变量的例子。
/*** 变量的内存可见性例子*/
public class VolatileExample {/*** main 方法作为一个主线程*/public static void main(String[] args) {MyThread myThread = new MyThread();// 开启线程myThread.start();// 主线程执行for (; ; ) {if (myThread.isFlag()) {System.out.println("主线程访问到 flag 变量");}}}}/*** 子线程类*/
class MyThread extends Thread {private boolean flag = false;@Overridepublic void run() {try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}// 修改变量值flag = true;System.out.println("flag = " + flag);}public boolean isFlag() {return flag;}public void setFlag(boolean flag) {this.flag = flag;}
}
执行上面的程序,你会发现,控制台永远都不会输出 “主线程访问到 flag 变量” 这句话。我们可以看到,子线程执行时已经将 flag 设置成 true,但主线程执行时没有读到 flag 的最新值,导致控制台没有输出上面的句子。
那么,我们思考一下为什么会出现这种情况呢?这里我们就要了解一下 Java 内存模型(简称 JMM)。
Java 内存模型
JMM(Java Memory Model):Java 内存模型,是 Java 虚拟机规范中所定义的一种内存模型,Java 内存模型是标准化的,屏蔽掉了底层不同计算机的区别。也就是说,JMM 是 JVM 中定义的一种并发编程的底层模型机制。
JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。
JMM 的规定:
- 所有的共享变量都存储于主内存。这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
- 每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
- 线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。
- 不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。
JMM 的抽象示意图:
然而,JMM 这样的规定可能会导致线程对共享变量的修改没有即时更新到主内存,或者线程没能够即时将共享变量的最新值同步到工作内存中,从而使得线程在使用共享变量的值时,该值并不是最新的。
正因为 JMM 这样的机制,就出现了可见性问题。也就是我们上面那个例子出现的问题。
那我们要如何解决可见性问题呢?接下来我们就聊聊内存可见性以及可见性问题的解决方案。
内存可见性
内存可见性是指当一个线程修改了某个变量的值,其它线程总是能知道这个变量变化。也就是说,如果线程 A 修改了共享变量 V 的值,那么线程 B 在使用 V 的值时,能立即读到 V 的最新值。
可见性问题的解决方案
我们如何保证多线程下共享变量的可见性呢?也就是当一个线程修改了某个值后,对其他线程是可见的。
这里有两种方案:加锁 和 使用 volatile 关键字。
下面我们使用这两个方案对上面的例子进行改造。
加锁
使用 synchronizer 进行加锁。
/*** main 方法作为一个主线程*/public static void main(String[] args) {MyThread myThread = new MyThread();// 开启线程myThread.start();// 主线程执行for (; ; ) {synchronized (myThread) {if (myThread.isFlag()) {System.out.println("主线程访问到 flag 变量");}}}}
这里大家应该有个疑问是,为什么加锁后就保证了变量的内存可见性了? 因为当一个线程进入 synchronizer 代码块后,线程获取到锁,会清空本地内存,然后从主内存中拷贝共享变量的最新值到本地内存作为副本,执行代码,又将修改后的副本值刷新到主内存中,最后线程释放锁。
这里除了 synchronizer 外,其它锁也能保证变量的内存可见性。
使用 volatile 关键字
使用 volatile 关键字修饰共享变量。
/*** 子线程类*/
class MyThread extends Thread {private volatile boolean flag = false;@Overridepublic void run() {try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}// 修改变量值flag = true;System.out.println("flag = " + flag);}public boolean isFlag() {return flag;}public void setFlag(boolean flag) {this.flag = flag;}
}
使用 volatile 修饰共享变量后,每个线程要操作变量时会从主内存中将变量拷贝到本地内存作为副本,当线程操作变量副本并写回主内存后,会通过 CPU 总线嗅探机制 告知其他线程该变量副本已经失效,需要重新从主内存中读取。
volatile 保证了不同线程对共享变量操作的可见性,也就是说一个线程修改了 volatile 修饰的变量,当修改后的变量写回主内存时,其他线程能立即看到最新值。
接下来我们就聊聊一个比较底层的知识点:总线嗅探机制
。
总线嗅探机制
在现代计算机中,CPU 的速度是极高的,如果 CPU 需要存取数据时都直接与内存打交道,在存取过程中,CPU 将一直空闲,这是一种极大的浪费,所以,为了提高处理速度,CPU 不直接和内存进行通信,而是在 CPU 与内存之间加入很多寄存器,多级缓存,它们比内存的存取速度高得多,这样就解决了 CPU 运算速度和内存读取速度不一致问题。
由于 CPU 与内存之间加入了缓存,在进行数据操作时,先将数据从内存拷贝到缓存中,CPU 直接操作的是缓存中的数据。但在多处理器下,将可能导致各自的缓存数据不一致(这也是可见性问题的由来),为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,而嗅探是实现缓存一致性的常见机制。
注意,缓存的一致性问题,不是多处理器导致,而是多缓存导致的。
嗅探机制工作原理:每个处理器通过监听在总线上传播的数据来检查自己的缓存值是不是过期了,如果处理器发现自己缓存行对应的内存地址修改,就会将当前处理器的缓存行设置无效状态,当处理器对这个数据进行修改操作的时候,会重新从主内存中把数据读到处理器缓存中。
注意:基于 CPU 缓存一致性协议,JVM 实现了 volatile 的可见性,但由于总线嗅探机制,会不断的监听总线,如果大量使用 volatile 会引起总线风暴。所以,volatile 的使用要适合具体场景。
可见性问题小结
上面的例子中,我们看到,使用 volatile 和 synchronized 锁都可以保证共享变量的可见性。相比 synchronized 而言,volatile 可以看作是一个轻量级锁,所以使用 volatile 的成本更低,因为它不会引起线程上下文的切换和调度。但 volatile 无法像 synchronized 一样保证操作的原子性。
下面我们来聊聊 volatile 的原子性问题。
原子性
所谓的原子性是指在一次操作或者多次操作中,要么所有的操作全部都得到了执行并且不会受到任何因素的干扰而中断,要么所有的操作都不执行。
在多线程环境下,volatile 关键字可以保证共享数据的可见性,但是并不能保证对数据操作的原子性。也就是说,多线程环境下,使用 volatile 修饰的变量是线程不安全的。
要解决这个问题,我们可以使用锁机制,或者使用原子类(如 AtomicInteger)。
这里特别说一下,对任意单个使用 volatile 修饰的变量的读 / 写是具有原子性,但类似于 flag = !flag
这种复合操作不具有原子性。简单地说就是,单纯的赋值操作是原子性的。
禁止指令重排序
什么是重排序?
有序性:即程序执行的顺序按照代码的先后顺序执行。
一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
从 Java 源代码到最终执行的指令序列,会分别经历下面三种重排序:
虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?靠的是数据依赖性:
编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
举例如下代码
double pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C
上面三个操作的数据依赖关系如下图所示
A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。下图是该程序的两种执行顺序:
在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能的开发并行度。编译器和处理器都遵从这一目标。
这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果;但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。这是就需要内存屏障来保证可见性了。
为了更好地理解重排序,请看下面的部分示例代码:
int a = 0;
flag = false;// 线程 A
a = 1; // 1
flag = true; // 2// 线程 B
if (flag) { // 3int i = a; // 4
}
单看上面的程序好像没有问题,最后 i 的值是 1。但是为了提高性能,编译器和处理器常常会在不改变数据依赖的情况下对指令做重排序(线程A中的a = 1;
和flag = true
没有依赖关系)。假设线程 A 在执行时被重排序成先执行代码 2,再执行代码 1;而线程 B 在线程 A 执行完代码 2 后,读取了 flag 变量。由于条件判断为真,线程 B 将读取变量 a。此时,变量 a 还根本没有被线程 A 写入,那么 i 最后的值是 0,导致执行结果不正确。那么如何程序执行结果正确呢?这里仍然可以使用 volatile 关键字。
这个例子中, 使用 volatile 不仅保证了变量的内存可见性,还禁止了指令的重排序,即保证了 volatile 修饰的变量编译后的顺序与程序的执行顺序一样。那么使用 volatile 修饰 flag 变量后,在线程 A 中,保证了代码 1 的执行顺序一定在代码 2 之前。
那么,让我们继续往下探索, volatile 是如何禁止指令重排序的呢?这里我们将引出一个概念:内存屏障指令
内存屏障指令
java编译器会在生成指令系列时在适当的位置会插入内存屏障
指令来禁止特定类型的处理器重排序。
内存屏障有两个作用:
1.阻止屏障两侧的指令重排序;
2.强制写入缓存中的最新数据更新写入主内存,让其他线程可见;让高速缓存中的数据失效,强制从新从主内存加载数据。
内存屏障分为两种:Store Barrier 和 Load Barrier 即 写屏障和读屏障。
-
写屏障(Store Barrier):
- 阻止屏障两侧的指令重排序。
- 清空CPU的Store Buffer,将修改刷入主存,并触发缓存一致性协议(如MESI)广播
Invalid
信号,使其他核心的缓存行失效。
读屏障(Load Barrier):
- 阻止屏障两侧的指令重排序。
- 强制CPU处理Invalidate Queue中的失效请求,若缓存行状态为
Invalid
,则从主存重新加载数据。
补充:
Store Buffer(存储缓冲区):位于 CPU 核心与 L1 缓存之间,优化写操作性能。
Invalidate Queue(失效队列):位于 L1 缓存与总线嗅探单元之间,加速对失效请求的响应。
java的内存屏障通常所谓的四种即StoreStore,StoreLoad,LoadLoad,LoadStore,实际上也是上述两种的组合,完成一系列的屏障和数据同步功能。
屏障类型 | 核心作用 | 防止的重排序 |
---|---|---|
StoreStore | 保证普通写优先完成 | 写-写重排序 |
StoreLoad | 确保写操作全局可见(全能屏障) | 写-读重排序 |
LoadLoad | 防止读操作重排序 | 读-读重排序 |
LoadStore | 防止读-写重排序 | 读-写重排序 |
下面我们来看看 volatile 读 / 写时是如何插入内存屏障的,见下图:
**
**
从上图,我们可以知道 volatile 读 / 写插入内存屏障规则:
-
在每个 volatile 写操作的前后分别插入一个 StoreStore 屏障和一个 StoreLoad 屏障。
作用:禁止普通写与
volatile
写重排序;强制刷新写缓冲区,触发MESI广播失效信号。
-
在每个 volatile 读操作的后面插入 LoadLoad 屏障和 LoadStore 屏障。
作用:处理失效队列,强制加载最新值;禁止后续普通写与
volatile
读重排序。
缓存一致性协议&内存屏障
原理总结:
- 可见性:
通过内存屏障(强制刷缓存/失效缓存)+ MESI 协议(同步缓存状态)实现
。 - 有序性:
通过内存屏障(禁止重排序)约束指令执行顺序
。
二者共同确保 volatile
变量的“可见性”与“有序性”,但不保证原子性(如 i++
需配合锁或原子类)。
1. 缓存一致性协议(MESI)作用
- 数据一致性:通过总线嗅探和状态机(Modified/Exclusive/Shared/Invalid)保证多核缓存数据一致。
- 失效触发:
- 核心A修改共享数据时,通过总线广播Invalid信号,使其他核心的缓存行失效(状态置为
Invalid
) - 其他核心读取失效数据时,强制从主存重新加载最新值
- 核心A修改共享数据时,通过总线广播Invalid信号,使其他核心的缓存行失效(状态置为
2. 内存屏障作用
(1) 禁止重排序:确保操作有序性
指令重排序是编译器和CPU为优化性能对指令执行顺序的调整,单线程安全但多线程会导致逻辑错误。通过插入屏障指令,防止编译器和CPU对指令乱序执行。
(2) 强制同步内存:确保可见性与一致性
内存屏障通过强制刷新缓存状态,解决多核CPU因私有缓存(Store Buffer/Invalidate Queue)导致的数据不一致问题:
刷新写缓冲区(强制写操作全局可见)
- 机制:
- 写屏障(如
StoreStore
/StoreLoad
)清空CPU的Store Buffer,将修改刷入主存,并触发缓存一致性协议(如MESI)广播Invalid
信号,使其他核心的缓存行失效。 - 示例:
volatile
写后插入StoreLoad
屏障,强制写缓冲区数据刷入主存,确保其他线程立即可见。
- 写屏障(如
处理失效队列(强制读操作加载最新值)
- 机制:
- 读屏障(如
LoadLoad
/LoadStore
)强制CPU处理Invalidate Queue中的失效请求,若缓存行状态为Invalid
,则从主存重新加载数据。 - 示例:
volatile
读前插入LoadLoad
屏障,确保读取前处理完所有失效请求,避免读到旧缓存。
- 读屏障(如
(3) 与缓存一致性协议(MESI)的协同
- MESI协议角色:
通过缓存行状态(Modified/Exclusive/Shared/Invalid)管理多核数据一致性,但无法主动保证顺序性。 - 屏障与协议联动:
- 写屏障触发MESI广播
Invalid
信号,使其他核心缓存失效; - 读屏障确保失效请求被处理,依赖MESI状态(如Invalid)重新加载数据。
- 写屏障触发MESI广播
happens-before
什么是happens-before?
一方面,程序员需要JMM提供一个强的内存模型来编写代码;另一方面,编译器和处理器希望JMM对它们的束缚越少越好,这样它们就可以最可能多的做优化来提高性能,希望的是一个弱的内存模型。
JMM考虑了这两种需求,并且找到了平衡点,对编译器和处理器来说,只要不改变程序的执行结果(单线程程序和正确同步了的多线程程序),编译器和处理器怎么优化都行。
而对于程序员,JMM提供了happens-before规则(JSR-133规范),满足了程序员的需求——简单易懂,并且提供了足够强的内存可见性保证。 换言之,程序员只要遵循happens-before规则,那他写的程序就能保证在JMM中具有强的内存可见性。
JMM使用happens-before的概念来定制两个操作之间的执行顺序。这两个操作可以在一个线程以内,也可以是不同的线程之间。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证。
happens-before关系的定义如下:
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见(保障可见性),而且第一个操作的执行顺序排在第二个操作之前(JMM对程序员做出的逻辑保障,并不是代码指令真正的执行保障)。
- 即使两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么JMM也允许这样的重排序。
因此,
第一条是JMM对程序员做出的逻辑保障;
第二条是JMM对编译器、处理器进行重排序的约束原则:只有不改变程序的执行结果(不管是单线程还是多线程),编译器、处理器怎么优化都可以。
happens-before关系本质上和as-if-serial语义是一回事。
as-if-serial语义保证单线程内重排序后的执行结果和程序代码本身应有的结果是一致的,happens-before关系保证正确同步的多线程程序的执行结果不被重排序改变。
总之,如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可见的,不管它们在不在一个线程。
天然的happens-before关系
在Java中,有以下天然的happens-before关系:
- 程序顺序规则:在一个线程内一段代码的执行结果是有序的。就是还会指令重排,但是随便它怎么排,结果是按照我们代码的顺序生成的不会变。
- 监视器锁规则:就是无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果。
- volatile 变量规则:就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见。
- 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
- start() 规则:在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。
- join() 规则:在主线程A执行过程中,子线程B终止,那么线程B在终止之前对共享变量的修改结果在线程A中可见。也称线程join()规则。
从 happens-before 的 volatile 变量规则可知,如果线程 A 写入了 volatile 修饰的变量 V,接着线程 B 读取了变量 V,那么,线程 A 写入变量 V 及之前的写操作都对线程 B 可见。
这里特别说明一下,happens-before 规则不是描述实际操作的先后顺序,它是用来描述可见性的一种规则
(前一个操作的结果对后续操作是可见的)。