【Java】JMM梳理

48 阅读8分钟

【Java】JMM梳理

前言

在开发时会经常遇到这样的场景,开发完成的代码在自己的运行环境上表现良好,但是当把它放在其它硬件平台上时,就会出现各种各样的错误,这是因为在不同的硬件生产商不同的操作系统下,内存的访问逻辑有一定的差异,结果就是当你的代码在某个系统环境下运行良好,并且线程安全,但是换了个系统就出现各种问题。为了解决这个问题,Java 内存模型(JMM)的概念就被提出来了,它的出现可以屏蔽系统和硬件的差异,让一套代码在不同平台下能到达相同的访问结果,实现平台的一致性,使得 Java 程序能够一次编写,到处运行

JVM 与 JMM 间的区别:实际上,JMM 是 Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式,JMM 定义了线程和主内存之间的抽象关系线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本,本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。而 JVM 则是描述的是 Java 虚拟机内部及各个结构间的关系。

JMM

Java内存模型( Java Memory Model),简称JMM。它本身只是一个抽象的概念,并不真实存在,它描述的是一种规则规范,是和多线程相关的一组规范。通过这组规范,定义了程序中对各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。需要每个JVM 的实现都要遵守这样的规范,有了JMM规范的保障,并发程序运行在不同的虚拟机上时,得到的程序结果才是安全可靠可信赖的。如果没有JMM 内存模型来规范,就可能会出现,经过不同 JVM 翻译之后,运行的结果不相同也不正确的情况。 计算机在执行程序时,每条指令都是在CPU中执行的。而执行指令的过程中,势必涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程,跟CPU执行指令的速度比起来要慢的多(硬盘 < 内存 <缓存cache < CPU)。因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在CPU里面就有了高速缓存。也就是当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时,就可以直接从它的高速缓存中读取数据或向其写入数据了。当运算结束之后,再将高速缓存中的数据刷新到主存当中。

内存模型三大特性

为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。它解决了CPU多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。在 Java 多线程中,Java 提供了一系列与并发处理相关的关键字,比如volatilesynchronizedfinalconcurrent包等。其实这些就是 Java 内存模型封装了底层的实现后提供给程序员使用的一些关键字,事实上,Java 内存模型的本质是围绕着 Java 并发过程中的如何处理原子性可见性顺序性这三个特征来设计的,这三大特性可以直接使用 Java 中提供的关键字实现。

原子性:一个或多个操作,要么全部执行,要么全部不执行(一个操作是不可中断的,即使是在多个线程一起执行的情况下,一个操作一旦开始执行,就不会受到其他线程的干扰),可以使用 synchronized 来保证方法和代码块内的操作是原子性的。;

可见性:只要有一个线程对共享变量的值做了修改,其他线程都将马上收到通知,立即获得最新值;

有序性:有序性可以总结为:在本线程内观察,所有的操作都是有序的而在一个线程内观察另一个线程,所有操作都是无序的。前半句指 语义:线程内似表现为串行,后半句是指:“指令重排序现象”和“工作内存与主内存同步延迟现象”。处理器为了提高程序的运行效率,提高并行效率,可能会对代码进行优化。编译器认为,重排序后的代码执行效率更优。这样一来,代码的执行顺序就未必是编写代码时候的顺序了,在多线程的情况下就可能会出错。Java 语言提供了 volatilesynchronized 两个关键字来保证线程之间操作的有序性,volatile 是因为其本身包含“禁止指令重排序”的语义,synchronized 是由“一个变量在同一个时刻只允许一条线程对其进行 lock 操作”这条规则获得的,此规则决定了持有同一个对象锁的两个同步块只能串行进入。

同步规定

  • 线程解锁前,必须把共享变量的值刷新回主内存;
  • 线程加锁前,必须将主内存的最新值读取到自己的工作内存;
  • 加锁解锁是同一把锁。

JMM线程间通信

线程间通信必须要经过主内存,线程A把本地内存A中更新过的共享变量刷新到主内存中去,线程B到主内存中去读取线程A之前已更新过的共享变量。

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

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

执行引擎是 Java 虚拟机核心的组成部分之一,执行引擎(Execution Engine) 的任务就是将字节码指令 解释/编译 为对应平台上的本地机器指令才可以。简单来说,JVM 中的执行引擎充当了将高级语言翻译为机器语言的译者。

操作流程图:

同步规则

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