volatile被称之为轻量级的synchronized,即通过无锁的方式保证可见性,而本文将通过自顶向下的方式深入剖析这个关键字的底层实现,希望对你有帮助。
详解volatile关键字
1.共享变量操作不可见范例
我们编写一段多线程读写一个变量的代码,t1一旦感知num被t2修改,就会结束循环,然而事实却是这段代码即使在t2完成修改之后,t1也像是感知不到变化一样一直无限循环阻塞着:
private static int num = 0;
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(2);
Thread t1 = new Thread(() -> {
while (num == 0) {
}
log.info("num已被修改为:1");
countDownLatch.countDown();
});
Thread t2 = new Thread(() -> {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
num++;
log.info("t2修改num为1");
countDownLatch.countDown();
});
t1.start();
t2.start();
countDownLatch.await();
log.info("执行结束");
2..volatile保证可见性
于是我们将代码增一个本文所引出的关键字volatile 加以修饰:
private volatile static int num = 0;
对应的我们给出输出结果,如预期一样线程修改完之后线程1就会感知到变化而结束循环:
23:54:04.040 [Thread-0] INFO MultiApplication - num已被修改为:1
23:54:04.040 [Thread-1] INFO MultiApplication - t2修改num为1
23:54:04.042 [main] INFO MultiApplication - 执行结束
3.详解volatile工作原理
volatile底层实现和JMM内存模型息息相关,该模型规范了线程的本地变量(各个线程拿到共享变量num的副本)和主存(内存中的变量num)的关系,其规范通过happens-before等规约强制规范了JVM需要针对这几个要求要做出不同的处理来配合处理器保证共享变量操作的可见性和有序性,这一点感兴趣的读者可以移步下面这篇文章了解一下JMM内存规范和避免指令重排序的实际落地实现:《从零开始理解 Java 内存模型——可见性与有序性详解》
按照JMM模型抽象的各种happens-before及其内存模型8大操作:volatile的变量的写操作, happens-before后续读该变量的代码。
这就要求t1和t2修改num的时候,都必须从主存中先加载才能进行修改,以上述代码为例,假设t1修改了num的值,完成后就必须将最新的结果写回主存中,而t2收到这个修改的通知后必须从主内存中拉取最新的结果才能进行操作:
上述这个流程只是JMM模型的抽象,也就是JVM便于让程序员理解的一种模型,不是实际的实现, 对应的我们通过jitwatch查看volatile修饰的变量num进行累加的代码:
private volatile static int num = 0;
public static void main(String[] args) throws InterruptedException {
num++;
}
从汇编码可以看出,汇编指令用到了一个lock的关键字,这就是保证并发编程可见性的关键:
0x00000000038ca0a1: lock addl $0x0,(%rsp) ;*putstatic num
; - org.example.Main::main@5 (line 10)
0x00000000038ca0a6: mov 0x68(%r10),%r11d
0x00000000038ca0aa: inc %r11d
0x00000000038ca0ad: mov %r11d,0x68(%r10)
0x00000000038ca0b1: lock addl $0x0,(%rsp) ;*putstatic num
通过查IA-32架构软件开发者手册可知,Lock前缀的指令在多核处理器下会引发了两件事情:
- 将当前变量num从当前处理器的缓存行(cache-line)写回内存。
- 通知其他处理器该变量已被修改,其他处理器cache-line中的num值全部变为invalid(无效)。
这也就是我们Intel 64著名的MESI协议,将该实现代入我们的代码,假设线程1的num被CPU-0的处理,线程2被CPU-1处理,实际上底层的实现是:
- t1获取共享变量num的值,此时并没有其他核心上的线程获取,状态为E(exclusive)。
- t2启动也获取到num的值,此时总线嗅探到另一个CPU也有这个变量的缓存,所以两个CPU缓存行都设置为S(shard)。
- t2修改num的值,通过总线嗅探机制发起通知,t1的线程收到消息后,将缓存行变量设置为I(invalid)。
- t1需要输出结果,因为看到自己变量是无效的,于是通知总线让t1将结果写回内存,自己重新加载。
更多关于MESI协议的实现细节,感兴趣的读者可以参考笔者的这篇文章:《CPU 缓存一致性问题深度解析》
volatile如何禁止指令重排序
而volatile不仅可以保证可见性,还可以避免指令重排序,底层同样是通过JMM规约,禁止特定编译器进行有风险的重排序,以及在生成字节序列时插入内存屏障避免CPU重排序解决问题。
我们不妨看一段双重锁校验的单例模式代码,代码如下所示可以看到经过双重锁校验后,会进行new Singleton();
public class Singleton {
private static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
这一操作,这个对象创建的操作乍一看是原子性的,实际上编译后再执行的机器码会将其分为3个动作:
- 为引用uniqueInstance分配内存空间
- 初始化uniqueInstance
- uniqueInstance指向分配的内存空间
所以如果没有volatile 禁止指令重排序的话,1、2、3的顺序操作很可能变成1、3、2,进而可能出现下面这种情况:
- 线程1执行步骤1分配内存空间。
- 线程1执行步骤3让引用指向这个内存空间。
- 线程2进入逻辑判断发现uniqueInstance不为空直接返回,导致外部操作异常。
极端情况下,这种情况可能导致线程2外部操作到的可能是未初始化的对象,导致一些业务上的操作异常:
所以针对这种情况,我们需要增加volatile 关键字让禁止这种指令重排序:
private volatile static Singleton uniqueInstance;
按照JMM的happens-before原则volatile的变量的写操作, happens-before后续读该变量的代码,这就会使的volatile操作可能实现如下几点:
- 第二个针对volatile写操作时,不管第一个操作是任何操作,都不能发生重排序。
- 第一个针对volatile读的操作,后续volatile任何操作都不能重排序。
- 第一个volatile写操作,后续volatile读,不能进行重排序。
因为这套规范,在编译器生成字节码时,就会通过内存屏障的方式告知处理器禁止特定的重排序:
- 每个volatile写后插入storestore,让第一个写优先于第二个写,避免重排序后的写(可以理解未变量计算)顺序重排序导致的计数结果异常。
- 每个volatile写后插入storeload,让第一个写先于后续读,避免读取异常。
- 每个volatile读后加个loadstore,让第一个读操作先于第二个写,避免读写重排序的异常。
- 每个volatile读后加个loadload,让第一个读先于第二个读,避免读取顺序重排序的异常。
volatile无法保证原子性
我们不妨看看下面这段代码,首先我们需要了解一下num++这个操作在底层是如何实现的:
- 读取num的值
- 对num进行+1
- 写回内存中
对应的汇编码如下:
0x00000000038ca096: mov 0x68(%r10),%r8d
0x00000000038ca09a: inc %r8d
0x00000000038ca09d: mov %r8d,0x68(%r10)
我们查看代码的运行结果,可以看到最终的值不一定是10000,由此可以得出volatile并不能保证原子性
public class VolatoleAdd {
private static int num = 0;
public void increase() {
num++;
}
public static void main(String[] args) {
int size = 10000;
CountDownLatch downLatch = new CountDownLatch(1);
ExecutorService threadPool = Executors.newFixedThreadPool(size);
VolatoleAdd volatoleAdd = new VolatoleAdd();
for (int i = 0; i < size; i++) {
threadPool.submit(() -> {
try {
downLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
volatoleAdd.increase();
});
}
downLatch.countDown();
threadPool.shutdown();
while (!threadPool.isTerminated()) {
}
System.out.println(VolatoleAdd.num);//9998
}
}
而对应的解决方案我们可以通过synchronized、原子类、或者Lock相关实现类解决问题。
并发编程中三个重要特性是什么知道吗?
即原子性、一致性、可见性:
- 原子性:一组操作要么全部都完成,要么全部失败,Java就是基于synchronized或者各种Lock实现原则性。
- 可见性:线程对于某些变量的操作,对于后续操作该变量的线程是立即可见的。Java基于synchronized或者各种Lock、volatile实现可见性,例如声明volatile变量这就意味着Java代码在操作该变量时每次都会从主内存中加载。
- 有序性:指令重排序只能保证串行语义一致性,并不能保证多线程情况下也一致,Java常常使用volatile禁止指令进行重排序优化。