• Java多线程——并发知识(计算机内存模型、Java内存模型JMM、可见性理解)


    计算机内存模型和Java内存模型有着一定的关系🤖🤖🤖而JMM的学习又和JVM有着直接的关系🐥🐥
    😶‍🌫️😶‍🌫️😶‍🌫️😶‍🌫️😶‍🌫️JMM与JVM内存区域的划分是不同的概念层次,在理解JMM的时候不要带着JVM的内存模型去理解,偶然看过一些面试过程,面试官问怎么理解“JMM”?很多人都说成了JVM,恰当地说JMM描述的是一组规则,通过这组规则控制程Java序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性,有序性、可见性拓展延伸的。
    本篇博文先对计算机内存模型进行分析,掌握一定的知识基础,并对原本的内存模型提出一些问题和相应的解决方案,而后开始进入JMM的学习。🐳🐳🐳
    里面的内容可能会乱,比较随性🐥🐥🐤

    计算机内存模型

    下面是非常非常粗糙并且非常简约的一张图,不代表任何事物,只为学习做一个简单的演示,真实的计算机内存模型可以参考其他文章,此处为了学习后续内容而已。🏵️🏵️

    在这里插入图片描述
    在这里插入图片描述

    问题来源(借助git理解一下🪐)

    在这种多核多线程的环境中,为了提高运行时数据的访问性能,经常就会使用多层缓存策略。但这种策略带来的最大的问题就是——共享变量可见性的问题
    🌼🌼🌼多个CPU,也就是我们说的多核,根据上方的图,不难发现每个核都有自己的本地副本,对共享变量的读写操作对于其他线程来说是未知的,解释一下:
    ⭐⭐⭐大家大部分用过git的人都知道的一个操作:大家合伙开发一个项目,大家都有这种项目,都直接git到自己电脑本地(那么这个项目就可以理解成共享变量),你在你自己的电脑里更新了某个项目文件,别人知道吗?别人不知道,你只有在执行了push操作的时候,别人才知道,你们之间彼此不可见,那么这个时候如果你改的部分,别人也改过,那么就会产生“push失败”,因为多人都对这个文件进行了修改,你就要取舍:删别人的,还是删自己的…(后续省略哈),再push一次
    🏵️🏵️回到正题,这里也是一样的道理,别的CPU不知道你修改了,因为CPU之间彼此不可见

    在这里也看得出来,每个缓存中的x都不一样,这就是我们说的——缓存不一致问题
    后续会讲解

    在这里插入图片描述

    JVM工作机制图

    JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程如果想要对一个变量读取赋值等操作那么必须在工作内存中进行,所以线程想操作变量时首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量刷写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝

    在这里插入图片描述

    剖析

    在上面的一张图中,出现了几个非常重要的概念:
    主内存、线程本地内存、共享变量、变量副本等
    🧊🧊🧊
    🍊🍊🍊下面根据这个图说明一下重点

    主内存和工作内存概念理解🕊️🕊️🕊️

    🔥主内存: 主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中(除开开启了逃逸分析和标量替换的栈上分配和TLAB分配),不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行非原子性操作时可能会发现线程安全问题。
    🔥工作内存: 主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存线程之间的通讯还是需要依赖于主存,因此存储在工作内存的数据不存在线程安全问题。

    主内存和线程本地内存关系

    主内存保留了Java程序的所有的共享变量,而线程本地内存保留的是这些共享变量的副本
    至于什么是共享变量,对于像局部变量和方法参数这些,都不能算是共享变量。

    🔥根据虚拟机规范,对于一个实例对象中的成员方法而言,如果方法中包含本地变量是基本数据类型(boolean,byte,short,char,int,long,float,double),将直接存储在工作内存的帧栈结构中的局部变量表
    🔥但倘若本地变量是引用类型,那么该对象的在内存中的具体引用地址将会被存储在工作内存的帧栈结构中的局部变量表,而对象实例将存储在主内存(共享数据区域,堆)中。
    🔥但对于实例对象的成员变量,不管它是基本数据类型或者包装类型(Integer、Double等)还是引用类型,都会被存储到堆区(栈上分配与TLAB分配除外)。
    🔥至于static变量以及类本身相关信息将会存储在主内存中。需要注意的是,在主内存中的实例对象可以被多线程共享,倘若两条线程同时调用了同一个类的同一个方法,那么两条线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存

    线程和线程本地内存关系

    每个线程都有一份属于自己的本地内存,不同线程之间的本地内存是互不可见的,而且线程对变量的操作也是只能针对自己的本地内存。

    线程通信

    线程间的通信(传值)必须通过主内存来完成
    一般情况下,线程之间是不能够直接通信的😇😇😇
    我们一直讨论如果某个线程(A)对一个共享变量进行了修改,那么如何让另一个线程得知呢?
    ——我们只有将A线程先将变量的更改反映到主存中,那么其他的线程从主存中读取,这样才算是完成线程之间的通信,这也会扯我们上面讲到的“可见性”。

    引起思考⁉️

    当然了,要完成上面的操作,又会牵扯到其他的问题:
    比如线程对共享变量的修改什么时候同步到主存?
    其他线程又是在什么时候与主存进行同步?等等的问题🤥🤥🤥🤥🤥

    JMM数据原子操作🔥

    针对上面出现的load和store指令,除此之外,还有其他指操作

    read(读取):从主内存中读取数据
    load(载入):将主内存读取到的数据写入工作内存中
    use(使用):从工作内存读取数据来计算
    assign(赋值):将计算好的值重新赋值到工作内存中
    store(存储):将工作内存数据写入主内存中
    write(写入):将store过去的变量值赋值给主内存中的变量
    lock(锁定):将主内存变量加锁,标识为线程独占状态
    unlock(解锁):将主内存变量解锁

    数据(缓存)一致性问题

    问题引入

    回到原始的问题——多个CPU之间彼此不可见,那么非常容易导致产生数据一致性问题

    由于主存与 CPU 处理器的运算能力之间有数量级的差距,所以在传统计算机内存架构中会引入高速缓存来作为主存和处理器之间的缓冲,CPU 将常用的数据放在高速缓存中,运算结束后 CPU 再讲运算结果同步到主存中。

    使用高速缓存解决了 CPU 和主存速率不匹配的问题,但同时又引入另外一个新问题:缓存一致性问题。

    在多CPU的系统中(或者单CPU多核的系统),每个CPU内核都有自己的高速缓存,它们共享同一主内存(Main Memory)。当多个CPU的运算任务都涉及同一块主内存区域时,CPU 会将数据读取到缓存中进行运算,这可能会导致各自的缓存数据不一致。

    缓存一致性协议

    解决方法就是需要每个 CPU 访问缓存时遵循一定的协议,在读写数据时根据协议进行操作,共同来维护缓存的一致性。这类协议有 MSI、MESI、MOSI、和 Dragon Protocol 等。不同硬件采用不同的协议

    在这里插入图片描述

    处理器优化重排序问题

    上面说到了缓存数据一致性问题——除了这种情况,还有一种硬件问题也比较重要。那就是为了使处理器内部的运算单元能够尽量的被充分利用,处理器可能会对输入代码进行乱序执行处理。这就是处理器优化。

    🐰🐰🐰也就是我们常说的“你看到的不一定就是真的”,代码不是一行行执行的,有时候为了提升效率,会打乱顺序,当然,打乱顺序也是有一定前提的,不是随随便便打乱顺序,他也是有原则的,那就是不能影响到程序在单线程下的准确性。

    除了现在很多流行的处理器会对代码进行优化乱序处理,很多编程语言的编译器也会有类似的优化,比如Java虚拟机的即时编译器(JIT)也会做指令重排。

    指令重排时机

    主要发生在编译和运行两个阶段,这两个阶段所对应的主角分别是编译器和CPU

    编译阶段

    将源代码经过编译器编译之后成为机器指令,而机器指令是可能被重排的。对于Java来说,就是Java源文件被javac编译后成为字节码指令,字节码可以被重排

    运行阶段

    指的是机器指令在被CPU执行时可能会先被CPU重排然后才执行,对于Java来说,就是字节码被Java执行器执行时可能会先重排后执行

    总结

    🐻‍❄️🐻‍❄️🐻‍❄️指令重排的阶段和原因是多种多样的,特别是对于Java来说会更加的复杂,因为Java层有不同的编译器和运行时环境。

    解决方法

    通过happens-before原则来解决,这些原则能够在某些节点上控制多线程的执行顺序🐳

    可见性和常见实现方式

    在内存模型中,如果一个线程修改了共享变量的值,其他的线程能够马上知道这个改变,那么就说这个变量具有可见性
    🪐🪐🪐实现可见性有4个常见操作:volatile、synchronized、final、锁
    要学习这几种操作,就先来理解下面的知识点😎😎😎

    并发编程的三大特性

    • 可见性
    • 有序性
    • 原子性

    关键点

    牢牢抓住这三大特性,这三大特性就是解决并发编程各大类问题的关键⭐⭐⭐⭐⭐
    学习并发编程可以反复提出这三大问题:

    • 是否解决了可见性?
    • 是否解决了有序性?
    • 是否解决了原子性?

    JMM

    终于到JMM了🐻‍❄️🐻‍❄️🐻‍❄️
    可以把JMM和上述的计算机内存模型结合一起来看。

    什么是内存模型?

    内存模型定义了共享内存系统中多线程程序读写操作行为的规范。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。他解决了CPU多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。

    内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障。

    注意🐰

    前面说到的解决可见性问题使用的一些方法或者关键字,它们的底层很多都是基于限制处理器优化和内存屏障来实现。

    那么Java内存模型呢?

    🤖Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范
    下面随便找了一张图~

    在这里插入图片描述

    Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

    工作内存

    Java内存模型与上述计算机内存模型相比,引入了“工作内存”的概念。通过上面的JVM工作模型也可以看出。🐰🐰可以把Java模型中的主存和工作内存分别与RAM与Cache或存储器对应起来。
    每个线程工作内存预先把需要的数据复制到Cache或者寄存器(这里并不是全部数据哦,具体还要看JVM的实现),这样提高了线程执行时数据的读取速度问题,就像上面为了提高CPU和主存之间的读取速度而引入寄存器、缓存等的概念。

    ppp:寄存器和Cache由于成本的问题存在容量大小的限制,这也是考验JVM实现的难题,事实上,这也是计算机要面临的难题🐻‍❄️🐻‍❄️🐻‍❄️

    好文推荐🍹🍹

    Java内存模型(JMM)与Volatile关键字底层原理🐰🐰🐰

  • 相关阅读:
    new Blob() 和 URL.createObjectURL()
    Windows下安装并访问mysql容器
    如何写好一篇技术型文档?
    API攻防-接口安全&SOAP&OpenAPI&RESTful&分类特征导入&项目联动检测
    字符串数字出现的新功能
    Redis基础
    七夕 跟mysql 数据库一起度过
    Linux之yum/git的使用
    OpenJudge NOI题库 入门 116题 (二)
    Spring Cloud 整合 nacos 实现动态配置中心
  • 原文地址:https://blog.csdn.net/Xmumu_/article/details/127535410