大家好,我是大明哥,一个专注「死磕 Java」系列创作的硬核程序员。 本文已收录到我的技术网站:skjava.com。有全网最优质的系列文章、Java 全栈技术文档以及大厂完整面经。
回答
volatile
是一种轻量级的同步机制,它能保证共享变量的可见性,同时禁止重排序保证了操作的有序性,但是它无法保证原子性。所以使用 volatile
必须要满足这两个条件:
- 写入变量不依赖当前值。
- 变量不参与与其他变量的不变性条件。
volatile
比较适合多个线程读,一个线程写的场合,典型的场景有如下几个:
- 状态标志
- 重检查锁定的单例模式
- 开销较低的"读-写锁"策略
详解
volatile 使用条件
要想正确安全地使用 volatile
,必须要具备这两个条件:
- 写入变量不依赖当前值 :变量的新值不能依赖于之前的旧值。如果变量的当前值与新值之间存在依赖关系,那么仅使用
volatile
是不够的,因为它不能保证一系列操作的原子性。比如 i++。 - 变量不参与与其他变量的不变性条件 :如果一个变量是与其他变量共同参与不变性条件的一部分,那么简单地声明变量为
volatile
是不够的。
第一个条件很好理解,第二个条件这里需要解释下。
"变量不参与与其他变量的不变性条件",这里的"不变性条件"指的是一个或多个变量在程序执行过程中需要保持的条件或关系,以确保程序的正确性。假设我们有两个变量,它们需要满足某种关系(例如,a + b = 99
)。我们需要在多线程环境下保证这种关闭在任何时候都是成立的。如果这个时候我们只是将其中一个变量声明为 volatile
,虽然确保了这个变量的更新对其他线程立即可见,但却不能保证这两个变量作为一个整体满足特定的不变性条件。在更新这两个变量的过程中,其他线程可能会看到这些变量处于不一致的状态。在这种情况下我们就需要使用锁或者其他同步机制来保证这种关系的整体一致性。
volatile 使用场景
volatile
比较适合多个线程读,一个线程写的场合。
状态标志
当我们需要用一个变量来作为状态标志,控制线程的执行流程时,使用 volatile
可以确保当一个线程修改了这个标志时,其他线程能够立即看到最新的值。
arduino
public class TaskRunner implements Runnable {
private volatile boolean running = true; // 状态标志,控制任务是否继续执行
public void run() {
while (running) { // 检查状态标志
// 执行任务
doSomething();
}
}
public void stop() {
running = false; // 修改状态标志,使得线程能够停止执行
}
private void doSomething() {
// 实际任务逻辑
}
}
DCL 的单例模式
在实现单例模式时,为了保证线程安全,通常使用双重检查锁定(Double-Checked Locking)模式。在这种模式中,volatile
用于避免单例实例的初始化过程中的指令重排序,确保其他线程看到一个完全初始化的单例对象,具体来说,就是使用 volatile
防止了Java 对象在实例化过程中的指令重排,确保在对象的构造函数执行完毕之前,不会将 instance
的内存分配操作指令重排到构造函数之外。
csharp
public class Singleton {
// 使用 volatile 保证实例的可见性和有序性
private static volatile Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查,避免不必要的同步
synchronized (Singleton.class) { // 锁定
if (instance == null) { // 第二次检查,确保只创建一次实例
instance = new Singleton();
}
}
}
return instance;
}
}
开销较低的"读-写锁"策略
这种策略一般都是允许多个线程同时读取一个资源,但只允许一个线程写入的同步机制。这种"读-写锁"非常适合读多写少的场景,我们可以利用 volatile
+ 锁的机制减少公共代码路径的开销。如下:
csharp
public class VolatileTest {
private volatile int value;
//读,不加锁,提供效率
public int getValue() {
return value;
}
//写操作,使用锁,保证线程安全
public synchronized int increment() {
return value++;
}
}
在 J.U.C 中,有一个采用"读-写锁"方式的类:ReentrantReadWriteLock
,它包含两个锁:一个是读锁,另一个是写锁。
下面是伪代码:
java
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class DataStructure {
private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private final Object data = ...; // 被保护的数据
public void read() {
readWriteLock.readLock().lock(); // 获取读锁
try {
// 执行读操作
// 例如,读取data的内容
} finally {
readWriteLock.readLock().unlock(); // 释放读锁
}
}
public void write(Object newData) {
readWriteLock.writeLock().lock(); // 获取写锁
try {
// 执行写操作
// 例如,修改data的内容
} finally {
readWriteLock.writeLock().unlock(); // 释放写锁
}
}
}
- 读操作 :多个线程可以同时持有读锁,因此多个线程可以同时执行
read()
方法。 - 写操作: 只有一个线程可以持有写锁,并且在持有写锁时,其他线程不能读取或写入。
这种"读-写锁"策略提高了在多线程环境下对共享资源的读取效率,尤其是在读操作远远多于写操作的情况下。但是,它也会让我们的程序变更更加复杂,比如潜在的读写锁冲突、锁升级(从读锁升级到写锁)等问题。因此,在实际应用中,大明哥推荐直接使用 ReentrantReadWriteLock
即可,无需头铁自己造轮子。