在尝试理解 volatile 前,非常建议先了解内存模型(应该是一般意义上的编程者与底层 CPU 和编译器的内存模型,而非局限于 Java Memory Model 或者 C++ Memory Model 这样特定于语言的模型)。JMM
what is volatile?#
我们已知,CPU 和编译器能够保证线程内部的有序性,可见性。但是对于线程间的操作,为了追求性能,CPU 和编译器没有足够的信息来保证有序性。因此 CPU 为上层应用提供了一个内存屏障的指令。经过 JMM 封装之后,就是 volatile 。
what we have?#
如果我们用 A,B,C,D,E,F 代表对不同内存的操作。
对于线程 1,存在这样的操作序列:A1->B1->C
。
对于线程 2,存在这样的操作序列:D->B2->A2
。
当进行并发时,从时间顺序上,因为线程内指令重排的存在。
对于冲突的指令 (A1,A2) 与 (B1,B2)。
可能出现下面的执行顺序
A1 -> B1 -> B2 -> A2
A1 -> B1 -> A2 -> B2
A1 -> B2 -> A2 -> B1
A1 -> B2 -> B1 -> A2
A1 -> A2 -> B2 -> B1
A1 -> A2 -> B1 -> B2
B1 -> A1 -> B2 -> A2
B1 -> A1 -> A2 -> B2
B1 -> A2 -> A1 -> B2
B1 -> A2 -> B2 -> A1
B1 -> B2 -> A2 -> A1
B1 -> B2 -> A1 -> A2
A2 -> A1 -> B2 -> B1
A2 -> A1 -> B1 -> B2
A2 -> B1 -> A1 -> B2
A2 -> B1 -> B2 -> A1
A2 -> B2 -> B1 -> A1
A2 -> B2 -> A1 -> B1
B2 -> A1 -> B1 -> A2
B2 -> A1 -> A2 -> B1
B2 -> A2 -> A1 -> B1
B2 -> A2 -> B1 -> A1
B2 -> B1 -> A2 -> A1
B2 -> B1 -> A1 -> A2
在线程的局部视角来看,这种重排序是合理的,因为 A B 之间没有冲突 / 依赖关系。
但是从全局视角来看,这种重排序会导致错误的。
比如这样的操作:
public class A {
int x = 0;
int y = 0;
void thread1() {
y = 1; // A1
int ans1 = x; // B1
}
void thread2() {
x = 1; // B2
int ans2 = y; // A2
}
}
对于执行序列 B1, A2, A1, B2
最终会得到 ans1 = 0, ans2 = 0
的结果。
这显然是意料之外的结果。
防止重排#
因此,需要一种方式来防止这种重排序,这是 volatile 的第一个语义。
public class A {
volatile int x = 0;
int y = 0;
void thread1() {
y = 1; // A1
int ans1 = x; // B1
}
void thread2() {
x = 1; // B2
int ans2 = y; // A2
}
}
在为x
加上 volatile
之后,假设时间上,B1 先于 B2 发生,那么应该可以保证,A1 先于 A2 发生,也就是 A1 的操作结果对 thread2 是可见的。
cache coherence 缓存一致性?#
另外一方面,如果我们说,B1 先于 B2 发生
进行B1
操作之后,内存B
变为 B'
,即 B + OP(B1) -> B'
这时候再进行 B2 操作,我们是这样的: B' + OP(B2) -> B''
。
但是如果线程 2 看到的内存仍然是 B
呢?,执行的操作是:B + OP(B2) -> B'''。 「**如果线程2 看到的内存仍然是
B` 呢?**」这是可能的吗?
在我查看的大部分关于 volatile 的博客、问题、文档中,很大一部分都会提到。
线程 1 和线程 2 的内存视图不一致。线程共有一份主内存,每个线程都有自己的工作内存(Working Memory),因此主内存视图不一样。
但是当我开始寻找这段描述的解释时,我看到包括但不限于
- 因为 CPU 内部缓存的存在,每个 CPU 核心看到的内存视图不一样。
- 即使有 MESI 这样的硬件缓存一致性保证,因为 invalid-queue 机制的存在,所以线程看到的内存视图不一样。
- 因为 MESI 这样的硬件缓存一致性保证,所以线程看到的内存视图是一样的。
- 尽管有 MESI 这样的硬件缓存一致性协议保证,但是由于 Java Thread 存在线程栈,线程栈中可能存在堆中的缓存。因此线程看到的内存视图是不一样的。
为了理解这个问题的答案,我浏览了很多页面,关于缓存一致性,内存连贯性等等。
大多数回答会指向 1,2。也就是即使能够在执行顺序上防止优化,按照顺序思维进行,线程 2 看到的内存仍然可能是B
而非B'
。线程看到的内存是不一致的,是不及时的。这是令人沮丧的:-(。
因为从分层的原则来说,硬件缓存应该是 Transparent
,透明的。但是 1,2 所述的显然破坏了这种透明性,cpu 硬件缓存为上层应用引入了新的复杂性。
另外一部分答案则持有 MESI 会保持缓存一致性,物理线程会看到一样的内存视图。但是可能因为 JVM 线程 Stack 缓存的存在,使得 JVM 上的线程看到的内存是不一致的。
由于目前对底层原理的有限认知,我仍然无法得出具体有效的结论。volatile 是否会影响同一时刻不同线程的内存视图,我知道使用 volatile 的变量可以保证这样的内存视图,但是不知道的是,没有 volatile 关键字,在不考虑重排序的情况下,CPU 是否会因为不可见性。
下面仅给出我所阅读的一些参考内容,供后续继续深入学习使用。
cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html
caching - does volatile keword in java really have to do with caches? - Stack Overflow
multithreading - Understanding Java volatile visibility - Stack Overflow
java - The Volatile Keyword and CPU Cache Coherence Protocol - Stack Overflow
So why do we need a volatile keyword ? (strogiyotec.github.io)
caching - Java volatile and cache coherency. Am I missing something? - Stack Overflow
The Java memory model explained, Rafael Winterhalter (youtube.com)
Java Memory Model Pragmatics (transcript) (shipilev.net)
Chapter 17. Threads and Locks (oracle.com)
long/double 更新#
对于非 volatile 的 long/double 字段的写入,JMM 并没有严格要求其操作的原子性。因此 JVM 实现可能是分两次进行的写入(一次高 32bit,一次低 32bit)。
这是 volatile 的第三个语义,保证 long/double 写入的原子性。
volatile 是必须的吗?#
看了这么多似乎 volatile 十分重要,必不可少。但是当把视角放宽到其他编程语言中时,难免会感到诧异。在 golang, python 等一众语言中,并没有强调 volatile 的概念。
why?volatile 只是 java 提供的一个轻量级的 API,是对内存屏障的一种简单封装,以保证变量的可见性和有序性。
python(CPython) 中因为 GIL(Global Interpreter Lock)的存在,可以保证同一时间只有一个线程在 CPU 上执行,从而保证可见性,同时有其他 api(锁)来保证原子性和有序性。python 也出现过要求添加并发模型的提案,PEP 583 – A Concurrency Memory Model for Python | peps.python.org
python 可以用一把 GIL 阻止并行实现可见性,但是 golang 中是运行并行的。同样存在内存的可见性问题,Golang 是如何解决的呢?
golang 推荐 communication 而非 shared memory,因此并不提供 volatile 这样直接作用于变量的 API,而是对 channel 这样的通信方式做出了保证,给出了内存模型。
回到 volatile 的本质,volatile 只是 java/c/cpp 等语言提供的一个轻量级的 API,通过这样的 API 可以保证一些场景的有序、可见、原子性。用其他方式也可以实现同样的目标,重量一点的锁,golang 中的 channel。本质都是在使用这样的 api 时,适时的插入内存屏障,保证正确性。