【JUC系列-01】深入理解JMM内存模型的底层实现原理

news2024/7/4 6:02:52

一,深入理解JMM内存模型

1,什么是可见性

在谈jmm的内存模型之前,先了解一下并发并发编程的三大特性,分别是:可见性,原子性,有序性。可见性指的就是当一个线程修改某个变量的值之后,其他的线程可以立马感知到。

接下来看一个例子,看一个线程改变值之后,另一个线程能否立马感知到这个值被改变了。

public class JmmTest {
    private boolean flag = true;
    private int count = 0;

    public void refresh() {
        flag = false;
        System.out.println(Thread.currentThread().getName() + "修改flag:"+flag);
    }

    public void load() {
        while (flag) {
            //TODO  业务逻辑
            count++;
        }
        System.out.println(Thread.currentThread().getName() + "跳出循环: count=" + count);
    }

    public static void main(String[] args) throws InterruptedException {
        JmmTest test = new JmmTest();

        // 线程threadA模拟数据加载场景
        Thread threadA = new Thread(() -> test.load(), "threadA");
        threadA.start();

        // 让threadA执行一会儿
        Thread.sleep(1000);
        // 线程threadB通过flag控制threadA的执行时间
        Thread threadB = new Thread(() -> test.refresh(), "threadB");
        threadB.start();
    }
}

可以发现以上操作,线程A先加载这个flag值,由于是true,因此一直处于while循环中空转,但是线程B随后修改了这个值,但是可以发现线程A是还在这个while循环中的,并没有跳出循环,其结果值如下:

threadB修改flag:false

也就是说,在一个正常的多线程之间的通信,是不能够直接的进行通信的,因此这就需要了解JMM的底层原理了

2,什么是JMM

Java Memory Model ,就是JMM的全称,意思是java内存模型。主要用于规范java虚拟机和计算机内存时如何协调工作的,规定了当一个线程改变某个共享变量值后,其他线程需要如何查看以及合适可以查看这个被改变的共享数据。

jmm的内存模型如下,java采用的是共享变量的模型方式,在创建一个共享变量之后,这些共享变量时存储在主内存中的,所有线程都能访问,但是每个线程需要操作这个变量时,需要先将这个值加载到每个线程的工作内存中,即每个线程都有对应栈帧,将这个值加入到局部变量表即可,就成为了共享变量的一个副本,随后线程A才能去修改这个值

在这里插入图片描述

而由于主内存中的变量都是共享变量,因此为了解决并发问题,在JMM内部又引入了八大原子操作

1,lock:作用于主内存的变量,把一个变量标记为一条线程独占状态
2,unlock:把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
3,read(读取):作用于主内存中,需要先对变量进行副本的拷贝,然后将变量值传输到工作内存中
4,load(载入):在工作内存中,需要对传输过来的副本变量进行一个获取,并且存入到工作内存中
5,use(使用): 需要将获取的变量传给执行引擎
6,assign(赋值):执行引擎会将这个收到的变量赋值给工作内存的变量
7,store(存储):修改这个传过来的副本之后,会将修改的值存储并送到主内存中
8,write(写入):会将这个存储的变量写回到主内存中,即修改主内存的值

如当一个线程去修改主内存中的共享变量的方式如下,比如说内存中的 x = 5 进行 +1 的操作如下图所示,首先线程A会read读取主内存中的x = 5的值,随后将读取到的值load载入到线程A的本地内存中,一般栈帧中存放变量的都是这个局部变量表,随后会通过use的指令使用这个变量,将这个值加入到cpu中,结果cpu内部的运算之后,此时 x = 6,会通过assign方式将这个结果值从cpu返回到本地内存中,随后将这个值返回到主内存中,并通过store的方式将这个值存储,最后将被修改的变量写回到主内存中。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jHCWw6Sa-1692553785831)(img/1692079002392.png)]

同时在使用这八种原子操作时,需要满足以下的规则

  • 如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
  • 不允许read和load、store和write操作之一单独出现
  • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

3,引入volatile

在了解完这个jmm内存模型之后,知道java线程之间是如何进行线程通信的,再回到这个 JmmTest 方法中,现在可以大胆的猜测一下,是不是因为线程B修改完值后,没有人去通知线程A?所以才导致值没有发生变化

因此接下来继续验证,就是直接在这个flag变量前面增加一个关键字 volatile

private volatile boolean flag = true;

其结果如下,可以得出结论,线程A跳出了循环,就是意味着线程A接收到了这个最新的值

threadB修改flag:false
threadA跳出循环: count=399766740

因此查阅了一些资料,以及看了一下hotspot里面关于这个volatile关键字的源码,可以发现这个关键字是通过一个JVM的内存屏障来实现的。

storeload();  //jvm内存屏障,在汇编指令中,对应着lock关键字

内存屏障可以禁止该指令与前面和后面的读写指令重排序,并且可以使其他线程中的本地内存中的该值直接失效,这样其他内存就需要去主内存中获取改值,就能拿到最新的值了。因此volatile是通过内存屏障的方式来实现数据的可见性和有序性的。

除了这个volatile关键字之外,另外像synchronized,lock等这些锁底层都是采用了这个内存屏障来实现,因此这些重量级锁肯定也是可以保证可见性和有序性的,同时由于是重量级操作,除了这两种之外,他们同时还能保证原子性。

除了内存屏障可以保证可见性之外,关键字final也是可以保证可见性的。总而言之能保证可见性的方式只有两种:一种是内存屏障,一种是上下文切换

4,cpu缓存架构

在cpu中,主要由寄存器,程序计数器,高速缓存,逻辑运算单元组成,高速缓存又分了三级缓存,分别是一级缓存、二级缓存和三级缓存,一级缓存中又分为两部分,一个用于存储指令,一个用于存储数据。在inter处理器中,一个cpu又分为两个处理器,因此会存在两个cpu共享一个三级缓存的情况。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1HofjptX-1692553785832)(img/1692234264579.png)]

使用高速缓存主要是减少等待内存的时间,提升CPU的计算能力

接下来根据这个缓存架构再举一个例子,现在有两个线程,分别是线程thread1和线程thread2,假设主内存中有一个值x=100,接下来两个线程同时去读这个100,线程1加对这个值加10,线程2对这个值加20,那么根据JMM的八大原子操作,此时线程1的CPU的值为110,线程2的CPU的值为120,最终会将这个值写回主内存中。

那么此时主内存就会出现两种情况,如果线程1先写回,线程2后写回,那么线程2会将线程1写回的值覆盖掉,此时;如果线程2先写回,线程后写回,那么线程会将线程2写回的值给覆盖掉,这就是经典的线程不安全问题

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PcaYNfBx-1692553785835)(img/1692234699580.png)]

造成这种原因的主要问题,是因为缓存不一致的问题。 即线程1的高速缓存的值和线程2的高速缓存的值不一致所导致的,因此为了解决这种缓存一致性的问题,主要有两种解决方式:嗅探机制、基于目录的机制

5,嗅探机制

再了解完这个导致数据不安全的原因是由于缓存不一致的问题,因此为了解决这个硬件层面的缓存一致性,最流行的还是使用这种嗅探机制

其工作原理如下:就是说如果存在多个缓存被共享的时候,如果有处理器修改了共享变量的值,那么必须传播到其他所有具有该变量的副本中,通过这种传播机制来防止系统违反缓存的一致性。就是说,数据的变更通知是通过总线来完成的。当其他缓存接收到这个通知信息之后,可以选择重新的在主内存中刷新数据,也可以直接让当前缓存中的值直接失效,具体是哪种做法,还得取决于使用哪种缓存一致性协议。

写失效:就是某个处理器将值改完之后,直接通知其他处理器,让其他处理器的缓存值失效

写更新:就是处理器将值修改完之后,在通知其他处理器的时候,直接将值携带上,让其他的处理器缓存值更新

总线的带宽是有效的,因此写失效的使用范围是最广的。MSI、MESI、MOSI、MOESI等是最常见的缓存一致性协议

6,解决缓存一致性的MESI

为了解决缓存一致性,使用最多的方式是这种MESI的方式,总共有四种状态,分别是

  • M:modify,修改状态
  • E:Exclusive,独占状态
  • S:Share,共享状态
  • I:Invalid,失效状态

在这里插入图片描述

当工作内存将主内存的值加载到高速缓存之后,假设此时只有当前线程thread1加载了X=5,那么此时X是一个Exclusive独占状态,如果此时线程thread2也加载了这个值,那么此时该值则会从一个独占状态变成一个Share共享状态,如果此时线程thread1要修改这个值,那么在修改这个值后,X就会从一个共享状态变为一个Modify修改状态,并且在回显的时候被总线窥探到,总线就会发起请求告诉其他的线程这个被修改的值,让其他的线程缓存里面的改值直接失效Invalid,那么其他线程就可以去获取最新的值。

但是该协议并不是会直接生效,而是需要在特定的时候生效,就是需要一个lock前缀指令才可以满足该协议,如一些常见的volatile,synchronized,lock等关键字。这样才能解决这种缓存一致性的问题。但是volatile并不能保证原子性。

并且在某个线程更新了某个值之后,刷新主内存的线程会立即执行,这样才能让其他已经处于失效的线程立马的回到主内存中去更新改值,从而线程在获取值时减少数据的脏读问题以及长时间等待的问题。

除了缓存一致性协议之外,还有总线一致性协议,由于总线一致性的性能问题,缓存一致性协议才得以出现。

7,JMM内存可见性的保证

在单线程中:由于需要保证 else-if-serial 规范,即不管如何进行指令重排,都必须要保证最终结果的一致性,因此,单线程不存在内存可见性的问题,不管是编译器还是及时处理器等,都必须保证和原始顺序所执行的结果值相同

在正确同步的多线程中:如在加锁的情况下,JMM在内部会禁止指令重排的操作,并且在底层会通过内存屏障的操作来操作底层硬件,从而实现可见性和有序性的操作。

未同步的多线程:JMM不能保证未同步的执行结果与顺序一致性的结果一致。由于在JVM中,存在一些JIT即时编译器以及解释器的一些优化等,因此就会出现指令重排的情况。

x = 10;						y = 100;													
y = 100;          ====>		x = 10;
z = x + 10;					z = x + 10;

举个例子,如在单例模式加锁的双重检测中,需要在对象的前面加一个关键字 volatile,如果不加的话,在new对象的时候,会经历以下步骤:开辟内存空间,堆内存初始化,栈中对象指向堆中对象。这里就会出现一个问题,由于new对象并没有保证这个原子操作,因此就会出现指令重排的情况,就是可能会先指向堆中的对象,再在堆内存中初始化,就是第二步和第三步的顺序可能会发生改变。

public class SingletonTest{
    private volatile static SingletonTest instance = null;
    private SingletonTest() {}

    public static SingletonTest getInstance() {
            if (instance == null) {
                synchronized (SingletonTest.class) {
                    if (instance == null) {
                        //在不加volatile或者其他锁的情况下
                        //可能会出现指令重排的情况
                        instance = new Singleton();
                    }
                }
            }
            return instance;
     }
}

那在多线程的情况下,在第一个线程正好执行到发生指令重排的第二步,就是指向了一个堆中的对象,但还没有初始化,只是经历了实例化,而第二个线程进行第一个if判断的时候,此时并没有加锁,所以发现不为null,就直接return了,但是return的是一个你有进行初始化的一个值,因此返回的对象肯定是有问题的

所以为了解决这个指令重排的问题,就需要在这个对象上面加上volatile这个关键字了,这样就能禁止指令重排了

private volatile static SingletonTest instance = null;

8,内存屏障

在jvm和硬件层面都有实现内存屏障的方式。

在jvm层面,在JSR规范中定义了四种内存屏障,分别是LoadStore,LoadLoad,StoreLoad,StoreStore。Load操作可以当做成是一个read读取操作,Store操作可以当做成是一个写入操作,两个操作之间相当于加了一个一堵墙,从而保证两个操作的顺序不被打乱

LoadStore:在store2指令写入数据之前,保证数据一定被load1指令先写入进去

LoadLoad:在Load2指令读取数据之前,保证数据一定被load1指令先读取出来

StoreLoad:在Load2指令读取数据之前,保证数据一定被Store指令写入进去

StoreStore:在store2指令写入数据之前,保证数据一定被load1指令读取出来

并且以上的写入操作,都是可以实现所有的处理器都可以感知到数据的变化,即保证可见性。当前jvm底层实现内存屏障的方式主要是通过这个StoreLoad方式来实现的。

在硬件层面,也提供了一系列的内存屏障的方式保证数据的一致性,主要是通过ifence和sfence来实现读写屏障,也可以通过Lock前缀来实现这个类似于内存屏障的功能。但是在JMM内存模型中屏蔽了这种底层硬件带来的差异,直接由JVM来为不同的平台生成相应的字节码。

9,为何多线程的累加值总是小于期待值

了解这个JMM的内存模型之后,接下来通过之前的多线程的系列的文章,来对上述这个问题做一个初步的了解。

count++;

由于在java中,实现线程的方式是使用的内核态的方式实现的多线程,也就是说开发者只能通过内核去调用操作系统,再去调用线程,因此开发人员并不能控制线程,因此就不能控制上下文切换等,并且实现线程的方式是抢占式的方式实现,所以在累加操作中,某个值可能只执行了一半,就出现了cpu中时间片的切换,导致这个值被其他线程操作,如果是在多线程的情况下,两个线程同时操作一个值,就会出现这种值被覆盖的问题。因此最终出现的结果会小于期待值

其次是通过JMM模型可知,每个线程都有属于自己的工作区间,但是每个线程在将值修改之后,其他线程并不能感知到,就是无法保证可见性的问题,因此也会出现大量的值被覆盖。所以累加的结构也会小于期待值

因此需要通过加锁的方式强行保证线程间执行顺序,以及需要通过实现内存屏障的方式来实现线程间的可见性和有序性以及原子性。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/905218.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

shell脚本之sed

sed sed 即 Stream EDitor,和 vi 不同,sed是行编辑器 基本用法 sed [选项] 自身脚本用法 [支持输入标准输入管道] 常用选项:-n 不输出模式空间内容到屏幕,即不自动打印-e 多点编辑-f FILE 从指定文件中读取编辑脚本-r, -E 使用…

【TypeScript】内置对象

JavaScript 中很多内置对象,可以直接在 TypeScript 中当做定义好了的类型。 ECMAScript 的内置对象 Boolean、Number、String、RegExp、Date、Error、XMLHttpRequest 等 let num: Number new Number(1) let date: Date new Date() let reg: RegExp new RegExp(…

WTF Langchain极简入门: 03. 数据连接

加载文档 langchain提供多种文档加载器,支持多种格式、来源的文件。可以从本地存储系统加载文件,也可以从网上加载远端文件。想了解LangChain所支持的所有文档加载器,请参考Document Loaders。 在本系列课程中,我们将使用最基本…

Docker 搭建 LNMP + Wordpress(详细步骤)

目录 一、项目模拟 1. 项目环境 2. 服务器环境 3.任务需求 二、Linux 系统基础镜像 三、Nginx 1. 建立工作目录 2. 编写 Dockerfile 脚本 3. 准备 nginx.conf 配置文件 4. 生成镜像 5. 创建自定义网络 6. 启动镜像容器 7. 验证 nginx 四、Mysql 1.…

快速学习GO语言总结

备注:本博客将自己初步学习GO的总结进行分享,希望大家通过本博客可以在短时间内快速掌握GO的基本程序编码能力,如有错误请留言指正,谢谢! 一、初步了解Go语言 (一)Go语言诞生的主要问题和目标…

shell脚本——expect脚本免交互

目录 一.Here Document 1.1.定义 1.2.多行重定向 二.expect实现免交互 2.1.基础免交互改密码 2.2.expect定义 2.3.expect基本命令 2.4.expect实现免交互ssh主机 一.Here Document 1.1.定义 使用I/O重定向的方式将命令列表提供给交互式程序,是标准输 入的一…

uniapp开发小程序,上传图片和视频功能

1.需求&#xff1a; 可以上传图片和视频&#xff0c;并且都可以删除&#xff0c;图片可以预览。 2.效果图 3.代码&#xff1a; <template><!-- 上传start --><view style"display: flex; flex-wrap: wrap;"><view class"update-file&quo…

FPGA解析串口指令控制spi flash完成连续写、读、擦除数据

前言 最近在收拾抽屉时找到一个某宝的spi flash模块&#xff0c;如下图所示&#xff0c;我就想用能不能串口来读写flash&#xff0c;大致过程就是&#xff0c;串口向fpga发送一条指令&#xff0c;fpga解析出指令控制flah&#xff0c;这个指令协议目前就是&#xff1a; 55 AA …

达梦数据库表空间创建和管理

概述 本文将介绍在达梦数据库如何创建和管理表空间。 1.创建表空间 1.1表空间个数限制 理论上最多允许有65535个表空间&#xff0c;但用户允许创建的表空间 ID 取值范围为0~32767&#xff0c; 超过 32767 的只允许系统使用&#xff0c;ID 由系统自动分配&#xff0c;ID不能…

周易卦爻解读笔记——小过

第六十二卦小过 雷山小过 震上艮下 小过卦为母卦&#xff0c;象征小有过越。 小过卦是中孚卦的错卦&#xff0c;序卦传【有信者必行之&#xff0c;故受之以小过】 小过&#xff1a;亨&#xff0c;利贞&#xff0c;可小事&#xff0c;不可大事。飞鸟遗之音&#xff0c;不宜上宜…

NEO-6M GPS模块 +无线透传模块组成短距离数据空中传输

NEO-6M GPS模块 无线透传模块组成短距离数据空中传输 &#x1f4cc;相关篇《GY-NEO6MV2 GPS模块测试》 &#x1f33f;NEO-6M GPS模块 &#x1f33f;透传模块采用的是GC2400-TC017 ✨数据的一收一发&#xff0c;需要配合一个USB转TTL工具&#xff0c;在电脑端通过串口调试助…

7-5 螺旋方阵

分数 20 全屏浏览题目 切换布局 作者 C课程组 单位 浙江大学 所谓“螺旋方阵”&#xff0c;是指对任意给定的N&#xff0c;将1到NN的数字从左上角第1个格子开始&#xff0c;按顺时针螺旋方向顺序填入NN的方阵里。本题要求构造这样的螺旋方阵。 输入格式&#xff1a; 输入在…

第3步---MySQL的DDL和DML操作

第3步---MySQL的DDL和DML操作 1.DDL操作 Data Defination Language 数据定义语言。创建数据库和表的不涉及到数据的操作。 1.1DDL基本操作 1.1.1数据库相关操作 ddl&#xff1a;创建数据库&#xff0c;创建和修改表 对数据库常见的操作&#xff1a; 操作数据库 -- 展示数据…

Unity 之 Input类

文章目录 总述具体介绍 总述 Input 类是 Unity 中用于处理用户输入的重要工具&#xff0c;它允许您获取来自键盘、鼠标、触摸屏和控制器等设备的输入数据。通过 Input 类&#xff0c;您可以轻松地检测按键、鼠标点击、鼠标移动、触摸、控制器按钮等用户输入事件。以下是关于 I…

拥塞控制(TCP限制窗口大小的机制)

拥塞控制机制可以使滑动窗口在保证可靠性的前提下&#xff0c;提高传输效率 关于滑动窗口的属性以及部分机制推荐看TCP中窗口和滑动窗口的含义以及流量控制 拥塞控制出现的原因 看了上面推荐的博客我们已经知道了&#xff0c;由于接收方接收数据的能力有限&#xff0c;所以要通…

js IntersectionObserver简单案例

效果 源码 <!DOCTYPE html> <!DOCTYPE html> <html lang"en"><head><meta charset"UTF-8"><meta http-equiv"X-UA-Compatible" content"IEedge"><meta name"viewport" content&q…

IP库新增经过实践的Verilog 库

网上严重缺乏实用的 Verilog 设计。Project F 库是尝试让 FPGA 初学者变得更好部分。 设计包括 Clock- 时钟生成 (PLL) 和域交叉Display - 显示时序、帧缓冲区、DVI/HDMI 输出Essential- 适用于多种设计的便捷模块Graphics- 绘制线条和形状Maths- 除法、LFSR、平方根、正弦....…

Vue--》打造个性化医疗服务的医院预约系统(七)完结篇

今天开始使用 vue3 + ts 搭建一个医院预约系统的前台页面,因为文章会将项目的每一个地方代码的书写都会讲解到,所以本项目会分成好几篇文章进行讲解,我会在最后一篇文章中会将项目代码开源到我的GithHub上,大家可以自行去进行下载运行,希望本文章对有帮助的朋友们能多多关…

Android OpenCV(七十四): Android OpenCV SDK 升级至 4.8.0

前言 如昨日文章所述,OpenCV 4.8.0 已经发布,虽然系列文章已经停更很久,但是版本升级工作笔者是很乐意快速完成的。 OpenCV 4.8.0 Android SDK:https://github.com/opencv/opencv/releases/download/4.8.0/opencv-4.8.0-android-sdk.zip 更新日志:https://github.com/o…

Apache Doris IP变更问题详解

Apache Doris IP变更问题详解 一、背景二、环境硬件信息软件信息 三、FE恢复3.1 异常日志3.2 获取当前ip3.3 重置ip信息3.4 重置元数据记录3.5 元数据模式恢复3.6 重置fe集群节点3.7 关闭元数据模式重启fe 四、BE恢复4.1 获取当前ip4.2 重置ip信息4.3 重置be集群节点 一、背景 …