JUC并发编程之JMM内存模型详解

黎明大大

共 6136字,需浏览 13分钟

 · 2021-04-09

点击“蓝字”关注我们吧


JMM内存模型概念



JMM就是Java内存模型(java memory model)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范。因为在不同的硬件生产商和不同的操作系统下,内存的访问有一定的差异,所以会造成相同的代码运行在不同的系统上会出现各种问题。所以java内存模型(JMM)屏蔽掉各种硬件和操作系统的内存访问差异,以实现让java程序在各种平台下都能达到一致的并发效果。
Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,包括实例变量,静态变量,但是不包括局部变量和方法参数。每个线程都有自己的工作内存,线程工作内存保存了该线程用到的变量和主内存的副本拷贝,线程对变量的操作都在工作内存中进行。线程不能直接读写主内存中的变量。
不同的线程之间也无法访问对方工作内存中的变量。线程之间变量值的传递均需要通过主内存来完成。


JMM内存模型详解



jmm较为简介的概念图如下:


主内存
主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,该实例对象是成员变量也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发生线程安全问题。


工作内存

主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝), 每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。(不要理解成栈帧,JMM内存模型和JVM内存模型是两码事)


每个线程的工作内存都是互相独立的,线程操作的数据只能在工作内存中操作,然后将操作完的值重新刷回主内存中,这是java内存模型定义的线程基本工作方式。

需要注意的是:jmm的内存模型与jvm的内存模型概念不一样的


上述所说的内容,可能还会有点抽象,结合一波代码配合演示图会更加好理解点

@Slf4jpublic class Test01 {    private static boolean initFlag = false;    public static void refresh() {        log.info("refresh data.......");        initFlag = true;        log.info("refresh data success.......");    }    public static void main(String[] args) {        Thread threadA = new Thread(() -> {            while (!initFlag) {            }            log.info("线程:" + Thread.currentThread().getName()                    + "当前线程嗅探到initFlag的状态的改变");        }, "threadA");        threadA.start();        try {            Thread.sleep(500);        } catch (InterruptedException e) {            e.printStackTrace();        }        Thread threadB = new Thread(() -> {            refresh();        }, "threadB");        threadB.start();    }}


运行效果图


从动图我们看到,A线程内部判断 "initFlag" 变量,如果变量false则一直进行循环,而代码中的B线程内部调用refresh()方法将变量 "initFlag" 修改为true,而此时A线程内部的循环感应到 "initFlag" 变量为true了应该退出来才对,而为什么演示图中A线程内部的循环并没有退出来?


带着这个疑惑,将代码稍微改动一下,这次在代码中定义一个全局变量为count为int类型,并在A线程循环中,将变量count自增操作,再来看看它的效果如何


上图,我们往循环内部加一个count自增操作,貌似并没有解决掉A线程循环的退出对吗?不慌,这次我们往count变量上加一个volatile关键字,接着来看看效果


欸,上图中,我往count变量加了一个volatile关键字,A线程内部的循环居然退出去,那么意味着A线程它检测到了全局变量 "initFlag" 将值改变为true了,那我们再接着测试一下,我们将count++操作去掉,往initFlag变量上加volatile关键字,在继续看看效果如何?


嗯,好家伙,似乎经过这两轮测试,其实可以大致猜出加了volatile关键字的原因,该篇文章不是讲volatile的重点,我来讲讲为什么发生这种情况。


先再次看到这张图


这张图,我前面已经放上去了,现在再次粘过来,是为了更好的说明上述程序的问题所在,我上面有解释过主内存是专门存储成员变量的,该成员变量的值是允许被多个线程进行共享,那么上述程序中 "initFlag" 变量作为成员变量,是可以被A线程和B线程进行读取和操作的,那么此时A和B线程都会要进行读取共享变量,它们各自会从主内存中将变量进行拷贝到各自线程内部的工作内存中,接着B线程内部调用了refresh()方法,将initFlag的值改为true,在jmm模型中,B线程它并不会直接将值改回到主内存中,而是先将自己内部的工作内存的 "initFlag" 值改为true,然后再写回主内存中,虽说此时主内存的值已经发生了改变,但是A线程内部的循环的判断,还是在使用它自己内部工作内存中的 "initFlag" 的值,它并没有及时的知道共享变量的值已经发生了改变,所以这就导致了A线程长时间无法走出循环的原因。


而我后面又在initFlag变量上加了volatile关键字,为什么能够立马感知到呢?

说到这,我们需要了解到并发的三大特性内容。


JMM内存模型定义

JMM内存模型主要通过三个特征组建成,1.原子性 2.可见性 3.有序性.这三个可谓是java并发的基础


原子性:

原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。


可见性:

可见性指当一个线程修改共享变量的值,其他线程能够立即知道被修改了。Java是利用volatile关键字来提供可见性的。当变量被volatile修饰时,这个变量被修改后会立刻刷新到主内存,当其它线程需要读取该变量时,会去主内存中读取新值。而普通变量则不能保证这一点。(如果其他线程使用到了该变量,修改后会立刻刷新到主内存,并且主动推送到其他线程的工作内存中更新该变量值)
看到此处,是不是就知道为什么加了volatile关键字,其他的线程能够立马感知到变量发生了变化。


有序性:

在并发情况下,能够让线程按代码从上往下按顺序进行执行,可以使用synchronized或者volatile保证多线程之间操作的有序性(这种是在没有指令重排的情况下)



JMM内存模型与硬件架构的关系



通过对前面的硬件内存架构、Java内存模型以及Java多线程的实现原理的了解,我们应该已经意识到,多线程的执行最终都会映射到硬件处理器上进行执行,但Java内存模型和硬件内存架构并不完全一致。对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(堆内存)之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中,因此总体上来说,Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。


结论:在JVM的内存模型中,每个线程有自己的工作内存,实际上JAVA线程借助了底层操作系统线程实现,一个JVM线程对应一个操作系统线程,线程的工作内存其实是cpu寄存器和高速缓存的抽象。



数据同步八大原子操作



在文章上面,我对JMM模型的代码案例以及图都做了一个比较清楚的解释,但是主内存中的共享变量的值是如何copy到线程内部的工作内存中的呢?这里就涉及到数据同步八大原子操作,且看下图。


(1)lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态
(2)unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
(3)read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用  
(4)load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
(5)use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎  
(6)assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量
(7)store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作
(8)write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中


如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步到主内存中,就需要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。


结合上面的代码例子,通过八大原子操作,实现的流程


同步规则分析
1)不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中
2)一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或者assign)的变量。即就是对一个变量实施use和store操作之前,必须先自行assign和load操作。
3)一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现。
4)如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需要重新执行load或assign操作初始化变量的值。
5)如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
6)对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)


最后看到这,我前面似乎还漏了一个问题没有讲到,我稍微回顾一下案例场景,上面代码案例中,我定义了一个 initFlag变量,通过B线程将变量的值进行改变,发现A线程循环内部无法跳出循环的问题,后来又额外的定义了一个count变量,用于在A线程循环内部做自增的操作,然后让它进行跳出来,发现该方法并不可行,然后又继续往count值上加了一个volatile关键字,它就能够立马被A线程感知到,看到这可能还感受不到问题的存在,那么再仔细想想,结合前面的JMM内存模型的图,我在initFlag变量上加了volatile关键字,它能够被立马感知到,这是非常符合逻辑的,但是问题出现在于为什么我将关键字加在了count变量上,initFlag变量也能够被感知到呢?

这里我想回答的是,在cpu底层的缓存行中,它的每个缓存行大小为64个字节,而我们的initFlag变量它只占用了一个字节,且count变量它占用了4个字节,它们在缓存行中总共5个字节,当缓存行中的某一个变量的值发生了修改,volatile关键字会强行通知线程去拉取最新变量的值。所以这就是为什么我在count变量上加了关键字,其他线程能够及时的感知到initFlag的值发生了改变的原因。


最后我还想说明的一点是,无论我们是否加了volatile关键字,线程迟早会知道变量发生了改变,只不过区别在于关键字能够及时的通知线程变量发生了改变。


我是黎明大大,我知道我没有惊世的才华,也没有超于凡人的能力,但毕竟我还有一个不屈服,敢于选择向命运冲锋的灵魂,和一个就是伤痕累累也要义无反顾走下去的心。


如果您觉得本文对您有帮助,还请关注点赞一波,后期将不间断更新更多技术文章


扫描二维码关注我
不定期更新技术文章哦



深入Hotspot源码与Linux内核理解NIO与Epoll

RabbitMQ高级特性之延迟队列

RabbitMQ高级特性之消费端限流

RabbitMQ六种队列模式之主题模式



发现“在看”和“赞”了吗,因为你的点赞,让我元气满满哦
浏览 10
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报