在Java并发编程的世界里,volatile关键字是一个既基础又核心的概念。它看似简单,却涉及Java内存模型(JMM)、指令重排序、内存屏障等底层原理,是理解并发编程可见性与有序性的关键。
一、并发编程的三大核心问题
在深入volatile之前,我们需要先理解并发编程中最基础的三个问题:可见性、有序性和原子性。这三个问题是并发编程Bug的主要来源,而volatile正是为了解决其中的可见性和有序性问题而生。
1.1 可见性问题
可见性指的是当一个线程修改了共享变量的值,其他线程能否立即看到这个修改。在Java中,每个线程都有自己的工作内存(Working Memory),而共享变量存储在主内存(Main Memory)中。线程对共享变量的操作必须在工作内存中进行:先从主内存读取变量到工作内存,修改后再刷新回主内存。
这种内存模型就导致了可见性问题:如果线程A修改了共享变量,但还没来得及刷新回主内存,或者线程B的工作内存中还保留着旧值,那么线程B就看不到线程A的修改。
我们可以用一个简单的例子来演示可见性问题:
package com.jam.demo;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.TimeUnit;
@Slf4j
public class VolatileVisibilityDemo {
private static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
Thread threadA = new Thread(() -> {
log.info("线程A开始执行,准备修改flag");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("线程A被中断", e);
}
flag = true;
log.info("线程A修改flag为true,执行结束");
}, "thread-A");
Thread threadB = new Thread(() -> {
log.info("线程B开始执行,等待flag变为true");
while (!flag) {
}
log.info("线程B检测到flag变为true,执行结束");
}, "thread-B");
threadB.start();
TimeUnit.MILLISECONDS.sleep(100);
threadA.start();
threadA.join();
threadB.join();
}
}
在这个例子中,线程A在1秒后将flag修改为true,但如果flag没有加volatile修饰,线程B可能永远无法跳出while循环,因为它的工作内存中flag一直是false。
1.2 有序性问题
有序性指的是程序执行的顺序按照代码的先后顺序执行。但在现代编译器和CPU中,为了提高性能,会对指令进行重排序(Instruction Reordering)。重排序需要满足两个条件:一是在单线程环境下不能改变程序的执行结果(as-if-serial语义);二是存在数据依赖的指令不能被重排序。
但在多线程环境下,指令重排序可能会导致严重的问题。比如下面这个例子:
// 线程A
context = loadContext(); // 1
initialized = true; // 2
// 线程B
while (!initialized) {
Thread.onSpinWait();
}
useContext(context);
在这个例子中,指令1和2没有数据依赖,可能被重排序为2先执行,1后执行。如果线程A先执行了2,将initialized设为true,此时线程B会跳出循环,使用还未加载完成的context,导致程序出错。
1.3 原子性问题
原子性指的是一个操作是不可分割的,要么全部执行成功,要么全部不执行。比如a = 1是一个原子操作,但a++不是,因为它包含了三个步骤:读取a的值、加1、写回a。在多线程环境下,多个线程同时执行a++可能会导致结果不符合预期。
需要注意的是,volatile不能保证原子性,这是它和synchronized、原子类的重要区别之一。
二、volatile的核心特性
volatile是Java提供的一种轻量级同步机制,它主要有两个核心特性:保证共享变量的可见性,禁止指令重排序。
2.1 保证可见性
当一个变量被volatile修饰后,它会保证:
- 线程对该变量的修改会立即刷新到主内存;
- 线程对该变量的读取会直接从主内存中读取,而不是从工作内存中读取。
回到之前的VolatileVisibilityDemo例子,如果我们将flag声明为volatile:
private static volatile boolean flag = false;
那么线程A修改flag后,会立即刷新到主内存;线程B每次读取flag时,都会从主内存中读取最新值,因此能立即看到flag的变化,从而跳出循环。
2.2 禁止指令重排序
volatile通过禁止指令重排序来保证有序性。具体来说,volatile会禁止以下两种重排序:
- 当程序执行到volatile变量的读操作或写操作时,在其前面的操作必须全部执行完成,且结果对后面的操作可见;
- 在程序执行到volatile变量的读操作或写操作时,在其后面的操作必须全部未执行。
这就保证了volatile变量之前的指令不会被重排序到它之后,之后的指令也不会被重排序到它之前。
2.3 不保证原子性
需要特别强调的是,volatile不能保证原子性。比如下面这个例子:
package com.jam.demo;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.CountDownLatch;
@Slf4j
public class VolatileAtomicityDemo {
private static volatile int count = 0;
public static void main(String[] args) throws InterruptedException {
int threadCount = 10;
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
count++;
}
countDownLatch.countDown();
}).start();
}
countDownLatch.await();
log.info("count的最终值:{}", count);
}
}
在这个例子中,我们启动了10个线程,每个线程对count执行1000次自增操作。如果volatile能保证原子性,那么count的最终值应该是10000,但实际运行结果往往小于10000。这是因为count++包含了读取、加1、写回三个步骤,volatile只能保证这三个步骤的可见性,但不能保证它们是一个原子操作。多个线程可能同时读取到count的旧值,加1后写回,导致结果被覆盖。
如果需要保证原子性,应该使用原子类(如AtomicInteger)或者synchronized。
三、volatile的底层实现原理
volatile的这些特性是如何实现的呢?答案是内存屏障(Memory Barrier)。在深入内存屏障之前,我们需要先了解Java内存模型(JMM)的抽象结构。
3.1 Java内存模型的抽象结构
JMM是一种规范,它定义了线程和主内存之间的抽象关系:
- 所有共享变量都存储在主内存中;
- 每个线程都有自己的工作内存,工作内存中保存了该线程使用的共享变量的副本;
- 线程对共享变量的所有操作都必须在工作内存中进行,不能直接读写主内存;
- 不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递需要通过主内存来完成。
JMM还定义了8种原子操作来完成主内存和工作内存之间的交互:lock、unlock、read、load、use、assign、store、write。这些操作的具体含义我们不需要深入了解,只需要知道JMM通过这些操作来保证可见性和有序性。
3.2 内存屏障的类型与作用
内存屏障是一种CPU指令,它的作用是禁止指令重排序和保证内存可见性。JMM将内存屏障分为四种类型:
| 屏障类型 | 指令示例 | 作用 |
| LoadLoad | Load1; LoadLoad; Load2 | 确保Load1的数据先于Load2及后续Load指令读取 |
| StoreStore | Store1; StoreStore; Store2 | 确保Store1的数据先于Store2及后续Store指令刷新到主内存 |
| LoadStore | Load1; LoadStore; Store2 | 确保Load1的数据先于Store2及后续Store指令执行 |
| StoreLoad | Store1; StoreLoad; Load2 | 确保Store1的数据先于Load2及后续Load指令执行,同时刷新写缓冲区到主内存 |
在这四种屏障中,StoreLoad是最“全能”的,它同时具有其他三种屏障的作用,但开销也最大,因为它需要将写缓冲区中的数据全部刷新到主内存,同时 invalidate 读缓冲区。
3.3 volatile的内存屏障插入策略
为了实现volatile的可见性和有序性,JMM会在volatile变量的读写操作前后插入内存屏障:
volatile写操作的内存屏障插入策略:
- 在每个volatile写操作前插入一个StoreStore屏障;
- 在每个volatile写操作后插入一个StoreLoad屏障。
volatile读操作的内存屏障插入策略:
- 在每个volatile读操作后插入一个LoadLoad屏障;
- 在每个volatile读操作后插入一个LoadStore屏障。
我们可以用流程图来更直观地理解这个过程:
3.4 不同CPU架构下的实现差异
需要注意的是,内存屏障的具体实现是依赖于CPU架构的。不同的CPU架构有不同的内存模型,因此JMM在不同架构下插入的内存屏障也会有所不同。
以x86架构为例,它是一种强内存模型(TSO,Total Store Order),具有以下特性:
- 不允许Load重排序;
- 不允许Store重排序;
- 不允许Load和后续Store重排序;
- 允许Store和后续Load重排序。
因此,在x86架构下:
- LoadLoad、StoreStore、LoadStore屏障都是no-op(空操作),不需要实际的指令;
- 只有StoreLoad屏障需要实际实现,通常使用lock前缀的指令(如
lock addl $0, 0(%esp))或者mfence指令。
这也是为什么在x86架构下,volatile的性能相对较好的原因之一。
四、volatile在单例模式中的应用
单例模式是最常用的设计模式之一,它的核心是确保一个类只有一个实例,并提供一个全局访问点。在多线程环境下,实现单例模式需要特别注意线程安全问题,而volatile在其中扮演了关键角色。
4.1 双重检查锁定(DCL)的演变
我们先来看几种常见的单例模式实现方式,以及它们的问题:
方式一:非线程安全的单例
package com.jam.demo;
import org.springframework.util.ObjectUtils;
public class UnsafeSingleton {
private static UnsafeSingleton instance;
private UnsafeSingleton() {
}
public static UnsafeSingleton getInstance() {
if (ObjectUtils.isEmpty(instance)) {
instance = new UnsafeSingleton();
}
return instance;
}
}
这种方式在单线程环境下没问题,但在多线程环境下,多个线程可能同时通过if判断,导致创建多个实例。
方式二:使用synchronized修饰方法
package com.jam.demo;
import org.springframework.util.ObjectUtils;
public class SynchronizedSingleton {
private static SynchronizedSingleton instance;
private SynchronizedSingleton() {
}
public static synchronized SynchronizedSingleton getInstance() {
if (ObjectUtils.isEmpty(instance)) {
instance = new SynchronizedSingleton();
}
return instance;
}
}
这种方式是线程安全的,但synchronized修饰方法会导致每次调用getInstance都需要加锁,性能开销较大。
方式三:双重检查锁定(DCL),但没有volatile
package com.jam.demo;
import org.springframework.util.ObjectUtils;
public class DclSingletonWithoutVolatile {
private static DclSingletonWithoutVolatile instance;
private DclSingletonWithoutVolatile() {
}
public static DclSingletonWithoutVolatile getInstance() {
if (ObjectUtils.isEmpty(instance)) {
synchronized (DclSingletonWithoutVolatile.class) {
if (ObjectUtils.isEmpty(instance)) {
instance = new DclSingletonWithoutVolatile();
}
}
}
return instance;
}
}
这种方式看起来很完美:第一次检查避免了不必要的同步,第二次检查保证了只有一个线程能创建实例。但它有一个致命的问题:指令重排序。
4.2 为什么DCL需要volatile?
问题出在instance = new DclSingletonWithoutVolatile()这行代码上。这行代码可以分解为三个步骤:
- 分配内存空间;
- 初始化对象;
- 将instance指向分配的内存地址。
在单线程环境下,这三个步骤的执行顺序不会影响结果,但在多线程环境下,指令2和3可能被重排序,执行顺序变为1->3->2。如果线程A先执行了步骤3,将instance指向内存地址,但还没执行步骤2初始化对象,此时线程B在第一次检查时看到instance不为空,就会直接返回instance,使用一个未初始化的对象,导致程序出错。
解决这个问题的方法就是给instance加上volatile修饰:
package com.jam.demo;
import org.springframework.util.ObjectUtils;
public class DclSingleton {
private static volatile DclSingleton instance;
private DclSingleton() {
}
public static DclSingleton getInstance() {
if (ObjectUtils.isEmpty(instance)) {
synchronized (DclSingleton.class) {
if (ObjectUtils.isEmpty(instance)) {
instance = new DclSingleton();
}
}
}
return instance;
}
}
volatile禁止了指令2和3的重排序,确保对象初始化完成后,instance才被赋值,从而避免了未初始化对象的问题。
五、volatile在无锁编程中的应用
除了单例模式,volatile在无锁编程中也有广泛的应用。无锁编程是指不使用synchronized等锁机制,而是利用CAS、volatile等机制实现线程安全的编程方式,它的性能通常比锁机制更好。
5.1 状态标记
状态标记是volatile最常见的无锁编程应用场景之一。比如我们可以用一个volatile变量来标记某个操作是否完成,其他线程通过这个变量来判断是否可以继续执行。
我们来看一个具体的例子:
package com.jam.demo;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.TimeUnit;
@Slf4j
public class VolatileStateDemo {
private static volatile boolean initialized = false;
private static String config;
public static void main(String[] args) throws InterruptedException {
Thread initThread = new Thread(() -> {
log.info("初始化线程开始执行");
try {
TimeUnit.SECONDS.sleep(2);
config = "初始化完成的配置信息";
initialized = true;
log.info("初始化线程执行结束,状态标记已修改");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("初始化线程被中断", e);
}
}, "init-thread");
Runnable businessTask = () -> {
String threadName = Thread.currentThread().getName();
log.info("{} 开始执行,等待初始化完成", threadName);
while (!initialized) {
Thread.onSpinWait();
}
log.info("{} 检测到初始化完成,使用配置:{}", threadName, config);
};
initThread.start();
for (int i = 0; i < 3; i++) {
new Thread(businessTask, "business-thread-" + i).start();
}
initThread.join();
}
}
在这个例子中,初始化线程负责加载配置,完成后将initialized设为true;业务线程自旋等待initialized变为true,然后使用配置。这里的initialized就是一个状态标记,它保证了初始化操作对业务线程的可见性,同时禁止了初始化操作和状态标记修改的重排序。
需要注意的是,这里使用了Thread.onSpinWait(),这是Java 9引入的一个方法,它的作用是提示CPU当前线程正在自旋等待,可以优化CPU的执行效率,避免空转占用过多CPU资源。
5.2 双重检查的其他场景
除了单例模式,双重检查还可以应用在其他需要延迟初始化的场景中。比如我们可以用双重检查来实现一个线程安全的延迟加载缓存:
package com.jam.demo;
import com.google.common.collect.Maps;
import org.springframework.util.ObjectUtils;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class LazyInitCache<K, V> {
private final Map<K, V> cache = new ConcurrentHashMap<>();
private volatile boolean initialized = false;
public V get(K key) {
if (!initialized) {
synchronized (this) {
if (!initialized) {
initCache();
initialized = true;
}
}
}
return cache.get(key);
}
private void initCache() {
cache.put(Maps.immutableEntry("key1", "value1").getKey(), Maps.immutableEntry("key1", "value1").getValue());
cache.put(Maps.immutableEntry("key2", "value2").getKey(), Maps.immutableEntry("key2", "value2").getValue());
}
}
在这个例子中,我们用initialized变量来标记缓存是否初始化完成,使用双重检查来避免不必要的同步,同时用volatile保证了initialized的可见性和有序性。
六、volatile的最佳实践与易混淆点
6.1 volatile的最佳实践
在使用volatile时,我们需要遵循以下最佳实践:
- 对变量的写操作不依赖于当前值:比如状态标记、双重检查锁定中的instance。如果写操作依赖于当前值(比如count++),那么volatile无法保证原子性,应该使用原子类或者synchronized。
- 该变量没有包含在具有其他变量的不变式中:比如如果有一个不变式是a < b,那么即使a和b都是volatile变量,也无法保证这个不变式在多线程环境下成立,因为线程可能在修改a之后、修改b之前被调度,导致其他线程看到a >= b的情况。
- 一个线程写,多个线程读的场景:volatile最适合这种场景,因为它能保证写操作对所有读操作的可见性,同时性能比锁机制更好。
6.2 volatile vs synchronized
| 特性 | volatile | synchronized |
| 原子性 | 不保证 | 保证 |
| 可见性 | 保证 | 保证 |
| 有序性 | 保证 | 保证 |
| 性能 | 高,无上下文切换开销 | 低,有上下文切换开销 |
| 修饰对象 | 变量 | 方法、代码块 |
6.3 volatile vs 原子类
原子类(如AtomicInteger、AtomicLong)是通过CAS(Compare-And-Swap)操作来保证原子性的,同时它们内部也使用了volatile变量来保证可见性和有序性。
| 特性 | volatile | 原子类 |
| 原子性 | 不保证 | 保证 |
| 可见性 | 保证 | 保证 |
| 有序性 | 保证 | 保证 |
| 适用场景 | 状态标记、双重检查等 | 计数器、累加器等需要原子操作的场景 |
七、总结
volatile是Java并发编程中一个非常重要的关键字,它通过内存屏障实现了可见性和有序性,是理解Java内存模型的关键。本文从并发编程的三大问题入手,详细讲解了volatile的核心特性、底层实现原理,并结合单例模式、无锁编程等场景,介绍了volatile的正确使用方法。
在使用volatile时,我们需要特别注意它不能保证原子性,因此在写操作依赖于当前值的场景下,应该使用原子类或者synchronized。同时,我们也需要遵循volatile的最佳实践,确保它的使用是正确和高效的。