深入理解并发编程之volatile与JMM内存模型
文章目录
高并发的三大特性:
- 原子性: 即一个操作或者多个操作(i++),要么全部执行并且不被打断,要么就都不执行。
- 可见性: 当一个线程修改了共享变量的值,其他线程会马上知道这个修改。
- 有序性: 虚拟机在进行代码编译时,对于那些改变顺序之后不会对最终结果造成影响的代码,虚拟机不一定会按照我们写的代码的顺序来执行,有可能将他们重排序。
JVM内存模型概念
Java内存模型(Java Memory Model,JMM)用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。
为什么要用JMM内存模型
从CPU角度分析:
这是一个普通的CPU,目前主流的CPU都是有三级高速缓存的,CPU为了提升执行效率,减少CPU与内存的交互(交互影响CPU效率),一般在CPU上集成了多级缓存架构,这里的三级缓存是每个CPU都有的。如下图所示:
下面再看一下访问不同位置数据的效率,从上到下访问数据的效率越来越慢。
我们再用个开发的的例子说一下为什么会出现JMM内存模型:
我们早期开发的系统是没有缓存的,而是直接读取数据库,当然这样就会有一个严重的缺点,系统访问效率很慢。后来为了提升效率就引入了缓存。
JMM是与上面的例子相似的,为了提高访问效率,JVM为所有的线程都开辟了独立的内存空间作为缓存(每个线程占用1个CPU,而且每个CPU都有自己的三级高速缓存,所以每个线程的内存空间是独立的),就形成了JMM内存模型。用CPU的高速缓存构建了每个线程自己的内存空间,线程就不需要每次去(主存)DRAM中去访问数据了,大大提高了代码的运行效率。
所以就形成了JMM的内存模型,如下图所示:
举例详细说明JMM内存模型
如下代码
public class Test001 {
//flag是共享变量,线程Thread001与main主线程之间共享的。
private static boolean flag = false;
static class Thread001 extends Thread {
@Override
public void run() {
while (true){
if(flag){
System.out.println("子线程停止,flag=" + flag);
break;
}
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread001 thread= new Thread001();
thread.start();
Thread.sleep(1000);
flag = true;
System.out.println("主线程已经停止");
}
}
执行结果:
我们用图来说明下为什么当flag值修改后线程Thread001没有生效:
如上面的图所示,主内存中flag是false的,当两个线程运行的时候为了提高读取效率就把flag=false拷贝到了自己的内存中,但是当main线程把flag修改为true的时候,主内存也修改为了flag=true,但是这时候Thread001没有主动的去主内存中同步更新数据,所以Thread001中的flag还是false,while死循环继续执行。至于Thread001为什么没有去同步flag=true,这就涉及到了CPU的缓存一致性协议,也就是MESI协议。
MESI协议说明Thread001为什么没有同步数据
什么是MESI协议(上百度百科):
MESI协议是基于Invalidate的高速缓存一致性协议,并且是支持回写高速缓存的最常用协议之一。 它也被称为伊利诺伊州协议(由于其在伊利诺伊大学厄巴纳 - 香槟分校的发展)。 回写高速缓存可以节省很多通常在写入缓存上浪费的带宽。 回写高速缓存中总是存在脏状态,表示高速缓存中的数据与主存储器中的数据不同。 如果块驻留在另一个缓存中,则Illinois协议要求缓存在未命中时缓存传输。 该协议相对于MSI协议减少了主存储器事务的数量。
在在早期的CPU设计上不是采用的MESI协议,而是采用的总线索,在处理器提供一个 LOCK# 信号,CPU1在操作共享变量的时候会预先对总线加锁,此时CPU2就不能通过总线来读取内存中的数据了,但这无疑会大大降低CPU的执行效率。这类似于我们的lock锁悲观锁概念。
MESI协议详解:
- M 修改 (Modified) 这行数据有效,数据被修改了,和主内存中的数据不一致,数据只存在于本Cache中。
- E 独享(Exclusive) 当只有一个cpu线程的情况下,cpu副本数据与主内存数据如果保持一致的情况下,则该cpu状态为E状态 独享,数据只存在于本Cache中。
- S 共享 (Shared) 在多个cpu线程的情况了下,每个cpu副本之间数据如果保持一致的情况下,则当前cpu状态为S,数据存在于很多Cache中。
- I 无效 (Invalid) 总线嗅探机制发现状态为m的情况下,则会将该cpu改为i状态无效该cpu缓存主动获取主内存的数据同步更新。
看一下上面的问题出在了哪里,出在了i上面,也就是状态没有改变,CPU1中的flag的状态没有变为无效,所以没有去主动的同步更新数据。
JMM八大同步规范
MESI协议看上去有点绕,看不明白为啥I没有触发,这是因为JMM的八大同步协议:
- lock(锁定): 作用于 主内存的变量,把一个变量标记为一条线程独占状态。
- unlock(解锁): 作用于 主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取): 作用于 主内存的变量,把一个变量值从主内存传输到线程的 工作内存中,以便随后的load动作使用。
- load(载入): 作用于 工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用): 作用于 工作内存的变量,把工作内存中的一个变量值传递给执行引擎。
- assign(赋值): 作用于 工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量。
- store(存储): 作用于 工作内存的变量,把工作内存中的一个变量的值传送到 主内存中,以便随后的write的操作。
- write(写入): 作用于 工作内存的变量,它把store操作从工作内存中的一个变量的值传送到 主内存的变量中。
看下图,这就是JMM八大同步规范的执行过程:
其实问题就出在了上面圈起来的那几个字上面,总线嗅探机制。
总线嗅探机制: 每个处理器通过监听在总线上传播的数据来检查自己的缓存值是不是过期了,如果处理器发现自己缓存行对应的内存地址修改,就会将当前处理器的缓存行设置无效状态,当处理器对这个数据进行修改操作的时候,会重新从主内存中把数据读到处理器缓存中。
最终问题就出现在了这里,因为总线嗅探机制没有去触发嗅探功能,所以Thread001还不知道自己的数据其实已经过期了,那么我们怎么去让它主动的去触发嗅探呢,这就需要volatile关键字。
Volatile关键字
Volatile可见性分析
volatile的两个功能特性:可见性,不保证原子性和防止重排序(这个明天讲解)。
可见性是指线程内部改变共享变量,所有线程之间是可以看到的,因为加了volatile关键字的变量会使总线嗅探机制,会不断的监听总线,去查看这个变量是否发生了变化。
再看上面的代码:
public class Test001 {
//此时加上了volatile关键字
private static volatile boolean flag = false;
static class Thread001 extends Thread {
@Override
public void run() {
while (true){
if(flag){
System.out.println("子线程停止,flag=" + flag);
break;
}
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread001 thread= new Thread001();
thread.start();
Thread.sleep(1000);
flag = true;
System.out.println("主线程已经停止");
}
}
看一下执行结果,由于嗅探机制的触发使子线程意识到了flag发生了变化,主动的去同步了flag,所以死循环执行结束:
注意:大量使用Volatile关键字,如果在短时间内产生大量的cas操作在加上 volatile的嗅探机制则会不断地占用总线带宽,导致总线流量激增,会引起总线嗅探风暴。
volatile不能保证原子性分析
看下面的代码:
public class Test001 {
public static volatile int i =0;
public static void increase(){
i++;
}
public static void main(String[] args) {
CountDownLatch latch = new CountDownLatch(10);
for(int i=0;i<10;i++){
new Thread(() -> {
for (int j=0;j<100;j++){
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
increase();
}
latch.countDown();
}).start();
}
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("最终结果:"+i);
}
}
我们预期的结果是:1000,但是实际的结果是小于1000的。
先用javac编译成class文件,然后用javap把class文件编译成汇编文件来分析一下i++操作,下面的截图是i++的汇编指令:
根据汇编来看i++其实是4步操作的。咱来分析下为什么结果永远小于1000,问题出在了哪步:
首先volatile肯定保证了可见性,假设总共两个线程,由于总线嗅探机制其中一个线程做完i++操作的时候必定同步到另一个线程,但是问题来了,我i++是四步操作的,同步的时候我另一个线程正在在干什么呢,具体分析下:
- 情况1: 此时操作第一步之前获取i,因为后面的具体加的操作还没执行,这没问题,获取到的是最新的i然后++。
- 情况2: 此时操作第二步之前,1压栈操作,因为后面的具体加的操作还没执行,这也没问题获取到的是最新的i然后++
- 情况3: 此时操作第三部之前,执行i=i+1操作,因为后面的具体加的操作还没执行,这也没问题,获取到的是最新的i然后++
- 情况4: 此时操作第三部之前,也就是写回i操作,这就出现了问题了,我前面i++都操作完了,假设当前线程获取时i是5,然后i++之后i变成了6,但是另一个线程的i同步过来了,关键问题,由于可见性两个线程做加操作之前的i是一致的,其中一个线程i++操作完了i=6,同步过来了,当前线程也i++也操作完了i=6,然后同步过来了,i=6。此时如果逻辑差可能绕进去了,但是你细想,我i初始是5,执行了两次i++该是7啊,所以找到问题了,当总线嗅探机制出现在写回i的值的时候其中一个i++的操作是无效的。所以最后得到的结果永远小于1000。
内容来源:蚂蚁课堂