volatile
作为 Java 语言的一个关键字,被看作是轻量级的 synchronized
(锁)。虽然 volatile
只具有synchronized
的部分功能,但是一般使用 volatile
会比使用 synchronized
更有效率。在编写多线程程序的时候,volatile
修饰的变量能够:
- 保证内存 可见性
- 防止指令 重排序
- 保证对 64 位变量 读写的原子性
一. 保证内存可见性
JVM 中,每个线程都拥有自己栈内存,用来保存当前线程运行过程中的变量数据;然后多个线程之间共享堆内存(也称主存)。当线程需要访问一个变量时,首先将其从堆内存中复制到自己的栈内存作为副本,然后线程每次对该变量的操作,都将是对栈中的副本进行操作 —— 在某些时刻(比如退出 synchronized
块或线程结束),线程会将栈中副本的值写回到主存,此时主存中的变量才会被替换为副本的值。这样自然就带来一个问题,即如果两个线程共享一个变量,线程A 改变了变量的值,但是 线程B 可能无法立即发现。比如下面这个经典的例子:
public class ConcurrentTest {
private static boolean running = true;
public static class AnotherThread extends Thread {
@Override
public void run() {
System.out.println("AnotherThread is running");
while (running) { }
System.out.println("AnotherThread is stoped");
}
}
public static void main(String[] args) throws Exception {
new AnotherThread ().start();
Thread.sleep(1000);
running = false; // 1 秒之后想停止 AnotherThread
}
}
上面这段代码一般情况下都会死锁,就是因为在 main
方法(主线程)中对 running 做的修改,并不能立马对 AnotherThread
可见。
如果将 running 加上修饰符 volatile
,那么便可以获取实际希望的结果,因为此时主线程中设置 running 为 false
之后,AnotherThread
可以立马发现 running 的值发生了改变:
对于
volatile
修饰的变量,JVM 可以保证:
- 每次对该变量的写操作,都将立即同步到主存;
- 每次对该变量的读操作,都将从主存读取,而不是线程栈
二. 防止指令重排序
如果一个操作不是原子操作,那么 JVM 便可能会对该操作涉及的指令进行 重排序。重排序即在不改变程序语义的前提下,通过调整指令的执行顺序,尽可能达到提高运行效率的目的。
对于单例模式,为了达到延时初始化,并且可以在多线程环境下使用,我们可以直接使用 synchronized
关键字:
public class Singleton {
public static Singleton instance = null;
private Singleton() { }
public synchronized static Singleton getSingleton() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
这样做的缺陷也很明显,那就是 instance 初始化完毕之后,以后每次获取 instance 仍然需要进行加锁操作,是个很大的效率浪费。
于是出现了一种经典写法叫 “双重检测锁”:
public class Singleton {
public static Singleton instance = null;
private Singleton() { }
public static Singleton getSingleton() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
但是这样的写法同样会存在问题,因为 instance = new Singleton()
并非原子操作,其大概可以等同于执行:
- 分配一个
Singleton
对应的内存 - 初始化这个
Singleton
对应的内存 - 将 instance 指向对应的内存的地址
其中,2 依赖于 1,但是 3 并不依赖于 2 —— 所以,存在 JVM 将这三条语句重排序为 1->3->2 的可能,即变为:
a. 分配一个 Singleton
对应的内存
b. 将 instance 指向对应的内存的地址
c. 初始化这个 Singleton
对应的内存
此时如果 线程A 执行完 b,那么此时的 instance 指向的内存并不为 null
,然而这块内存却还没有被初始化。当 线程B 此时判断第一个 if (instance == null)
时发现 instance 并不为 null
,便会将此时的 instance 返回 —— 但 Singleton
的初始化可能并未完成,此时 线程B 使用 instance 便可能会出现错误。
在 JDK 1.5 之后,增强了volatile
的语义,严格限制 JVM (编译器、处理器)不能对volatile
修饰的变量涉及的操作指令进行重排序。
所以为了避免对 instance 变量涉及的操作进行重排序,保证 “双重检测锁” 的正确性,我们可以将 instance 使用 volatile
修饰:
public class Singleton {
/* 使用 volatile 修饰 */
public static volatile Singleton instance = null;
private Singleton() { }
public static Singleton getSingleton() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
三. 保证对 64 位变量读写的原子性
JVM 可以保证对 32位 数据读写的原子性,但是对于 long
和 double
这样 64位 的数据的读写,会将其分为 高32位 和 低32位 分两次读写。所以对于long
或 double
的读写并不是原子性的,这样在并发程序中共享 long
或 double
变量就可能会出现问题,于是 JVM 提供了 volatile
关键字来解决这个问题:
使用volatile
修饰的long
或double
变量,JVM 可以保证对其读写的原子性。
但值得注意的是,此处的 “写” 仅指对 64位 的变量进行直接赋值。而对于 i++
这个语句,事实上涉及了 读取-修改-写入 三个操作:
- 读取变量到栈中某个位置
- 对栈中该位置的值进行自增
- 将自增后的值写回到变量对应的存储位置
因此哪怕变量 i
使用 volatile
修饰,也并不能使涉及上面三个操作的 i++
具有原子性。所以多线程条件下使用 volatile
关键字的前提是:对变量的写操作不依赖于变量的当前值,而赋值操作很明显满足这一前提。
在多线程环境下,正确使用 volatile
关键字可以比直接使用 synchronized
更加高效而且代码简洁,但是使用 volatile
关键字也更容易出错。所以,除非十分清楚 volatile
的使用场景,否则还是应该选择更加具有保障性的 synchronized
。
Brian Goetz 大大写过一篇 “volatile 变量使用指南”,有兴趣的读者可以参阅:Java 理论与实践: 正确使用 Volatile 变量
volatile
变量的底层实现原理,有兴趣的读者可以参阅:
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。