Java并发编程中的Volatile什么时候可以使用,什么时候不可以使用?
在Java并发编程中,volatile是一个既强大又容易被误用的关键字。它像一把轻巧的锁,提供了比synchronized更低的开销,但适用场景也更为受限。《Java并发编程实战》一书为我们指明了方向,提出了使用volatile必须同时满足的三个条件。
然而,原书中的表述对于初学者来说可能有些晦涩。本文旨在用最通俗的语言和最直观的代码,带你彻底搞懂volatile的正确用法。
核心回顾:
volatile的作用在深入探讨之前,我们先快速回顾一下
volatile的两个核心作用:
- 可见性(Visibility): 当一个线程修改了
volatile变量的值,这个新值对其他线程是立即可见的。它通过防止编译器和CPU的指令重排序、并确保修改后的值立即写回主内存来实现。- 有序性(Ordering): 在一定程度上防止指令重排序。具体来说,它能确保
volatile变量之前的代码先执行,volatile变量之后的代码后执行,但不能保证volatile代码块内部的指令不被重排序。关键点:
volatile不保证原子性。像count++这样的复合操作,它无法保证其执行过程不被打断。
现在,让我们逐一剖析使用volatile的三条黄金法则。
法则一:写入不依赖当前值,或只有单线程写入
原文
对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
通俗解读
这条法则是关于原子性的。它告诉我们,如果你对变量的操作不是“原子”的,那么volatile就帮不了你,除非你确保只有一个线程在“搞事情”(写入)。
什么是“写入不依赖当前值”?
简单的赋值操作就是,例如 running = false; 或者 value = 10;。这个操作本身就是原子的,volatile可以确保这个赋值结果能被所有线程立刻看到。
什么是“写入依赖当前值”?
最典型的例子就是 count++。这个操作实际上包含了三个步骤:
- 读取
count的当前值。 - 在读取到的值上加1。
- 将新值写回
count。
在多线程环境下,线程A可能刚读取完count(比如是10),还没来得及加1写回,线程B也读取了count(也是10)。然后A和B各自加1,都把11写回。结果是count只增加了1,而不是2。volatile无法阻止这种线程间的交错执行。
代码示例
场景一:错误使用 volatile
我们来看一个多线程计数器,这正是“写入依赖当前值”的典型场景。
1 | public class VolatileCounter { |
为什么错?
即使count是volatile的,保证了每次读取都从主内存获取最新值,但++操作的“读-改-写”过程不是原子的。多个线程可能同时读取到同一个旧值,导致部分自增操作丢失。在这种场景下,你应该使用AtomicInteger或者synchronized。
场景二:正确使用 volatile
现在来看一个适合volatile的场景:一个线程发出“关闭”信号,其他线程读取这个信号。
1 | public class ShutdownSwitch { |
为什么对?
这里,shutdownRequested变量只有一个写入者(main线程),但有多个读取者(worker线程)。写入操作shutdownRequested = true;是原子的,不依赖其当前值。volatile完美地确保了worker线程能立即看到shutdownRequested的变化,从而安全地退出循环。
法则二:变量不与其它状态变量构成“不变性条件”
原文
该变量不会与其他状态变量一起纳入不变性条件中。
通俗解读
这条法则是关于多个变量的原子性绑定的。如果一个变量的合法性依赖于另一个变量的值(它们共同构成一个“不变的规则”),那么volatile就无能为力了。
“不变性条件”(Invariant)指的是一个或多个变量之间必须始终保持的某种关系。例如,在一个表示范围的类中,lower(下界)必须始终小于等于upper(上界)。这就是一个不变性条件:lower <= upper。
如果你将lower和upper都声明为volatile,当一个线程要同时更新它们时(比如从[0, 5]更新到[3, 8]),这个更新过程不是原子的。它会分两步:
this.lower = 3;this.upper = 8;
在第一步和第二步之间,另一个线程可能会介入,读取到一个lower为3,但upper仍然是旧值5的临时非法状态。volatile只能保证单个变量的可见性,无法将多个变量的修改“打包”成一个原子操作。
代码示例
场景一:错误使用 volatile
1 | public class NumberRange { |
为什么错?
想象线程A调用setLower(4),同时线程B调用setUpper(3)。由于没有锁,它们的执行可能交错,导致最终范围变为[4, 3],这显然是错误的。volatile无法保证这两个set方法之间的原子性,也无法保护lower <= upper这个不变性条件。
场景二:正确的做法(使用synchronized)
1 | public class SafeNumberRange { |
为什么对?
通过synchronized关键字,我们确保了任何时候只有一个线程可以进入setRange或isInRange方法。当一个线程在修改lower和upper时,其他线程必须等待。这样就保证了其他线程永远不会看到lower > upper的中间状态,不变性条件得到了保护。
法则三:访问变量时不需要加锁
原文
在访问变量时不需要加锁。
通俗解读
这更像是一条工程实践原则。它的意思是:如果你在代码的某个地方无论如何都需要一个锁(比如synchronized),那么就没必要再给这个锁保护的变量加上volatile了。
synchronized块已经同时提供了可见性和原子性。当线程退出synchronized块时,它会把所有在块内修改过的共享变量的值刷新回主内存;当线程进入synchronized块时,它会从主内存重新加载共享变量的值。
所以,synchronized已经包含了volatile的内存可见性功能,并且功能更强大(还提供了原子性)。在同一个地方同时使用两者是多余的,徒增代码的复杂性。
代码示例
1 | public class RedundantVolatile { |
为什么冗余?
这里的volatile是完全多余的。synchronized(lock)已经确保了value的修改对所有后续进入synchronized(lock)块的线程都是可见的。去掉volatile,程序的正确性不会有任何改变。
总结:什么时候才应该用 volatile?
结合以上三条法则,我们可以总结出volatile的最佳使用场景:简单的、独立的、原子赋值的状态标志。
何时使用 volatile? (必须同时满足) |
何时不应使用 volatile? (任一情况) |
替代方案 |
|---|---|---|
1. 写入是原子的 (如 flag = true) 或仅由单线程写入。 |
1. 写入依赖旧值 (如 count++) 且有多线程写入。 |
java.util.concurrent.atomic.* (如 AtomicInteger) |
2. 该变量是独立的,不与其他变量构成不变性条件 (如 lower <= upper)。 |
2. 变量的有效性依赖其他变量 (如范围[lower, upper])。 |
synchronized 或 ReentrantLock |
| 3. 访问该变量的代码块本身不需要其他原因的锁。 | 3. 访问该变量的代码无论如何都需要一个锁来保护其他资源。 | synchronized 或 ReentrantLock |
最终建议volatile是优化并发性能的利器,但它是一把需要精准使用的手术刀。如果你对它的使用场景有任何一丝不确定,那么更安全、更通用的synchronized或java.util.concurrent包下的工具类(如AtomicInteger, ReentrantLock)通常是更好的选择。
希望这篇博文能帮助你彻底掌握volatile的精髓,写出更健壮、更高效的并发代码!


