Java底层-01-Java内存模型

2023-04-25 20:59:52

本系列不会有规律的持续更新,而是学到了、接触到了,就会进行整理总结。
学习本章之前,请先学习Java内存的相关基础知识
水平有限,如若有错,感谢指正!
本章参考-1
本章参考-2


1. 简介

Java内存模型是一种抽象的规则或规范,定义了程序中存在竞争现象的对象(包括实例字段、静态字段和数组对象,不包括局部变量,形式参数;后者是线程私有,不存在竞争问题)的访问方式。

如果我们要想深入了解Java并发编程,就要先理解好Java内存模型。Java内存模型定义了多线程之间共享变量的可见性以及如何在需要的时候对共享变量进行同步。

JVM通过Java内存模型,屏蔽掉不同硬件和操作系统的内存访问差异,实现各种平台具有一致的并发效果。

2. 内存模型

2.1 硬件的效率与一致性——计算机内存模型

在这里插入图片描述
显然,CPU的运行速度远远大于内存交互速度,至少有这几个数量级的差距。因此,现代计算机在CPU与内存之间都有一个高速缓存(Cache)作为数据缓冲:将运算需要的数据从内存中复制到缓存中,当缓存从CPU中拿到运算结果之后再同步到内存中——这样CPU就不用等待缓慢的内存读写了。

但此时就出现了一个大问题:缓存一致性。
现在基本都是多核处理器,每个处理器都有自己的高速缓存,而他们共享同一个主内存,这样就会导致各自的缓存数据不一致的情况,为了解决这个问题各个处理器访问缓存时都要遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI、MOSI、Synapse等等。
除此之外,为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致。类似的,Java虚拟机的即时编译器中也有类似的指令重排序优化。

2.2 Java内存模型

接下来,我们通过一个Java程序了解缓存一致性。

Java的内存模型跟计算机的内存模型极其相似

在这里插入图片描述CPU在内存模型当中充当的就是“算术逻辑单元”,主要负责运算。
Java线程也可以理解为“运算单元”,因此,可以看到,Java内存模型几乎和计算机内存模型保持一致。

内存模型总述

  • 1.所有变量都在主内存当中,工作内存中的变量都是从主内存中拷贝的。
  • 2.线程对变量的所有操作都在工作内存中完成
  • 3.不同线程无法直接访问其他线程工作内存中的变量。线程之间的变量传递需要通过主内存完成

Java中的缓存一致性问题:
.
考虑下面这个代码:
i = 10++;
假设现在有两个线程同时执行上述代码,
根据内存模型总述第1条,这两个线程都是从主内存中拷贝i到自己的工作内存当中,然后分别对其执行自加,然后将其放回主内存。
此时,尽管“自加了两次”,但实际上i=11,而非12。
这种被多个线程访问的变量为共享变量

2.3 Java内存模型和计算机内存模型的关系

通常情况下,当一个cpu需要读取主存的时候它会将主存的部分读取到cpu缓存中。它甚至会将缓存的部分内容读到内部寄存器里,然后在寄存器中执行操作,当cpu需要将结果回写到主存的时候,它会将内部寄存器的值刷新到缓存中,然后在某个时间点将值刷新回主存。

在这里插入图片描述

Heap就是主内存,Thread Stack就是工作内存

通过图可以看出java内存模型与硬件架构之间存在一些差异,硬件内存架构它没有区分线程栈和堆,对于硬件而言所有的线程栈和堆都分布在主内存里,部分cpu栈和堆可能出现cpu缓存中和cpu内部的寄存器里面。

3. 内存特性

3.1 原子性

一个操作是不可中断的,即便是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。

对基本数据类型的变量读取和赋值的操作都是原子操作,即这些操作是不可中断,要么执行,要么不执行。

x = 10;         //语句1
y = x;         //语句2
x++;           //语句3
x = x + 1;     //语句4

以上语句只有语句1是原子性操作。

  • 语句1是直接将数值10赋值给x,也就是说线程执行这个语句会直接将数值10写入到工作内存中。

  • 语句2实际上包含2个操作,先读取x的值,再将x的值写入到工作内存中,虽然读取x的值及将x的值写入工作内存都是原子操作,但结合起来就不是原子性的操作了。

  • x++和x=x+1包含3个操作,读取x的值,进行加1操作,写入新的值。

  • 也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作

Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性

3.2 有序性

编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
也就是说:在并发时,程序的执行可能会出现乱序。

另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

3.3 可见性

一个线程修改了某一个共享变量的值,其他线程是否能够立刻知道这个修改。(可见会牺牲性能)。

通过volatile关键字保证可见性:

  • 当一个共享变量被volatile修饰时,它会保证修改的值会立即更新到主存,当有其他线程需要读取时,它会去主存中读取最新的值。

普通的共享变量不能保证可见性,因为共享变量被修改后,什么时候被写入主存是不确定的,当其他线程去读取的时候,此时内存中可能还是原来的旧值,因此无法保证可见性。

通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

3.4 Happen-Before 规则

  • 程序顺序原则:一个线程内保证语义的串行性(按书写次序顺序执行)
  • 锁规则:解锁必然发生在随后的加锁前
  • 传递性:A 先于 B ,B 先于 C,那么 A 必然先于 C
  • volatile 规则:volatile 变量的写,先发生于读,保证了 volatile 变量的可见性
  • 线程启动规则:线程的 start() 方法先于它的每一个动作
  • 线程中断规则:线程的所有操作先于线程的终结(Thread.join())
  • 线程终结规则:线程的中断先于被中断程序的代码
  • 对象终结规则:对象的构造函数执行结束先于 finalize() 方法

如果不满足以上规则,则虚拟机就会对其进行指令重排序

指令重排序

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序,重排序分为以下三种类型

1、编译器优化的重排序--编译器在不改变单线程程序语义的情况下,可以重新安排语句的执行顺序

2、指令级并行的重排序--如果不存在数据依赖,处理器可以改变语句对应机器执行的语句顺序

3、内存系统的重排序--由于处理器使用缓存和读/写缓存,这使得加载和存储操作看上去像是在乱序执行

在执行程序时,java内存模型确保在不同的编译器和不同的处理器平台上,来插入内存屏障来禁止特定的编译器重排序和处理器重排序,从而为上层提供内存一致性的条件。

4. 内存同步(交互)的八大操作

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java内存模型中定义了以下八种操作来完成:

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎。(每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作)
  • assign(赋值):作用于工作内存的变量,它把一个执行引擎接收到的值赋值给工作内存的变量。(每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作)
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

同步规则

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

  • 2、不允许read和load、store和write操作之一单独出现

  • 3、不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。

  • 4、不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。

  • 5、一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。

  • 6、一个变量在同一时刻只允许一条线程对其进行locd操作,但lock操作可以被同一条线程重复执行多次,多次执行load后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现。

  • 7、如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行Load或assign操作初始化变量的值。

  • 8、如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。

  • 9、对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。
    在这里插入图片描述

其他参考

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

推荐阅读

更深入的了解内存模型

指令重排序的补充知识

  • 作者:舜绪
  • 原文链接:https://blog.csdn.net/weixin_43367550/article/details/103672624
    更新时间:2023-04-25 20:59:52