一、volatile 的内存语义
1.1 volatile 的特性
理解 volatile 特性的一个好办法是把对 volatile 变量的单个读 / 写,看成是使用同一个锁对这些单个读 / 写操作做了同步。下面通过具体的示例来说明,示例代码如下:
- class VolatileFeaturesExample {
- volatile Long vl = 0L; //使用volatile声明64位的Long型变量
- public void set(Long l) {
- vl = l; //单个volatile变量的写
- }
- public void getAndIncrement() {
- vl++; //复合(多个)volatile变量的读/写
- }
- public Long get() {
- return vl; //单个volatile变量的读
- }
- }
假设有多个线程分别调用上面程序的 3 个方法,这个程序在语义上和下面程序等价。
- class VolatileFeaturesExample {
- Long vl = 0L; //64位的Long型普通变量
- public synchronized void set(Long l) { //对单个普通变量的写用同一个锁同步
- vl = l;
- }
- public void getAndIncrement() { //普通方法调用
- Long temp = get(); //调用已同步的读方法
- temp += 1L; //普通写操作
- set(temp); //调用已同步的写方法
- }
- public synchronized Long get() { //对单个的普通变量的读用同一个锁同步
- return vl;
- }
- }
如上面示例程序所示,一个 volatile 变量的单个读 / 写操作,与一个普通变量的读 / 写操作都是使用同一个锁来同步,他们之间的执行效果相同。
锁的 happens-before 规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个 volatile 变量的读,总是能看到 (任意线程) 对这个 volatile 变量最后的写入。
锁的语义决定了临界区代码的执行具有原子性。这意味着,即使是 64 位的 Long 型和 double 型变量,只要它是 volatile 变量,对该变量的读 / 写就具有原子性。如果是多个 volatile 操作或类似于 volatile++ 这种复合操作,这些操作整体上不具有原子性。
简而言之,volatile 变量自身具有下列特性:
可见性:对一个 volatile 变量的读,总是能看到 (任意线程) 对这个 volatile 变量最后的写入。
原子性:对任意单个 volatile 变量的读 / 写具有原子性,但类似于 volatile++ 这种复合操作不具有原子性。
1.2 volatile 写 - 读建立的 happens-before 关系
上面说的是 volatile 自身的特性,对程序员来说,volatile 对线程的内存可见性的影响比 volatile 自身的特性更为重要,也更需要我们去关注。
从 JSR-133 开始 (即从 JDK 5 开始),volatile 变量的写 - 读可以实现线程之间的通信。
从内存语义的角度来说,volatile 的写 - 读与锁的释放 - 获取有相同的内存效果:volatile 写和锁的释放有相同的内存语义;volatile 读与锁的获取有相同的内存语义。
使用 volatile 变量的示例代码:
- class VolatileExample {
- int a = 0;
- volatile boolean flag = false;
- public void writer() {
- a = 1; //1
- flag = true; //2
- }
- public void reader() {
- if (flag) { //3
- int i = a * a; //4
- }
- }
- }
假设线程 A 执行 writer() 方法之后,线程 B 执行 reader() 方法。根据 happens-before 规则,这个过程建立的 happens-before 关系可以分为 3 类:
1、根据程序次序规则,1 happens-before2,3 happens-before 4。
2、根据 volatile 规则,2 happens-before 3。
3、根据 happens-before 传递性规则,1 happens-before 4。
上述 happens-before 关系的图形化表现形式如下:
这里 A 线程写一个 volatile 变量后,B 线程读同一个 volatile 变量。A 线程在写 volatile 变量之前所有可见的共享变量,在 B 线程读同一个 volatile 变量后,将立即变得对 B 线程可见。
1.3 volatile 写 - 读的内存语义
当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存。
以上面示例程序 VolatileExample 为例,假设线程 A 首先执行 writer() 方法,随后线程 B 执行 reader() 方法,初始时两个线程的本地内存中的 flag 和 a 都是初始状态。下图是线程 A 执行 volatile 写后,共享变量的状态示意图:
线程 A 在写 flag 变量后,本地内存 A 中被线程 A 更新过的两个共享变量的值被刷新到主内存中。此时本地内存 A 和主内存中的共享变量的值是一致的。
volatile 读的内存语义如下:
当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
下图是线程 B 读同一个 volatile 变量后,共享变量的状态示意图:
如图所示,在读 flag 变量后,本地内存 B 包含的值已经被置为无效。此时,线程 B 必须从主内存中读取共享变量。线程 B 的读取操作将导致本地内存 B 与主内存中的共享变量的值编程一致。
如果我们把 volatile 写和 volatile 读两个步骤综合起来看的话,在读线程 B 读一个 volatile 变量后,写线程 A 在写这个 volatile 变量之前所有可见的共享变量的值都将立即变得对读线程 B 可见。
下面对 volatile 写和 volatile 读的内存语义做个总结:
>线程 A 写一个 volatile 变量,实质上是线程 A 向接下来将要读这个 volatile 变量的某个线程发出了 (其对共享变量所做修改的) 消息。
>线程 B 读一个 volatile 变量,实质上是线程 B 接收了之前某个线程发出的 (在写这个 volatile 变量之前对共享变量所做修改的) 消息。
> 线程 A 写一个 volatile 变量,随后线程 B 读这个 volatile 变量,这个过程实质上是线程 A 通过主内存向线程 B 发送消息。
1.4 volatile 内存语义的实现
重排序分为编译器重排序和处理器重排序。为了实现 volatile 内存语义,JMM 会分别限制这两种类型的重排序类型,制定了如下规则:
1、当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。
2、当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。
3、当第一个操作是 volatile 写,第二个操作是 volatile 读时,不能重排序。
为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM 采取保守策略。下面是基于保守策略的 JMM 内存屏障插入策略。
在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
在每个 volatile 读操作的前面插入一个 LoadLoad 屏障。
在每个 volatile 读操作的后面插入一个 LoadStore 屏障。
上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的 volatile 内存语义。
由于不同的处理器有不同 "松紧度" 的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。
1.5 JSR-133 为什么要增强 volatile 的内存语义
在 JSR-133 之前的旧 java 内存模型中,虽然不允许 volatile 变量之间重排序,但旧的 Java 内存模型允许 volatile 变量与普通变量重排序。
在旧的内存模型中,volatile 的写 - 读没有锁的释放 - 获取具有的的内存语义。为了提供一种比锁更轻量级的线程之间通信的机制,JSR-133 专家组决定增强 volatile 的内存语义:严格限制编译器和处理器对 volatile 变量与普通变量的重排序,确保 volatile 的写 - 读和锁的释放 - 获取具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要 volatile 变量与普通变量之间的重排序可能会破坏 volatile 的内存语义,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。
由于 volatile 仅仅保证对单个 volatile 变量的读 / 写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比 volatile 更强大;在可伸缩性和执行性能上,volatile 更有优势,但是如果想在程序中用 volatile 代替锁,一定要谨慎。
来源: