写点什么

技术探索系列 - 轻松带你掌握 JMM(1)

发布于: 2021 年 05 月 01 日
技术探索系列 - 轻松带你掌握 JMM(1)

每日一句


鸟欲高飞先振翅,人求上进先读书 ——李苦禅


前提概要

Hello,你好,欢迎来到我新的专题板块,技术探索系列之 JMM 内存原理机制,对此深有疑惑的兄弟们,赶快上车,跟着我一起探索它的奥秘吧。如果有不对的地方或者不正确的地方还希望大佬们多多指正,小编一定多多改正,谢谢,以后我会出更多这样系列的干活!

背景介绍

谈到 JVM 的核心技术层面内,要说到最难理解同时也是最接近计算机底层的模型的,那一定是 Java 内存模型,同时它已经成为了面试中的必备问题,是非常具有原理性的一个知识点。但是,有不少人把它和 JVM 的内存布局搞混了,以至于答非所问。这个现象在一些工作多年的程序员中非常普遍,主要是因为 JMM 与多线程有关,算是一个比较有挑战性和难度的知识体系,接下来就和我一起学习吧。

在我之前的文章中,已经有相关 JVM 的内存布局方向的,你可以认为这是 JVM 的数据存储模型;但对于 JVM 的运行时模型,还有一个和多线程相关的,且非常容易搞混的概念——Java 的内存模型(JMM,Java Memory Model)。

基本概念

JMM(Java 的内存模型)主要是为了规定了线程和内存之间的一些关系定义了程序中各个共享变量的访问规则,即在虚拟机中将变量存储到内存和从内存读取变量这样的底层细节

设计原则

根据 JMM 的设计,系统存在一个主内存(MainMemory),Java 中所有实例变量都储存在主存中,对于所有线程都是共享的。每条线程都有自己的工作内存(WorkingMemory),工作内存由缓存和栈两部分组成,缓存中保存的是主存中变量的拷贝,缓存可能并不总和主存同步,也就是缓存中变量的修改可能没有立刻写到主存中栈中保存的是线程的局部变量,线程之间无法相互直接访问堆栈中的变量,必须要通过主存来完成通信。

设计目的

屏蔽各种硬件和操作系统(服务厂商)的内存访问速度和方式差异,以实现让 Java 程序在各种平台下

都能达到一致的内存访问效果,在这里你能够知道了,类加载器以及 Class 字节码,解决了跨平台性的指令执行问题,而 JMM 则是解决了各个平台下内存访问和内存写入的标准问题。

为什么要理解 JMM

理解 JMM 是理解并发问题的基础,主内存、工作内存和线程三者的交互关系。如下图所示:

JMM 的涵盖范围

包括“并发、同步、主内存、本地内存、重排序、内存屏障、happens before 规则、as-if-serial 规则、数据依赖性、顺序一致性模型、JMM 的含义和意义”。

1. 并发

定义并发(同时)发生。在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行并发需要处理两个关键问题:线程之间如何通信及线程之间如何同步

  • 通信 —— 是指线程之间如何交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递

  • 同步 —— 是指程序用于控制不同线程之间操作发生相对顺序的机制。在 Java 中通过 volatile,synchronized, 锁等方式实现同步。

1.1 并发的三个概念
  • 原子性:原子是世界上的最小单位,具有不可分割性。比如 :a=0; 这个操作是不可分割的,那么我们说这个操作时原子操作。

再比如:a++; 这个操作实际是 a = a + 1;是可分割的,所以他不是一个原子操作。非原子操作都会存在线程安全问题,需要我们使用同步技术(sychronized)来让它变成一个原子操作。

一个操作是原子操作,那么我们称它具有原子性。

java 的 concurrent 包下提供了一些原子类,比如:AtomicInteger、AtomicLong、AtomicReference 等。

举个例子:

i = 0;       //1j = i ;      //2i++;         //3i = j + 1;   //4
复制代码

上面四个操作,有哪个几个是原子操作:

  1. 在 Java 中,对基本数据类型的变量和赋值操作都是原子性操作;(是)

  2. 包含了两个操作:读取 i,将 i 值赋值给 j(不是)

  3. 包含了三个操作:读取 i 值、i + 1 、将+1 结果赋值给 i(不是)

  4. 中同三一样(不是)

  • 可见性:是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果。另一个线程马上就能看到。

举个例子:

//线程1执行的代码int i = 0;i = 10;//线程2执行的代码j = i;
复制代码

假若执行线程 1 的是 CPU1,执行线程 2 的是 CPU2。由上面的分析可知,当线程 1 执行 i = 10,这句时,会先把 i 的初始值加载到 CPU1 的高速缓存中,然后赋值为 10,那么在 CPU1 的高速缓存当中 i 的值变为 10 了,却没有立即写入到主存当中此时线程 2 执行 j = i,它会先去主存读取 i 的值并加载到 CPU2 的缓存当中,注意此时内存当中 i 的值还是 0,那么就会使得 j 的值为 0,而不是 10

这就是可见性问题,线程 1 对变量 i 修改了之后,线程 2 没有立即看到线程 1 修改的值。在上面已经分析了,在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。

对于可见性,Java 提供了 volatile 关键字来保证可见性。当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性

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

  • 有序性:即程序执行的顺序按照代码的先后顺序执行。

举个例子:

//线程1执行的代码int i = 0;              boolean flag = false;i = 1;                //语句1  flag = true;          //语句2
复制代码

上面代码定义了一个 int 型变量,定义了一个 boolean 类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句 1 是在语句 2 前面的,那么 JVM 在真正执行这段代码的时候会保证语句 1 一定会在语句 2 前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)

在 Java 里面,可以通过 volatile 关键字来保证一定的“有序性”。另外可以通过 synchronized 和 Lock 来保证有序性,很显然,synchronized 和 Lock 保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性(As If Serial)

另外,Java 内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。

2. 主内存和本地内存

  • 主内存:即 main memory。在 java 中,实例域、静态域和数组元素是线程之间共享的数据,它们存储在主内存中

  • 本地内存:即 local memory。 局部变量,方法定义参数 和 异常处理器参数是不会在线程之间共享的,它们存储在线程的本地内存中

2.1. 主内存与工作内存交互协议

JMM 定义了 8 中操作来完成主内存与工作内存的交互, 虚拟机保证每种操作都是原子的,不可再分。8 种操作分别是 lock,unlock,read,load,use,assign,store,write

  1. 把一个变量复制到工作内存,就要顺序的执行 read 和 load 操作。

  2. 从工作内存同步会主内存,就要顺序的执行 store 和 write 操作

  3. 对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在使用前,需要重新执行 load 或 assign 操作初始化变量的值。

  4. 对一个变量 unlock 操作前,必须先将此变量同步会主内存。

  5. 工作内存中的 volatile 变量在每次使用前要刷新,执行引擎看不到不一致的情况,volatile 还禁止指令重排序。执行(1)操作。

2.1.1 内存间交互操作

我们接着再来关注下变量从主内存读取到工作内存,然后同步回工作内存的细节,这就是主内存与工作内存之间的交互协议。Java 内存模型定义了以下 8 种操作来完成,它们都是 32bit 原子操作(除了对 long 和 double 类型的变量,但是 64bit 操作同样也被处理作为原子操作机制,在操作数栈或者局部变量)

  • lock(锁定)作用于主内存中的变量,它将一个变量标志为一个线程独占的状态

  • unlock(解锁)作用于主内存中变量,解除变量锁定状态,被解除锁定状态的变量才能被其他线程锁定。

  • read(读取)作用于主内存中的变量,它把一个变量的值从主内存中传递到工作内存,以便进行下一步的 load 操作。

  • load(载入)作用于工作内存中的变量,它把 read 操作传递来的变量值放到工作内存中的变量副本中。

  • use(使用)作用于工作内存中的变量,这个操作把变量副本中的值传递给执行引擎。当执行需要使用到变量值的字节码指令的时候就会执行这个操作。

  • assign(赋值)作用于工作内存中的变量,接收执行引擎传递过来的值,将其赋给工作内存中的变量。当执行赋值的字节码指令的时候就会执行这个操作。

  • store(存储)作用于工作内存中的变量,它把工作内存中的值传递到主内存中来,以便 write 操作。

  • write(写入)作用于主内存中的变量,它把 store 传递过来的值放到主内存的变量中。


2.1.2 JMM 对交互指令的约束

如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行 read 和 load 操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行 store 和 write 操作

JMM 要求上述操作必须按顺序执行,而没有保证必须是连续执行。也就是 read 和 load 之间,store 和 write 之间是可以插入其他指令的,如对主内存中的变量 a、b 进行访问时,可能的顺序是 read a,read b,load b, load a。


Java 内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

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

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

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

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

  • 一个变量在同一时刻只允许一条线程对其进行 lock 操作,lock 和 unlock 必须成对出现

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

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

  • 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)。


具体执行流程图:

3. 重排序

计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排,一般分以下 3 种:

编译器优化的重排
  • 编译器语义优化

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

指令并行的重排
  • 处理器级别指令级别优化

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

内存系统的重排
  • 缓存级别以及优化

由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。



其中编译器优化的重排属于编译期重排,指令并行的重排和内存系统的重排属于处理器重排,在多线程环境中,这些重排优化可能会导致程序出现内存可见性问题。

不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。java 内存中的变量都有指针引用,上下文引用成链,这个链是不会被打乱重排序的,只有没有数据依赖关系的代码,才会被冲排序,所以在单线程内部重排序不会改变程序运行结果。

总结: 为了提高程序的并发度,从而提高性能!但是对于多线程程序,重排序可能会导致程序执行的结果不是我们需要的结果!因此,在多线程环境下就需要我们通过“volatile,synchronize,锁等方式”作出正确的实现同步,因为单线程遵循 as-if-serial 语义

4. as-if-serial 语义

as-if-serial 语义:所有动作都可以为优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的。Java 编译器、运行时和处理器都会保证 Java 在单线程下遵循 as-if-serial 语义

总结

as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial 语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题

4. happens-before 规则

JDK5 开始,JSR-133 提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。

如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间

两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见。

常见的满足 happens- before 原则的语法现象:

  • 对象加锁:对一个监视器锁的解锁,happens-before 于随后对这个监视器锁的加锁。

  • volatile 变量:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。


由于篇幅过长,所以拆分为两篇进行梳理,下一篇敬请期待,未完待续......

发布于: 2021 年 05 月 01 日阅读数: 107
用户头像

我们始于迷惘,终于更高水平的迷惘。 2020.03.25 加入

🏆 【酷爱计算机技术、醉心开发编程、喜爱健身运动、热衷悬疑推理的”极客狂人“】 🏅 【Java技术领域,MySQL技术领域,APM全链路追踪技术及微服务、分布式方向的技术体系等】 🤝未来我们希望可以共同进步🤝

评论 (1 条评论)

发布
用户头像
由于篇幅过长,所以拆分为两篇进行梳理,下一篇敬请期待,未完待续......


2021 年 05 月 01 日 13:48
回复
没有更多了
技术探索系列 - 轻松带你掌握 JMM(1)