volatile和线程安全-Java快速进阶教程
1. 概述
虽然 Java 中的volatile关键字通常可以确保线程安全,但情况并非总是如此。
在本教程中,我们将了解共享volatile
变量可能导致争用条件的情况。
2. 什么是volatile
变量?
与其他变量不同,volatile变量是写入主存储器和从主存储器读取的。CPU 不缓存volatile变量的值。
让我们看看如何声明一个volatile变量:
static volatile int count = 0;
3.volatile变量的性质
在本节中,我们将研究volatile变量的一些重要特征。
3.1. 可见性保证
假设我们有两个线程,在不同的CPU上运行,访问一个共享的非volatile变量。让我们进一步假设第一个线程正在写入一个变量,而第二个线程正在读取相同的变量。
出于性能原因,每个线程将变量的值从主内存复制到其各自的 CPU 缓存中。
对于非volatile变量,JVM 不保证何时将值从缓存写回主内存。
如果第一个线程的更新值没有立即刷新回主内存,则第二个线程可能最终会读取较旧的值。
下图描述了上述场景:

在这里,第一个线程已将变量计数的值更新为 5。但是,更新的值刷新回主内存不会立即发生。因此,第二个线程读取较旧的值。这可能会导致多线程环境中出现错误的结果。
另一方面,如果我们将count声明为volatile,则每个线程都会在主内存中看到其最新更新的值,而不会有任何延迟。
这称为volatile关键字的可见性保证。它有助于避免上述数据不一致问题。
3.2. 发生前确保
JVM和CPU有时会对独立的指令重新排序并并行执行它们以提高性能。
例如,让我们看一下两个独立且可以同时运行的指令:
a = b + c;
d = d + 1;
但是,某些指令不能并行执行,因为后一条指令取决于先前指令的结果:
a = b + c;
d = a + e;
此外,还可以对独立指令进行重新排序。这可能会导致多线程应用程序中出现不正确的行为。
假设我们有两个线程访问两个不同的变量:
int num = 10;
boolean flag = false;
此外,假设第一个线程递增num的值,然后将标志设置为 true,而第二个线程等待标志设置为true。并且,一旦标志的值设置为true,第二个线程就会读取num 的值。
因此,第一个线程应按以下顺序执行指令:
num = num + 10;
flag = true;
但是,假设 CPU 将指令重新排序为:
flag = true;
num = num + 10;
在这种情况下,一旦标志设置为true,第二个线程就会开始执行。由于变量 num 尚未更新,第二个线程将读取旧的num 值,即 10。这会导致不正确的结果。
但是,如果我们将标志声明为volatile,则不会发生上述指令重新排序。
对变量应用volatile关键字通过提供发生前保证来防止指令重新排序。
这可确保在写入volatile变量之前的所有指令都保证不会在它之后重新排序。同样,读取volatile变量后的指令不能重新排序以在其之前发生。
4.volatile关键字何时提供线程安全?
volatile关键字在两种多线程方案中很有用:
- 当只有一个线程写入volatile变量,而其他线程读取其值时。因此,读取线程会看到变量的最新值。
- 当多个线程写入共享变量以使操作是原子的时。这意味着写入的新值不依赖于以前的值。
5. 什么时候volatile不提供线程安全?
volatile关键字是一种轻量级同步机制。
与同步方法或块不同,它不会让其他线程在一个线程处理关键部分时等待。因此,当对共享变量执行非原子操作或复合操作时,volatile关键字不提供线程安全性。
增量和递减等操作是复合操作。这些操作在内部涉及三个步骤:读取变量的值,更新它,然后将更新的值写回内存。
读取值和将新值写回内存之间的短时间间隔可能会产生争用条件。处理同一变量的其他线程可能会在该时间间隔内读取和操作较旧的值。
此外,如果多个线程对同一个共享变量执行非原子操作,它们可能会覆盖彼此的结果。
因此,在线程需要首先读取共享变量的值以找出下一个值的情况下,将变量声明为volatile将不起作用。
6. 示例
现在,我们将尝试在示例的帮助下将变量声明为volatile不是线程安全的时,尝试理解上述情况。
为此,我们将声明一个名为count的共享volatile变量并将其初始化为零。我们还将定义一个递增此变量的方法:
static volatile int count = 0;
void increment() {
count++;
}
接下来,我们将创建两个线程t1和t2。这些线程调用上述增量操作一千次:
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
for(int index=0; index<1000; index++) {
increment();
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
for(int index=0; index<1000; index++) {
increment();
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
从上面的程序中,我们可以预期count变量的最终值将是 2000。但是,每次我们执行程序时,结果都会有所不同。有时,它会打印“正确”值 (2000),有时不会。
让我们看一下运行示例程序时获得的两个不同输出:
value of counter variable: 2000
value of counter variable: 1652
上述不可预测的行为是因为两个线程都在对共享计数变量执行增量操作。如前所述,增量操作不是原子的。它执行三个操作 – 读取、更新,然后将变量的新值写入主内存。因此,当t1和t2同时运行时,这些操作很有可能发生交错。
假设 t1 和t2同时运行,t1对计数变量执行增量操作。但是,在将更新的值写回主内存之前,线程t2会从主内存中读取count变量的值。在这种情况下,t2将读取较旧的值并对其执行增量操作。这可能会导致将计数变量更新到主内存的值不正确。因此,结果将与预期的不同——2000年。
7. 结论
在本文中,我们看到将共享变量声明为volatile并不总是线程安全的。
我们了解到,为了提供线程安全并避免非原子操作的竞争条件,使用同步方法或块或原子变量都是可行的解决方案。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· AI 智能体引爆开源社区「GitHub 热点速览」
· 从HTTP原因短语缺失研究HTTP/2和HTTP/3的设计差异
· 三行代码完成国际化适配,妙~啊~