1 前言
Java 编程语言允许线程访问共享变量。作为规则,为了确保共享变量被一致并可靠地更新,线程应该确保独占地使用这种变量,其惯用的方式是通过获取锁来实现,即强制线程互斥地使用这些变量。
Java 编程语言还提供了第二种机制,即 volatile,volatile 的意思是可见的,常用来修饰某个共享变量,意思是当共享变量的值被修改后,会及时通知其它线程,其它线程就能知道当前共享变量的值已经被修改了。在某些方面,它比加锁机制要方便。
字段可以被声明为 volatile ,此时 Java 内存模型会确保所有线程看到的都是该变量的一致的值。
如果 final 变量同时也被声明为 volatile ,那么就会产生一个编译时错误。
2 volatile 域
对于下面的例子:
public class NoSync {
static int i = 0, j = 0;
static void one() { i++; j++; }
static void two() {
System.out.println("i=" + i + " j=" + j);
}
public static void main(String[] args) {
new Thread(() -> {
for (int k = 0; k < 10000; k++) {
one();
}
}).start();
new Thread(() -> {
for (int k = 0; k < 10000; k++) {
two();
}
}).start();
}
}
如果一个线程重复地调用方法 one(但是总共不超过 Integer.MAX_VALUE 次),而另一个线程重复地调用方法 two,那么方法 two 打印出的 j 的值偶尔会比 i 的值要大:
i=3315 j=3418
i=8350 j=8405
i=9107 j=9152
i=9715 j=9878
i=10000 j=10000
i=10000 j=10000
i=10000 j=10000
因为这个示例没有包含任何同步机制,共享变量 i 和 j 可能会被乱序更新。
—种可以防止这种乱序行为的方式是将方法 one 和 two 都声明为 synchronized 。
public class Sync {
static int i = 0, j = 0;
static synchronized void one() { i++; j++; }
static synchronized void two() {
System.out.println("i=" + i + " j=" + j);
}
public static void main(String[] args) {
new Thread(() -> {
for (int k = 0; k < 10000; k++) {
one();
}
}).start();
new Thread(() -> {
for (int k = 0; k < 10000; k++) {
two();
}
}).start();
}
}
这可以阻止方法 one 和方法 two 被并发地执行,并且可以确保共享变量 i 和 j 都会在方法 one 返回之前被更新。因此,方法 tow 永远都不会看到 j 的值大于 i 的值。实际上,它总是看到 i 和 j 有相同的值。
i=3738 j=3738
i=3738 j=3738
i=3738 j=3738
i=3738 j=3738
i=3738 j=3738
i=3919 j=3919
i=3922 j=3922
i=3922 j=3922
i=3922 j=3922
i=3922 j=3922
i=3922 j=3922
i=3922 j=3922
另一种方法是将 i 和 j 声明为 volatile:
public class Volatile {
static volatile int i = 0, j = 0;
static void one() { i++; j++; }
static void two() {
System.out.println("i=" + i + " j=" + j);
}
public static void main(String[] args) {
new Thread(() -> {
for (int k = 0; k < 10000; k++) {
one();
}
}).start();
new Thread(() -> {
for (int k = 0; k < 10000; k++) {
two();
}
}).start();
}
}
这使得方法 one 和方法 two 可以并发地执行,但是可以确保对共享变量 i 和 j 的访问发生的次数,与所有线程执行这段程序文本时这些访问出现的次数精确相等,并且以完全相同的顺序发生。因此,j 的共享值永远都不会大于 i 的共享值,因为每次对 i 的更新必须在 j 被更新之前反映到 i 的共享值中。但是,有可能会发现,任意给定的对方法 two 的调用都会观察到 j 的值比观察到的 i 的值大许多,因为方法 one 可能会在方法 two 抓取 i 的值的时刻与抓取 j 的值的时刻之间执行了许多次。
i=9260 j=9474
i=10000 j=10000
i=10000 j=10000
i=10000 j=10000
i=10000 j=10000
i=10000 j=10000
i=10000 j=10000
3 volatile的内存语义
在了解 volatile 实现原理之前,让我们先来了解一下与其实现原理相关的 CPU 相关的知识。现代计算机中,为了提高处理速度,处理器不直接和内存进行通信,而是会先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。这时候会产生这样一个问题,CPU 缓存中的值和内存中的值可能并不是时刻都同步的,导致线程获取到的用于计算的值可能不是最新的,共享变量的值有可能已经被其它线程所修改了,但此时修改的是机器内存的值,CPU 缓存的还是原来没有更新的值,从而就会导致计算出现问题。
那么 volatile 是如何来保证可见性的呢?
我们可以在 X86 处理器下通过工具获取 JIT 编译器生成的汇编指令来了解其背后的原理。
对于如下的 Java 代码:
instance = new Singleton(); // instance 是 volatile 变量
转变成的汇编代码如下:
0x01a3de1d: movb $0 * 0,0 * 1104800(%esi);
0x01a3de24: lock add1 $0 * 0,(%esp);
有 volatile 修饰的共享变量在进行写操作的时候会多出第二行汇编代码,通过查Intel IA-32 架构软件开发者手册的多处理器管理章节可知,Lock 前缀的指令在多核处理器下会引发两件事情:
- 将当前处理器缓存行的数据写回到系统内存。
- 这个写回内存的操作会使在其他 CPU 里缓存里该内存地址的数据无效。
缓存行:CPU 高速缓存中可以分配的最小存储单位(通常为64个字节)。处理器填写缓存行时会加载整个缓存行,现代 CPU 需要执行几百次 CPU 指令。
如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 Lock 前缀的指令, 将这个变量所在的缓存行的数据写回到系统内存。但是,就算写回到内存,也不能保证其他处理器会立刻去内存中读取最新的值,这个时候处理器中缓存的值还是原来的旧值,线程在获取这个值执行计算操作时就会有问题。因此,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查字节缓存的值是不是过期了,如果发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存中。
volatile 的两条实现原则是:
- Lock 前缀指令会引起处理器缓存写回到内存。 Lock 指令首先会尝试锁缓存,如果锁缓存无法保证独占共享内存,则会锁定整个总线。
- 一个处理器的缓存回写到内存会导致其他处理器的缓存无效。
CPU每个缓存行标记四种状态(额外两位)
- Modified:CPU修改了缓存中的数据(缓存中的数据与内存相比有更改过)
- Exclusive:该缓存数据只由当前CPU使用(缓存中的数据是独享的)
- Shared:缓存中的数据除了该CPU在读取,其他CPU也在读取
- Invalid:缓存中的数据在读取时被其他CPU修改过
有些无法被缓存的数据(比较大的数据),或者跨越多个缓存行的数据依然必须使用总线锁。
参考资料
- 《Java语言规范 基于 Java SE 8》
- Java Language Specification 8
- 《Java并发编程的艺术》