volatile
是 Java 中的一个关键字,用于修饰变量。它是 Java 内存模型 (JMM) 中实现可见性和有序性(禁止指令重排序)的一种轻量级同步机制。
一、volatile
的作用
volatile
关键字有两个主要作用:
-
保证可见性 (Visibility):
-
什么是可见性问题?
- 在多线程环境下,每个线程都有自己的工作内存(例如 CPU 缓存)。
- 线程对共享变量的修改,首先会在自己的工作内存中进行,然后才会被刷新到主内存中。
- 其他线程可能无法立即看到这个修改,因为它们可能还在读取自己工作内存中的旧值。
- 这就是可见性问题:一个线程对共享变量的修改,对其他线程不可见。
-
volatile
如何保证可见性?- 当一个变量被声明为
volatile
时,Java 内存模型 (JMM) 会确保:- 写操作: 当一个线程修改了
volatile
变量的值,这个新值会立即被刷新到主内存中。 - 读操作: 当一个线程读取
volatile
变量的值,它会强制从主内存中读取最新值,而不是从自己的工作内存中读取。
- 写操作: 当一个线程修改了
- 当一个变量被声明为
-
底层原理 (简化):
- 写操作: 在写
volatile
变量时,会插入一个 StoreStore 屏障 和一个 StoreLoad 屏障。- StoreStore 屏障:禁止上面的普通写和下面的
volatile
写重排序。 - StoreLoad 屏障:强制刷新处理器缓存,确保其他处理器能够看到
volatile
变量的最新值。
- StoreStore 屏障:禁止上面的普通写和下面的
- 读操作: 在读
volatile
变量时,会插入一个 LoadLoad 屏障 和一个 LoadStore 屏障。- LoadLoad 屏障:禁止上面的
volatile
读和下面的普通读重排序。 - LoadStore 屏障:禁止上面的
volatile
读和下面的普通写重排序。 - 还会强制从主内存读取
volatile
变量的值。
- LoadLoad 屏障:禁止上面的
- 写操作: 在写
-
-
禁止指令重排序 (Ordering):
-
什么是指令重排序?
- 为了优化性能,编译器和处理器可能会对指令的执行顺序进行重新排序,只要不影响单线程程序的执行结果。
- 但在多线程环境下,指令重排序可能会导致意想不到的问题。
-
volatile
如何禁止指令重排序?volatile
关键字可以禁止特定类型的指令重排序,具体规则如下:- 写操作:
- 不能将
volatile
写操作重排序到之前的任何操作之前。 - 不能将
volatile
写操作重排序到之后的其他volatile
写操作之后。
- 不能将
- 读操作:
- 不能将
volatile
读操作重排序到之后的任何操作之后。 - 不能将
volatile
读操作重排序到之前的其他volatile
读操作之前。
- 不能将
- 写操作:
-
内存屏障 (Memory Barrier):
volatile
的禁止指令重排序是通过 内存屏障 (Memory Barrier) 来实现的。- 内存屏障是一种特殊的 CPU 指令,可以强制处理器按照特定的顺序执行内存操作,并刷新处理器缓存。
-
二、volatile
的使用场景
volatile
适用于以下场景:
-
状态标志 (Status Flag):
-
场景描述:
- 使用一个
boolean
类型的变量作为状态标志,控制线程的执行。 - 例如,一个线程检查标志位,决定是否继续执行;另一个线程修改标志位,通知其他线程停止执行。
- 使用一个
-
volatile
的作用:- 保证标志位的可见性,确保线程能够及时看到标志位的变化。
-
代码示例:
public class VolatileFlagExample { private volatile boolean running = true; // 使用 volatile 修饰 public void start() { new Thread(() -> { while (running) { // 执行任务 System.out.println("Running..."); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println("Stopped."); }).start(); } public void stop() { running = false; // 修改标志位 } public static void main(String[] args) throws InterruptedException { VolatileFlagExample example = new VolatileFlagExample(); example.start(); Thread.sleep(2000); example.stop(); } }
-
-
双重检查锁定 (Double-Checked Locking) 单例模式 (JDK 1.5 之后):
-
场景描述:
- 单例模式的一种实现方式,延迟初始化,只有在第一次使用时才创建实例。
- 为了保证线程安全,需要进行同步处理。
- 双重检查锁定是一种优化手段,可以减少同步的开销。
-
volatile
的作用:- 禁止指令重排序,确保 instance = new Singleton() 这条语句的原子性.
这条语句并非原子操作,实际上包含三个步骤:
- 分配内存空间
- 初始化对象
- 将 instance 指向分配的内存空间。
如果没有volatile
,可能会发生指令重排序,导致一个线程获取到未完全初始化的对象。
- 禁止指令重排序,确保 instance = new Singleton() 这条语句的原子性.
-
JDK1.5 之前,即使是使用了双重检查,由于volatile 关键字语义不完善, DCL仍然不能保证安全。
-
代码示例:
public class Singleton { private volatile static Singleton instance; // 使用 volatile 修饰 private Singleton() {} public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { // 加锁 if (instance == null) { // 第二次检查 instance = new Singleton(); } } } return instance; } }
-
-
发布-订阅模式中的可见性:
-
场景描述:
- 一个线程发布(修改)一个共享变量,其他线程订阅(读取)这个变量的变化。
-
volatile
的作用:- 保证发布线程对共享变量的修改对订阅线程可见。
-
-
写多读少的场景:
- 如果一个变量被多个线程读取, 但只有一个线程写入,使用
volatile
可以提供轻量级的同步。
- 如果一个变量被多个线程读取, 但只有一个线程写入,使用
三、volatile
的局限性 (不能做什么)
-
volatile
不能保证原子性:volatile
只能保证可见性和有序性,不能保证原子性。- 对于复合操作(例如
i++
),volatile
无法保证线程安全。i++
操作实际上包含三个步骤:读取 i 的值、将 i 的值加 1、将新值写回 i。 即使 i 是volatile
的,这三个步骤也不是原子的,多个线程同时执行i++
仍然可能导致错误。
-
volatile
不能代替锁:volatile
是一种轻量级的同步机制,它比锁的开销要小。- 但
volatile
的功能有限,只能用于特定场景,不能完全代替锁。 - 如果需要保证复合操作的原子性,或者需要更复杂的同步控制,仍然需要使用锁(例如
synchronized
或ReentrantLock
)或原子类。
四、volatile
与 synchronized
的比较
特性 | volatile | synchronized |
---|---|---|
作用 | 保证可见性、禁止指令重排序 | 保证原子性、可见性、有序性 |
使用方式 | 修饰变量 | 修饰方法或代码块 |
性能 | 轻量级,开销较小 | 重量级,开销较大 |
阻塞 | 不阻塞线程 | 阻塞线程 |
死锁 | 不会导致死锁 | 可能导致死锁 |
原子性 | 不保证原子性 (只保证单个 volatile 变量的读写操作的原子性) | 保证原子性 (被 synchronized 修饰的代码块或方法具有原子性) |
使用场景 | 状态标志、双重检查锁定单例模式、发布-订阅模式 | 需要保证原子性、可见性和有序性的场景,例如对多个变量进行复合操作、实现复杂的同步逻辑 |
与 CAS 的关系 | CAS 操作通常需要 volatile 变量来保证可见性和有序性。 CAS 提供了原子性的比较并交换操作, 结合 volatile 变量的可见性, 就可以实现一些无锁的算法。 | synchronized 内部使用了监视器锁 (Monitor Lock) 来实现同步。 Java 对象头中的 Mark Word 用于存储锁的信息。 JDK 1.6 之后,synchronized 进行了优化,引入了偏向锁、轻量级锁、重量级锁等机制, 其中轻量级锁的实现就利用了 CAS。 |
实现 | JVM通过插入内存屏障实现 | JVM 基于进入和退出 Monitor Object 来实现方法同步和代码块同步, monitorenter 和 monitorexit. 代码块同步是使用 monitorenter 和 monitorexit 指令实现的,方法同步使用另一种方式实现(通常是添加 ACC_SYNCHRONIZED 标志) |
总结:
volatile
是 Java 中的一个关键字,用于修饰变量,提供轻量级的同步机制。volatile
的主要作用是保证可见性和禁止指令重排序,但不能保证原子性。volatile
适用于状态标志、双重检查锁定单例模式、发布-订阅模式等场景。volatile
不能代替锁,在需要保证复合操作的原子性或更复杂的同步控制时,仍然需要使用锁或原子类。- 理解
volatile
的原理和使用场景,可以帮助你编写正确的并发程序,避免可见性和有序性导致的问题。
五、 深入理解 volatile
(高级主题)
-
happens-before 关系:
- Java 内存模型 (JMM) 定义了 happens-before 关系,用于描述操作之间的可见性。
- 如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见,并且 A 的执行顺序在 B 之前。
volatile
变量的写操作 happens-before 后续对该变量的读操作。- 这意味着,如果一个线程写入一个
volatile
变量,那么后续任何线程读取这个volatile
变量,都能看到之前写入的值。
-
内存屏障 (Memory Barrier) 的细节:
- 内存屏障是一种 CPU 指令,用于控制内存访问的顺序和可见性。
- 不同的 CPU 架构有不同的内存屏障指令。
- Java 内存模型 (JMM) 定义了一组抽象的内存屏障,JVM 会根据具体的 CPU 架构将这些抽象的内存屏障映射到具体的 CPU 指令。
volatile
关键字的实现依赖于内存屏障:- StoreStore 屏障: 禁止上面的普通写和下面的
volatile
写重排序。 - StoreLoad 屏障: 强制刷新处理器缓存,确保其他处理器能够看到
volatile
变量的最新值。 - LoadLoad 屏障: 禁止上面的
volatile
读和下面的普通读重排序。 - LoadStore 屏障: 禁止上面的
volatile
读和下面的普通写重排序。 - 更详细地说:
- 在每个volatile写操作前插入StoreStore屏障。
- 在每个volatile写操作后插入StoreLoad屏障。
- 在每个volatile读操作后插入LoadLoad屏障。
- 在每个volatile读操作后插入LoadStore屏障。
- StoreStore 屏障: 禁止上面的普通写和下面的
-
volatile
与缓存一致性协议 (Cache Coherence Protocol):- 在多核处理器中,每个 CPU 核心都有自己的缓存(L1, L2, L3 缓存)。
- 为了保证多个 CPU 核心之间的缓存数据一致性,需要使用缓存一致性协议(例如 MESI 协议)。
volatile
变量的写操作会触发缓存一致性协议,将新值写入主内存,并使其他 CPU 核心中该变量的缓存失效。
-
volatile
与原子类的关系:
原子类内部使用了volatile
和 CAS操作来实现原子性、可见性和有序性。 原子类中的变量通常会被声明为volatile
。
六、 最佳实践
- 明确
volatile
的适用场景: 不要滥用volatile
,只有在确实需要保证可见性和禁止指令重排序时才使用它。 - 理解
volatile
的局限性:volatile
不能保证原子性,不能代替锁。 - 优先使用原子类: 如果需要原子性操作,优先使用 Java 并发包提供的原子类 (例如
AtomicInteger
,AtomicLong
,AtomicReference
等),而不是直接使用volatile
和 CAS。 - 谨慎使用双重检查锁定: 只有在 JDK 1.5 及以上版本,并且正确使用
volatile
关键字的情况下,双重检查锁定才是安全的。 - 结合具体场景分析: 对于复杂的并发场景,要仔细分析,确定是否需要使用
volatile
,以及如何正确使用。
**总结 **
volatile
是 Java 并发编程中一个重要的关键字,它可以提供轻量级的同步机制,保证可见性和禁止指令重排序。 理解 volatile
的原理、作用、使用场景和局限性,是编写正确的并发程序的基础。 在实际开发中,要根据具体情况,合理使用 volatile
、原子类、锁等同步机制,构建高效、可靠的多线程应用程序。 尽量避免直接使用底层的Unsafe
类进行CAS操作。