前言
在讲述 Volatile 关键字之前,咱们先大略讲一下 cpu 多核并发缓存架构,再到 JMM,即 java 内存模型,最初到 volatile 关键字。
JMM(Java 内存模型)
多核并发缓存架构的引入
为了解决 CPU 和主内存速度交互的不匹配问题,计算机在设计的时候在两头加几级缓存(个别放在 CPU 外部的,这里是为了难看画到两头了),高速缓存读取速度十分快,CPU 和高速缓存交互,程序完结后,会把缓存中的数据同步到主内存再回写到硬盘。
而 Java 线程的内存模型和 CPU 缓存模型是相似的,是基于 CPU 缓存模型建设起来的。Java 线程的内存模型是标准化的,屏蔽掉了底层不同计算机的区别。如下图显示:
和 CPU 一样,线程 A 为了解决跟主内存速度不匹配问题,会把这个共享变量 copy 到线程的工作内存。线程读取共享变量数据是和工作内存中变量的正本做交互。这里的工作内存相似于缓存。
JMM 数据原子操作
JMM 有 8 个原子操作,依照应用流程来排序,别离如下:
在这里介绍 java 的数据原子操作,是为了更好的为上面的问题铺垫。
CPU 缓存不统一问题
对于多核 CPU 而言,当共享变量同时被加载到缓存中并在多个外围中都同时进行操作时,当外围 A 批改了变量 a 后,外围 B 不晓得 a 曾经做了批改,持续推动外围 B 的线程工作,这样子,程序就会呈现问题,因而就存在了缓存不统一问题。
为了解决 CPU 缓存不统一问题,工程师次要应用了两种形式。晚期次要应用总线加锁形式。
总线加锁:即 cpu 从主内存读取数据到高速缓存,会在总线对这个数据加锁,这样其余 cpu 外围没有方法去读或者写这个数据,直到这个 cpu 应用完数据并开释锁之后,其余 cpu 外围能力读取该数据。该形式能够用 java 内存模型和 java 数据原子操作来体现,如下图:
当一个线程读取主内存中某个变量的时候,就会对这个变量加锁,其余 CPU(线程)想从主内存读这个变量的数据是读不到的,直到这把锁开释了能力读取到该变量的值,并在其余 CPU 中做运算。在 read 之前会执行 lock 操作,标识为线程独占状态,在 write 写回主内存的时候会做个 unlock 操作,解锁后其余线程能够锁定该变量。晚期的 CPU 为了解决可见性,一致性问题,把一个并行执行的程序最终变成串行执行。
显然该计划不可行,起初,工程师应用了 MESI 缓存一致性协定来解决该问题
MESI 缓存一致性协定:多个 cpu 从主内存读取同一个数据到各自的高速缓存中,当其中某个 cpu 批改了缓存里的数据,该数据会马上同步回主内存,其余 cpu 通过总线嗅探机制能够感知到数据的变动从而将本人缓存里的数据生效.
如下图:
CPU 和内存之间通过总线相连接。各个线程都从主内存中读数据,实现了并行。当线程 2 批改 initFlag 变量后,执行 store 操作时,此时会把这个工作内存中批改的数据 initFlag=true 变量的值回写到主内存中,最初执行 write 替换主内存中的值。一旦执行 store 此原子操作,该数据会通过总线回写到主内存,MESI 缓存一致性协定有个 CPU 总线嗅探机制(通过硬件实现):当其中某个线程(这里是线程 2)把批改的变量的值从工作内存往主内存回写的时候,只有数据通过总线,其余的 CPU(这里是线程 1)会对这个总线做一个监听,对总线中感兴趣的变量一直监听数据流动,发现有其余 CPU(这里是线程 1)感兴趣的变量的时候,MESI 缓存一致性协定就会通过总线嗅探机制把这个其余 CPU(这里是线程 1)中工作内存中的同一个变量的值置为有效。而后线程 1 再执行循环操作的时候,发现 initFlag 生效了,就从新从主内存去 readinitFlag,而此时主内存中的 initFlag 曾经被批改过了(为 true),线程 1 就能拿到最新的值了。就能通过 MESI 缓存一致性协定和总线嗅探机制能够让程序达到缓存一致性。
java 代码演示不可见性
说完了 CPU 缓存不统一解决方案,接下来,咱们通过 java 代码演示一下多线程下缓存不一致性的问题,也称为不可见性。
public class JMM {
private static boolean initFlag = false;
public static void main(String[] args) throws InterruptedException {new Thread(() -> {while (!initFlag) { }
System.out.println("hello...");
}).start();
TimeUnit.SECONDS.sleep(2);
new Thread(() -> {System.out.println("......");
initFlag = true;
System.out.println("批改胜利....");
}).start();}
}
``````
查看输入后果:代码运行后后果只输入线程二的信息。次要起因在于两个核 CPU 不可见性。![](https://upload-images.jianshu.io/upload_images/23140115-ad3571b4abd1a88d.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
![](https://upload-images.jianshu.io/upload_images/23140115-288a10443772abc4.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
能够看出,多线程状况下,java 代码的共享变量 initFlag 也存在不可见性,那么,java 是怎么解决缓存不统一问题的呢?引入了 Volatile 关键字
# Volatile 的作用
咱们通过应用 volatile 润饰变量 initFlag, 查看代码运行状态。
public class JMM {
private static volatile boolean initFlag = false;
public static void main(String[] args) throws InterruptedException {new Thread(() -> {while (!initFlag) { }
System.out.println("hello...");
}).start();
TimeUnit.SECONDS.sleep(2);
new Thread(() -> {System.out.println("......");
initFlag = true;
System.out.println("批改胜利....");
}).start();}
}
线程 2 对 initFlag 的批改,线程 1 中的 initFlag 是能够感知到的,即 java 的关键字 volatile 能够解决缓存一致性问题。![](https://upload-images.jianshu.io/upload_images/23140115-1382dbc7f33eea02.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
# 那 volatile 是如何解决缓存一致性问题的呢?Volatile 缓存可见性实现原理:底层实现次要是通过汇编 lock 前缀指令,该指令会锁定这块内存区域的缓存(缓存行锁定)并写回主内存。IA-32 架构软件开发者手册对 lock 指令的解释:1)会将以后处理器缓存行的数据立刻写回零碎内存
2)这个写回内存的操作会引起其余 CPU 里缓存了该内存地址的数据有效(MESI)# 看不懂下面在说什么?没关系,记住 3 点一共:1)将以后处理器缓存行的数据立刻写回零碎内存
2)这个写回内存的操作会引起其余 CPU 里缓存了该内存地址的数据有效(MESI)3)在 store 前加锁 lock,write 后 unlock
通过对下面的 Java 程序转为汇编代码查看(之前看 b 站的老师转过,具体我也没转,挺麻烦的,这里保留了他的截图)![](https://upload-images.jianshu.io/upload_images/23140115-d1ff66f90607a056.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
![](https://upload-images.jianshu.io/upload_images/23140115-16020cb12ac36d0a.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
# Java 实现缓存一致性问题
由 Java 内存模型能够看到,参考了 CPU 的缓存模型,因而多核多线程状况下存在缓存一致性问题。由第 5 点可知,java 在解决缓存一致性问题的时候,应用了 volatile 关键字进行解决。那么,java 是如何通过实现 volatile 解决缓存不统一问题呢?java 参考了 CPU 解决思路,同时把总线加锁和 MESI 缓存一致性协定进行了联合,联合 MESI 缓存一致性协定的加锁的实现 = volatile,也就解决了缓存一致性问题。具体如下:线程 1 和线程 2 能够同时从主内存中读取共享变量 initFlag 到各自工作内存,而后调用各种执行引擎解决该变量,而对该共享变量加上 volatile 指令后,在线程二执行 initFlag= true 的时候,会加上 lock 的前缀汇编指令,该指令使得 CPU 底层会把批改的工作内存正本变量的值立刻写回零碎内存。而且这个数据通过总线时,让 CPU 总线上 MESI 缓存一致性协定以及联合 CPU 总线嗅探机制让其余 CPU 缓存外面那个雷同的正本变量生效,同时会锁定这块内存区域的缓存(也就是行将 store 到内存区域的时候先锁一下),在 store 回主内存的时候,会先做个 lock 操作,而后回写完了后,做一个 unlock(write 后)操作。这样子,就能够解决缓存一致性问题了。# 和总线加锁的区别
volatile 的底层实现是:联合 MESI 缓存一致性协定的加锁的实现,该实现和总线加锁的区别在哪里?volatile 把这个锁的密度大大减小,性能十分高,一开始 read 的时候各个 CPU 都能 read,然而在回写主内存的时候其余 CPU 没法运算。若 volatile 不加 lock 操作和 unlock 操作的话,只应用缓存一致性协定和总线嗅探机制,是否有问题?? 不加 lock,数据刚往总线这边同步(即刚刚回写主内存),这个数据还没写到主内存中的变量中(即这个变量 initFlag 还没改为 true),而其余 CPU 通过 MESI 缓存一致性协定外面的总线嗅探机制监听到这个 initFlag 的值的变动,马上把其余线程中的工作内存的值生效。而其余 CPU(线程 1)还在继续执行 while 操作,发现 initFlag 生效,就马上从主内存中读 initFlag, 这个线程 2 还没马上把 initFlag 批改过的值写到主内存,因而此时其余 CPU(线程 1)读的还是原来的老数据。所以 lock 前缀指令必须对 store 之前加一把锁,在真正 write 到主内存后,再去把这把锁开释掉,就是为了避免数据还是有些误读(时间差的问题),这个锁的密度十分小,只是对主内存赋一个值,对内存操作,速度得多块,内存级别的并发量至多每秒几十万上百万的操作。只是做变量地址的赋值操作,在这么一个短时间内加一把锁十分快!!!# volatile 不保障原子性
讲到这了,大家应该都分明并发编程三大个性:可见性、原子性、有序性
而 Volatile 能够保障可见性和有序性,然而不能保障原子性,原子性能够借助 synchronized 的锁机制或者并发包下的原子类进行解决,这个原子性下一篇博客会进行总结。代码演示一下 volatile 不保障原子性。
public class VolatileAtomicTest {
public static volatile int num = 0;
public static void increase() {num++;// num = num + 1}
public static void main(String[] args) throws InterruptedException {Thread[] threads = new Thread[10];
for (int i = 0; i < threads.length; i++) {threads[i] = new Thread(new Runnable() {
@Override
public void run() {for (int j = 0; j < 100000; j++) {increase();
}
}
});
threads[i].start();}
for (Thread t : threads) {t.join();
}
System.out.println(num) ;// 后果是小于或等于 1000000
}
}
![](https://upload-images.jianshu.io/upload_images/23140115-21020e4f45d5bb7d.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
不保障原子性起因
线程 1 做完 ++ 操作,而后一旦做完 assign 操作后,就会写主内存。然而呈现一种状况,当线程 1 做完 ++ 后,刚 assign 值的时候,这个回写操作还没做的时候,线程 2 也做了 num++ 了,同时也 assign 完结了,两个线程就同时向主内存回写。谁先回写的(哪个线程的数据先达到总线),那个线程就会通过 volatile 关键字给该数据加一把锁,后达到的回写的操作看到该数据有锁之后,就不能加锁了,同时线程 1 加锁胜利了后,执行 store 的时候数据通过总线,MESI 缓存一致性协定联合 CPU 总线嗅探机制把线程 2 的工作内存的值生效掉。那么线程 2 做的 num++ 的操作曾经没有意义了(失落了),下次线程 2 再做 num++ 的时候,从新从主内存中 read 到这个线程 1 写回的值。这个时候上一次线程 2 做的 num++ 的操作失落了,也就失落了一次加 1 操作。网络上有些博客说是因为 i ++ 不是一个原子操作,然而我更感觉这种形式才是解释为什么不保障原子性的根本原因。![](https://upload-images.jianshu.io/upload_images/23140115-c761a366cc990d6c.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
# volatile 保障有序性
volatile 次要是通过内存屏障来避免指令重排达到解决有序性问题!# 最初