从零开始的JVM学习--Java内存模型(JMM)

简单介绍

为什么要有内存模型?

想要回答这个问题,我们得先了解清楚计算机硬件内存结构

计算机硬件内存架构

4核CPU读取内存数据模型

image-20221002185757916

解释

  • CPU 寄存器(CPU Register)

    什么是 CPU 寄存器?

    CPU寄存器是CPU内部非常小、非常快速的存储部件,它的容量很有限。现代CPU都内置了几十个甚至上百个寄存器。一般我们说PC是多少位的实际指的就是CPU寄存器的位数。

    为什么要 CPU 寄存器?

    虽然内存的读取速度已经很快了,但是和CPU比起来,还是有很大差距的,不是一个数量级的。如果每次都从内存中读取数据,会严重拖慢CPU的运行速度,CPU经常处于等待状态,无事可做。在CPU内部设置一个缓存,可以将使用频繁的数据暂时读取到缓存,需要同一地址上的数据时,就不用大老远地再去访问内存,直接从缓存中读取即可。

  • Cache 存储器(Cache Memory)

    什么是 Cache 存储器?

    Cache存储器是位于CPU和主存之间,规模较小,但速度很高的存储器。通常由SRAM(静态存储器)组成。

    为什么要 Cache 存储器?

    CPU的速度远高于内存,当CPU直接从内存中存取数据时要等待一定时间周期,而Cache则可以保存CPU刚用过或循环使用的一部分数据,如果CPU需要再次使用该部分数据时可从Cache中直接调用,这样就避免了重复存取数据,减少了CPU的等待时间,因而提高了系统的效率。

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

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

缓存一致性问题

什么是缓存一致性问题?

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

因此需要每个 CPU 访问缓存时遵循一定的协议,在读写数据时根据协议进行操作,共同来维护缓存的一致性。这类协议有 MSI、MESI、MOSI、和 Dragon Protocol 等。

为了提升性能在 CPU 和主内存之间增加了高速缓存,但在多线程并发场景可能会遇到缓存一致性问题。那还有没有办法进一步提升 CPU 的执行效率呢? 答案是:处理器优化

处理器优化和指令重排序

什么是处理器优化?

为了使处理器内部的运算单元能够最大化被充分利用,处理器会对输入代码进行乱序执行处理,这就是处理器优化。

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

image-20220928141640358

重排序的类型

  • 编译器优化的重排序:

    编译器在不改变单线程程序语义放入前提下,可以重新安排语句的执行顺序。

  • 指令级并行的重排序:

    现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

  • 内存系统的重排序:

    由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

在解决完以上的问题以后,我们开始真正的要解决「为什么要有内存模型?」的问题了。

我们上面讲的「缓存一致性」、「处理器优化」、「指令重排序」,分别就是造成我们Java并发编程的三个基本问题:「可见性问题」、「原子性问题」和「有序性问题」的主要原因。

为了解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。我们在物理机器上定义出一套内存模型,规范内存的读写操作。内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障。

Java 内存模型(JMM)

首先最重要的一点——「Java 内存模型(JMM)」指的不是 「Java运行时数据区域」!

「Java 运行时数据区域」是我们看八股文常看到的:" Java内存区域五大块:堆、方法区、虚拟机栈、本地方法栈、PC寄存器... "

「Java 内存模型(JMM)」是什么在本章会做解答。接着往下看吧!

什么是 JMM

JMM(Java Memory Model)是Java内存模型,用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。

JMM 本身是一种抽象的概念,实际上不存在,它描述的是一组规范。通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

JMM 规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

JMM 的几个规范

  • 所有变量存储在主内存
  • 主内存是虚拟机内存的一部分
  • 每条线程有自己的工作内存
  • 线程的工作内存保存变量的主内存副本
  • 线程对变量的操作必须在工作内存中进行
  • 不同线程之间无法直接访问对方工作内存中的变量
  • 线程间变量值的传递均需要通过主内存来完成

Java运行时内存区域与硬件内存的关系

Java 运行时内存区域是分成栈和堆的,但这些都是JVM定义的逻辑概念。在传统的硬件内存架构中没有栈和堆这种概念。

Java 运行时内存区域与硬件之间的关系图

image-20221002191805847

由上图可知,栈和堆既存在于高速缓存中又存在于主存中。

这两者在硬件上的分布并不是隔离的,都不过是JVM在存储设备上的抽象划分。

Java线程与主存的关系

JVM 运行程序的实体是线程,而每个线程创建时 JVM 都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域。

JMM 规定了所有变量都必须存储在主存中。主内存是共享内存区域,所有线程都可以访问。但是线程对变量的操作都必须在工作内存中进行,首先需要将变量从主存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存。

线程不能直接操作主存中的变量,各个线程中的工作内存中存储着主存中的变量拷贝副本,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主存来完成。

JMM 规范定义的几个规范,并且根据几个规范作图

  • 所有的变量都存储在主内存中。
  • 每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的拷贝副本。
  • 线程对变量的所有操作都必须在本地内存中进行,而不能直接读写主内存。
  • 不同的线程之间无法直接访问对方本地内存中的变量。

image-20221002190823796

线程之间拥有的本地内存是逻辑上隔离,而物理上不隔离的。

JMM下的线程间通信

接下来我们作图演示如下操作:

两个线程都对一个共享变量进行操作,共享变量初始值为 1,线程1对它进行加 1,接着线程2对它进行加1,预期共享变量的值为 3。

JMM 规范下的操作:

image-20221002192522693

  • 步骤一:

    线程1从主存中读取A,进行加一更新,将本地内存1中更新过的共享变量A(此时为2)刷新到主存中去

  • 步骤二:

    线程2从主存中读取线程1更新过的共享变量A(此时为2),并进行加一更新,此时为3,再刷新到主存中去。

JMM数据原子操作

为了更好的控制主内存和本地内存的交互,JMM 定义了八种操作来实现:

  • lock

    锁定。作用于主内存的变量,把一个变量标识为一条线程独占状态。

    加锁时的操作。

  • unlock

    解锁。作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

  • read

    读取。作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用

  • load

    载入。作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

  • use

    使用。作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。

  • assign

    赋值。作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

  • store

    存储。作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。

  • write

    写入。作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

注意:工作内存也就是本地内存的意思。

八种操作的要点

  • JVM 实现时必须保证上面的每种操作都是原子的
  • JMM 只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
  • 如果要把一个变量从主存中复制到工作内存中,则需要按序执行readload操作。
  • 如果要把一个变量从主存中同步回主存中,则需要按序执行storewrite操作。

交互操作的流程

按照:「主内存→read→load→工作内存→use→业务方法→assign→工作内存→store→write→主内存」的顺序进行交互

image-20221002213731899

解释

  • 什么是总线?

    这里的总线实际上就是硬件之间的关联。硬件之间的数据传递靠的就是总线,靠着总线的概念我们在研究问题的时候可以忽略背后复杂的硬件结构。

  • readloaduseassign

    光画图和文字可能还是比较抽象,这里我直接写好了代码,可以和图结合理解。

    这里我们定义了一个线程1:

    image.png

    线程1运行并遇到initFlag,则依次执行readload操作,将变量从主存中加载到工作内存中。

    然后我们看到!initFlag这个语句就是进行use操作了,对工作内存读取数据到线程里面来进行计算。

    然后根据代码的循环逻辑CPU就卡在这里不断地对initFlag做取反操作

    同时我们这里还有一个线程2,一样地要对initFlag进行readloaduse。关键在于对initFlag做了一次赋值操作assign

    image.png

    这个时候工作内存中的initFlag已经从false变成true了。

  • storewrite

    线程2赋值完成以后,共享变量initFlag的副本值会同步回我们的主存

    image.png

    这个同步的过程底层做的操作就是store,把工作内存中的变量的值传送到主存中。

    但是这个时候只是暂时放入到主存,并没有赋值给主存中的共享变量。直到它进行了write操作。

    在这之后主存中的initFlag才会变成true

关于缓存一致性问题的详细解决方法会在后面多线程章节的博客里解决。

小结

本章为JVM学习的入门章节,同时与JUC并发编程章节有很强的关联性。可以根据后面的学习反复的进行复习和总结。

本文参考:

猜你喜欢

转载自juejin.im/post/7150174931970424869