并发编程实战-JMM线程内存模型

大家好,最近呢我对并发编程展现出了兴趣(没办法,别人都会你不会说不过去啊),然后我就要奋发图强学好并发编程,那么接下来让我们一起进入学习吧。我们在学习并发编程实战之前,应该先要了解一下我们的Java 内存模型,因为你如果连java内存模型都不会的话,这就说不过去了吧。

1、CPU并发缓存架构

大家好,接下来我们就要分享一下Java的内存模型,在分享java内存模型之前,我们需要了解一下多核并发缓存架构,也就是我上一节讲的java mesi缓存一致性策略,这里只是带大家复习一下。

image-20220107235709600

我们这次分享的是JMM缓存模型,那么在这之前呢,我们得知道CPU的多核的一个缓存架构,如果看过我上一篇文章的小伙伴,看这个图肯定可以看懂的,在之前CPU是没有这个缓存的,之后为什么出现呢,是因为cpu和主内存的速率不匹配,用更加高昂的材料制造了我们缓存,他的速度和我们cpu是差不多的,当然大部分情况下,是不如我们的cpu的运算速度的,只是能让他很快很快,快到比光速还快。上面的这个图,可以叫做cpu的硬件缓存模型。为了解决cpu和主内存的速率不匹配问题,cpu也就诞生多级缓存,一般分好几级。

那么我们的缓存是怎么执行的,cpu在操作数据的时候,会把我们主内存的数据,放到缓存中执行,也就是说在读写的时候,会在缓存操作我们的内存数据,其实在mysql中 buffer pool 不也是这样的吗。

2、JAVA 线程内存模型

在我们刚才了解了cpu的缓存模型后,我们再看Java的线程内存模型是很easy的。其实他就是基于cpu缓存来建立的,Java线程内存模型是标准化的,它屏蔽了底层不同计算机的区别。

image-20220108105330818

上面呢,我画了一张图,这张图我们有很多的线程,比方说我们有多个线程,我们线程要访问变量的话,其实呢,他访问的是主内存,这个时候会拿到共享变量的副本,然后放入到线程的工作内存中。在线程运算的时候,其实他是和共享变量的副本来进行做交互的,那么这个时候可能就有问题了,假设我们现在有个变量 flag = false,线程A修改了flag 为 true,但是线程B中的flag还是为false,这个时候其他线程还是看不到 ,这样就会导致多线程操作同一共享变量的时候,数据不一致的情况。

我们感受一下代码。

image-20220108110258116

我们可以看到上述代码中,我们启动了两个线程,操作同一个共享变量,第一个线程循环判断是不是true,如果是true就不循环了。

我们现在执行一次试验一下,看看结果如果,我们要验证的就是多线程操作同一共享变量的时候,每个线程的共享变量副本被修改了,他们之间是否可见的问题。

image-20220108110443615

我们发现程序没有停止,但是我们数据已经准备好了,也就是flag的值已经被修改了,这个时候就会出现内存不可见问题。

我们看了一次这个执行程序之后,我们也就对JMM线程执行模型,也就了解了。

那么这个运行结果和我们的预期不符,这个时候我学习java的时候知道java有一个关键字,也就是 volatile 可以保证线程间共享变量的内存可见性问题。那么我们试验一下。

image-20220108111624156

这个时候我们可以看到程序执行完了,那么这个时候假如面试官问你,我们应该都会知道volatile可以保证内存可见性,那么你能深入的讲解一下volatile的内存可见性的底层原理吗?然后你就傻眼了,你说我不会,面试官说,好吧,下一位,慢走不送。

3、JAVA 内存模型的原子操作

在我们想要深入理解volatile的时候需要了解一下,java的原子操作有哪些:

1. read(读取):从主存中读取数据
2. load(载入):将主存读取到的数据写入工作内存中
3. use(使用):从工作内存读取数据做计算
4. assign(赋值):将计算好的值重新赋值到工作内存中
5. store(存储):将工作内存数据写入主存
6. write(写入):将store过去的变量值赋值给主存中的变量(更新主存)
7. lock(锁定):将主存变量加锁,标识为线程独占状态
8. unlock(解锁):将主存变量解锁,解锁以后其它线程就可以锁定该变量。

我们光看可能不好理解,我们看图吧。

image-20220108151215955

这个图比较乱,各位大佬可能看的有些不明所以,没关系,我们以上面我们写的代码来讲解一下,为什么程序没有按照我们的想法来执行,来看一下。

首先我们的主内存中有一个flag共享变量,他的值是false,接着线程1来进行read 我们的flag共享变量,然后load到线程的私有工作内存中,线程1开始使用这个变量,并且while循环起来了。

线程2 这个时候,也进行read flag共享变量,load到线程2的工作内存中,线程也开始use使用这个共享变量的副本。并且做了一个assign的操作,将flag赋值为true,接着我们将修改好的值,store到主内存中,然后通过write写入主内存中的flag共享变量,这个时候flag的值就是true了。

但是线程1的flag还是false,所以线程会一直执行。那么有个问题,假如没有加sleep会发生什么呢?

4、volatile关键词详解

4.1 内存可见性

我们刚才分析了java 内存模型的原子操作,那么为什么volatile 可以保证内存可见性呢。接下来我们就进入深入的分析一番。

我们原子操作少讲了两个原子操作,一个是lock,一个是unlock。

那么我们在执行这个volatile程序的时候,假如把程序的汇编打出来,能看到lock吗。

image-20220108154358180

很漂亮,我们看到了lock 前缀指令。那么这个lock前缀指令代表了什么意思呢?

IA-32架构软件开发者手册对lock前缀指令的解释:

1、会将当前处理器缓存行的数据 立即 写回系统内存

2、这个写会内存的操作会引起其他cpu里面假如也缓存了这个这个缓存行的数据无效,如果看过MESI缓存一致性的话。那个其他缓存行的状态也就是I 状态。采用了MESI缓存一致性协议,以及总线嗅探机制。

那么我们就看看运行流程到底是怎么样的。

image-20220108201057338

上面呢,是我画的一个图,这个图比较乱,我们分步骤来讲解一下这个图什么意思:

  1. 主内存有一个flag共享变量的值为false
  2. 线程1通知总线我要flag变量 这就是read操作
  3. 然后总线给到线程1,flag共享变量,将值load到工作内存。
  4. 接着线程执行代码use这个线程1的共享变量,发现一直是false也就是死循环等待了
  5. 线程2通知总线我要flag变量 这就是read操作
  6. 然后总线给到线程1,flag共享变量,将值load到工作内存。
  7. 接着线程2执行代码use这个线程2的共享变量
  8. 接着线程2对这个工作内存中的线程2执行assign赋值为true
  9. 因为这个变量是被lock前缀修饰的,也就是说在执行store之前会进行lock加锁操作,这个时候谁也不能操作,因为我对这个缓存行加锁了,所以谁也不可以操作。
  10. 然后写入内存,并且将true的值write到flag中
  11. 此时线程1通过cpu总线嗅探机制,发现flag变量被修改了,线程1的flag也就修改状态为无效
  12. 然后线程1向总线发起read,获取数据,总线发现线程2 有一个最新的值,这个时候就给到线程1,线程1 load到工作内存,use他,然后发现是true,此时也就退出了while循环
  13. 程序到此结束。

4.2 禁止指令重排

计算机在执行程序的时候, 为了提高性能, 编译器和处理器会对指令进行重排序.

1.单线程环境, 重排序能保证程序的最终结果和顺序执行的结果一致

2.处理器处理重排序必须考虑指令之间的数据依赖性(如b依赖a, 不能将b排序在a之前执行)

3.多线程调度过程中, 由于重排序存在, 两个线程的变量无法保证一致性

java编译器会在生成指令系列时在适当的位置会插入内存屏障来禁止特定类型的处理器重排序。

volatile写操作是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障

image-20220108203400418

image-20220108203423724

4.3 不保证原子性

单个基本数据类型的基本运算可以看作时保证原子性的,但多个基本数据类型运算不能保证。

比如:

int j=1;//可保证

int z=j;//不能保证

z++;//不能保证

基本原理:当多个线程对同一使用volatile定义的共享变量进行num++时:

执行num++需要进行以下步骤:

1、线程读取num		2、temp = num + 1 		3、num = temp

首先线程a,b读取到num=0,

线程a开始执行temp=num+1,线程b开始执行temp=num+1,此时线程a,b的私有工作内存中num=0,temp=1,

此时a执行num=temp,由于MESI缓存一致性与cpu总线嗅探机制(监控机制),

此时num的值会立即刷新到主存并通知其他线程保存的 num 值失效,

此时B线程需要重新读取 num 的值那么此时B线程保存的 num 就是1,

同时B线程保存的 temp 还仍然是1, 然后B线程执行 num=temp ,所以导致了计算结果比预期少了1

解决方法:

1.在方法体中加上synchronized,不推荐性能损耗太大

2.利用java.util.concurrent.atomic.AtomicInteger类

总结

今天我们介绍了Cpu的并发缓存架构,以及jmm线程内存模型。以及一些java内存模型的原子操作。jmm保证的其实是在并发编程的时候数据的原子性、有序性、可见性。

我们又详细介绍了volatile关键字,以及他的核心原理,相信大家对该关键字有了更加深入的了解。不会被面试官说,慢走不送,下一位了。

借鉴:

https://blog.csdn.net/qq_43357394/article/details/109592265

https://blog.csdn.net/qq_36669347/article/details/107561874

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值