Java 并发编程之Volatile原理剖析及使用
在开始介绍Volatile
之前,回顾一下在并发中极其重要的三个概念:原子性,可见行和有序性
- 原子性: 是指一个操作不可以被中断.比如赋值操作
a=1
和返回操作return a
,这样的操作在JVM中只需要一步就可以完成,因此具有原子性,而想自增操作a++
这样的操作就不具备原子性,a++
在JVM中要一般经历三个步骤:- 从内存中取出a.
- 计算a+1.
- 将计算结果写回内存中去.
- 可见性: 一个线程对于共享变量的修改,能够及时地被其他线程看到.
- 有序性: 程序执行的顺序按照代码的先后逻辑顺序执行.
只有同时保证了这三个特性才能认为操作是线程安全的.
在Java中,volatile
是轻量级的Synchronized
,在并发编程中保证了共享变量的可见性,与synchronized
块相比,volatile
变量所需的编码较少,并且运行时开销也较少,但是它所能实现的功能也仅是 synchronized
的一部分,想在程序中用volatile
代替锁,一定要谨慎再谨慎(最好还是不要用,确实容易出错).
volatile保证可见性的原理
在X86处理器通过工具获取JIT编译器生成的汇编指令来查看对volatile
修饰变量进行写操作时,CPU会做什么事情.
Java代码如下
instance = new Singleton(); //instance是volatile变量
转变为汇编代码如下.
0X01a3deld: movd $0X0,0X1104800(%esi);0x01a3de24: lock add1 $0X0,(%esp)
在对volatile
修饰的共享变量进行写操作的时候多出了0x01a3de24: lock add1 $0X0,(%esp)
这行代码, 这里的Lock
前缀的指令是实现可见性原理的关键.
Lock
前缀指令在多核处理器中会引发两件事情:
- 将当前处理器缓存行的数据写回到系统内存
- 这个写回内存的操作会使其他CPU里缓存了该内存地址的数据无效.
所有的变量都存储在主内存中,为了提高程序执行速度,线程拥有自己的工作内存,工作内存存储在高速缓存或者寄存器中,保存了该线程使用的变量的主内存副本拷贝。但是这样便会带来缓存一致性问题,解决了缓存一致性问题,也就解决了可见性问题.
缓存一致性:如果多个缓存共享同一块主内存区域,那么多个缓存的数据可能会不一致。
线程只能直接操作工作内存中的变量,不同线程之间的变量值传递需要通过主内存来完成。
volatile关键字如何保证可见性(解决缓存一致性问题)
写volatile
变量时:
- JMM会把该线程对应的本地内存中的共享变量值立即刷新到主内存中.
对应volatile的第一条实现原则—Lock
前缀指令会引起当前处理器缓存行的数据写回到系统内存
读volatile
变量时:
- JMM会把其他线程中该volatile变量对应的本地内存置为无效,然后将主内存最新的共享变量刷新到本地内存中来.
对应volatile的第二条实现原则—一个处理器的缓存会写到主内存中会导致其他处理器的缓存无效(使用嗅探技术保证)
如何使用volatile关键字
只能在有限的一些情形下使用 volatile 变量替代锁。要使 volatile 变量提供理想的线程安全,必须同时满足下面两个条件:
- 对变量的写操作不依赖于当前值。
- 该变量没有包含在具有其他变量的不变式中。
两种常见错误
最初使用volatile
关键字的时候,大家可能最常见的就是第一种错误了.
class VolatileExample{
private volatile int value;
public void add(){
value++;
}
public int get(){
return value;
}
}
public static void main(String[] args) throws InterruptedException, ExecutionException {
VolatileExample volatileExample = new VolatileExample();
ExecutorService executorService = Executors.newCachedThreadPool();
CountDownLatch countDownLatch = new CountDownLatch(100);
for (int i = 0; i <100; i++) {
executorService.execute(()->{
volatileExample.add();
countDownLatch.countDown();
});
}
countDownLatch.await();
executorService.shutdown();
System.out.println(volatileExample.get());
}
代码结果输出
98
问题分析:
像vaule++
这样的操作并不是原子的,即使被volatile
修饰了依旧不是原子操作.假如线程A从主内存中读取value=10
,随后线程B也从主内存中读取value=10
,线程A执行value++
,线程B执行value++
,线程A将value=11
写入主内存,线程B也将value=11
写入主内存,最终主内存中value=11
,而不是value=12
.像这种初级失误是一定要避免的.
下面演示了一个非线程安全的数值范围类,违反了第二个条件。它包含了一个不变式 —— 下界总是小于或等于上界。
@NotThreadSafe
public class NumberRange {
private volatile int lower, upper;
public int getLower() { return lower; }
public int getUpper() { return upper; }
public void setLower(int value) {
if (value > upper)
throw new IllegalArgumentException(...);
lower = value;
}
public void setUpper(int value) {
if (value < lower)
throw new IllegalArgumentException(...);
upper = value;
}
}
问题分析:
这种方式限制了范围的状态变量,因此将 lower 和 upper 字段定义为 volatile 类型不能够充分实现类的线程安全;从而仍然需要使用同步。否则,如果凑巧两个线程在同一时间使用不一致的值执行 setLower 和 setUpper 的话,则会使范围处于不一致的状态。例如,如果初始状态是 (0, 5),同一时间内,线程 A 调用 setLower(4) 并且线程 B 调用 setUpper(3),显然这两个操作交叉存入的值是不符合条件的,那么两个线程都会通过用于保护不变式的检查,使得最后的范围值是 (4, 3) —— 一个无效值。至于针对范围的其他操作,我们需要使 setLower() 和 setUpper() 操作原子化 —— 而将字段定义为 volatile 类型是无法实现这一目的的。
正确使用示范
讲一种最常用也是最不容易出错的使用方式—将volatile
变量作为状态标志使用
volatile boolean shutdownRequested;
...
public void shutdown() { shutdownRequested = true; }
public void doWork() {
while (!shutdownRequested) {
// do stuff
}
}
当前线程一直在执行doWork()
方法,假如这个时候另一个线程调用shutdown()
方法将shutdownRequested
设置为true
,当前线程本地内存的shutdownRequested
拷贝副本马上失效,需从主内存中重新读取,读取到shutdownRequested
为true
,立即停止工作.
Java 并发编程(一)Volatile原理剖析及使用
Java 并发编程(二)Synchronized原理剖析及使用
Java 并发编程(三)Synchronized底层优化(偏向锁与轻量级锁)
Java 并发编程(四)JVM中锁的优化
Java 并发编程(五)原子操作类