目录

Java内存模型JMM

并发编程模型的两个关键问题

  • 线程间如何通信?即:线程之间以何种机制来交换信息
  • 线程间如何同步?即:线程以何种机制来控制不同线程间操作发生的相对顺序

有两种并发模型可以解决这两个问题:

  • 消息传递并发模型
  • 共享内存并发模型

这两种模型之间的区别如下表所示:

https://gitee.com/lienhui68/picStore/raw/master/null/20200807152729.png

在Java中,使用的是共享内存并发模型

JMM产生背景和定义

JMM(Java内存模型)源于物理机器CPU架构的内存模型,最初用于解决MP(多处理器架构)系统中的缓存一致性问题,而JVM为了屏蔽各个硬件平台和操作系统对内存访问机制的差异化,提出了JMM的概念。Java内存模型是一种虚拟机规范,JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量

完整定义:JMM(Java Memory Model)是一系列的Java虚拟机平台对开发者提供的多线程环境下的内存可见性、是否可以重排序等问题的无关具体平台的统一的保证。(可能在术语上与Java运行时内存分布有歧义,后者指堆、方法区、线程栈等内存区域)。

计算机系统硬件组成

https://gitee.com/lienhui68/picStore/raw/master/null/20200907062402.png

上面这张图是Inter Pentium系列产品的模型,主要包括****总线、I/O设备、主存、处理器****这四个部分,下面详细解释着四个部分:

  1. I/O设备

首先介绍I/O设备,这也是我们经常操作的计算机硬件,上图标出了鼠标、键盘、显示器和硬盘这四个I/O设备,每个I/O设备都通过一个控制器或适配器与I/O总线相连。

*控制器和适配器的相同与不同:相同是指他们都是用来I/O设备与计算机其他硬件(总线)进行数据信息传递用的,不同在于控制器是一个芯片组,内置于I/O设备或主板,而适配器是一块插在主板上的卡,如显卡。*

  1. 总线

贯穿整个计算机硬件系统的一组电子管道,*携带位信息或字节信息在计算机各个部件之间传递*,但是总线一次能携带的位数或字节数是固定的,这称为****总线****宽度****,**如32为Windows系统总线宽度是4个字节,即32位信息。**

  1. 主存

*也就是我们所指的运行内存*,注意与硬盘不同,一般是指内存条,它****主要用来存储程序和程序处理的数据****,以什么样的形式存储呢?这个后面会讲,现在**一定要记住主存逻辑上是一个字节数组******,什么是字节数组,首先它是一个数组,这个数组以字节为单位进行计算,如我们一般在C语言中定义的整数数组****

1
    int arr[10];

这表示一个含有10个整数的数组,每个整数有4个字节,对应来说,主存(内存)就是一个形如下面的数组

1
    RAM_type RAM[N];

*其中RAM_type是一个字节类型的数据类型,与char类似,在内存中占一个字节,RAM[N]表示主存数组,N为数组长度,即表示主存能包含多少个字节,*那到底*N*为多少呢?*它与总线宽度有关*,**如果按照上面说的总线宽度是32位,那么N = 4GB(2的32次方),也就是说主存数组的索引值从0到4GB-1,这就是指的主存的地址,**怎么理解呢?要解释这个问题需要理解两个点:

(1)什么是机器指令?我们写的一行C语言代码与机器指令的关系?主存怎么存储机器指令?

机器指令肯定是机器能执行的一条命令,如读取内存中一个变量的值等;

一行C语言代码可能对应一条机器指令也可能是多条;

一般来说,组成程序的每条机器指令都由不同数量的字节构成,那刚好主车就是一个字节数组,一条机器指令可能存在主存中的一个字节中或者多个字节中。

(2)怎么理解总线宽度与主存数组长度相等?

要解释这个问题,需要说明一个在处理器中的核心存储设备,即程序计数器(PC),在任何时刻PC都指向内存中的一条指令,即可以理解为PC就是一个指向一条机器语言指令的指针,指针的值就是这条指令在主存中的地址(索引值),最重要的一点就是PC的容量是一个字(4个字节),所以

*PC = 总线宽度 = 主存(数组)长度*

*现在再来理解总线宽度与主存数组长度相等,最重要的是你要理解内存寻址的意思,PC和总线宽度都是32位,则可以把总线理解为32根地址线,那32根地址线最多能寻址的范围是多少呢?那就是4GB啊,难道不是吗?如果是2根地址线,能寻址的范围是0(二进制00)~4(二进制11),所以主存数组长度就是 与总线宽度相等。*

https://img-blog.csdn.net/20170506185336093?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvcXFfMjY4NDkyMzM=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/Center

另外,这也说明了Windows 32位系统最多能识别4GB的内存,就算你安装8GB的内存条,它也只能用4GB

  1. 处理器

*处理器即计算机中央处理单元(CPU),它主要用来解释(或执行)存储在主存中指令的引擎,*上面也说明了处理器的*核心是一个字长的存储设备,即*PC(程序计数器),但同时处理器还包括*寄存器文件(一组长度为字长的寄存器)、算术逻辑单元ALU(主要计算新的数据和地址值)*,CPU在指令要求下一般会执行以下这些命令:

(1)**加载:从主存到寄存器,**把一个字节或者一个字从主存复制到寄存器文件,以覆盖寄存器原来的内容;

(2)存储:从寄存器到主存;

(3)操作:先寄存器到算术逻辑单元ALU进行计算,然后结果再从ALU到寄存器;

(4)跳转:主存到PC,用于更新PC的值。

来自于《深入理解计算机系统》

Java线程与处理器

https://gitee.com/lienhui68/picStore/raw/master/null/20200726080018.png

超线程:比如4核8线程,这里的4核8线程是指有4个内核,每个内核一个运算单元(ALU)对应两套寄存器(pc|register),线程切换时只需要切换alu指向的寄存器即可,无需保护现场。

Java内存模型和操作系统内存模型的关系

https://gitee.com/lienhui68/picStore/raw/master/null/20200726080804.png

Java内存模型的主要目标是定义程序中各个变量的访问规则。此处提到的变量只包含了实例对象静态对象构成数组对象的元素局部变量和方法参数是线程私有的,不会共享,当然不存在数据竞争问题(如果局部变量是一个reference引用类型,它引用的对象在Java堆中可被各个线程共享,但是reference引用本身在Java栈的局部变量表中,是线程私有的)。为了获得较高的执行性能,Java内存模型并没有限制执行引擎使用处理器的特定寄存器或者缓存来和主内存进行交互(可见性),也没有限制即时编译器进行调整代码执行顺序这类优化措施(有序性)。

JMM规定了所有的变量都存储在主内存(Main Memory)中。每个线程还有自己的工作内存(Working Memory),线程的工作内存其实是cpu寄存器和高速缓存的抽象。线程的工作内存中保存了该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量(volatile变量仍然有工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般)。不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成

栈顶缓存(Top-of-StackCashing)工作

基于栈式架构得虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派次数和内存读写次数。由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然影响速度。

综上所述,jvm是将栈顶元素全部缓存在物理CPU地寄存器当中,从此降低对内存地读/写次数,提升执行引擎地执行效率。

要注意区分栈顶缓存与线程工作内存,一个是存的堆内容的副本。一个是存的栈内容(副本压栈)

对于JMM与JVM本身的内存模型,参照《深入理解Java虚拟机》的解释,主内存、工作内存与Java内存区域中的Java堆、栈、方法区等并不是同一个层次的内存划分。如果两者一定要勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中对象的实例数据部分,而工作内存则对应于虚拟机栈中的部分区域从更低的层次来说,主内存就是硬件的内存,而为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中因为运行时主要访问——读写的是工作内存

JMM与Java内存区域划分的区别与联系

  • 区别

    两者是不同的概念层次。JMM是抽象的,他是用来描述一组规则,通过这个规则来控制各个变量的访问方式,围绕原子性、有序性、可见性等展开的。而Java运行时内存的划分是具体的,是JVM运行Java程序时,必要的内存划分。

  • 联系

    都存在私有数据区域和共享数据区域。一般来说,JMM中的主内存属于共享数据区域,他是包含了堆和方法区;同样,JMM中的本地内存属于私有数据区域,包含了程序计数器、本地方法栈、虚拟机栈。

实际上,他们表达的是同一种含义,这里不做区分

Java内存模型的抽象结构

在Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享(本章用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量(Local Variables),方法定义参数(Java语言规范称之为Formal Method Parameters)和异常处理器参数(Exception Handler Parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。

Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。Java内存模型的抽象示意如图所示:

https://gitee.com/lienhui68/picStore/raw/master/null/20200726082540.png

从图来看,如果线程A与线程B之间要通信的话,必须要经历下面2个步骤。

  1. 线程A把本地内存A中更新过的共享变量刷新到主内存中去。
  2. 线程B到主内存中去读取线程A之前已更新过的共享变量。

所以,线程A无法直接访问线程B的工作内存,线程间通信必须经过主内存

下面通过示意图来说明这两个步骤。

https://gitee.com/lienhui68/picStore/raw/master/null/20200726090602.png

如上图所示,本地内存A和本地内存B由主内存中共享变量x的副本。假设初始时,这3个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证

jmm是虚拟机规范,底层可以由缓存一致性协议比如mesi、原子语句、总线锁等来实现。

注意,根据JMM的规定,线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主内存中读取

所以线程B并不是直接去主内存中读取共享变量的值,而是先在本地内存B中找到这个共享变量,发现这个共享变量已经被更新了,然后本地内存B去主内存中读取这个共享变量的新值,并拷贝到本地内存B中,最后线程B再读取本地内存B中的新值。

那么怎么知道这个共享变量的被其他线程更新了呢?这就是JMM的功劳了,也是JMM存在的必要性之一。JMM通过控制主内存与每个线程的本地内存之间的交互,来提供内存可见性保证

Java中的volatile关键字可以保证多线程操作共享变量的可见性以及禁止指令重排序,synchronized关键字不仅保证可见性,同时也保证了原子性(互斥性)。在更底层,JMM通过内存屏障来实现内存的可见性以及禁止重排序。为了程序员的方便理解,提出了happens-before,它更加的简单易懂,从而避免了程序员为了理解内存可见性而去学习复杂的重排序规则以及这些规则的具体实现方法。这里涉及到的所有内容后面都会有专门的章节介绍。

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

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

https://gitee.com/lienhui68/picStore/raw/master/null/20200726093210.png

八大交互指令

  1. lock 锁定

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

  2. unlock 解锁

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

  3. read 读取

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

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

    read 是从堆读出数据(一份拷贝)到cache中(一个个cache line) load 是从cache中获取变量的引用写入到局部变量表(也存放在cache)中 use 是将这份数据在需要执行的时候从局部变量表传递到操作数栈(可能存放在寄存器)

    以上占用的cache区域、寄存器等可以抽象地理解为线程的工作空间(或者叫本地内存)

  5. use 使用

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

  6. assign 赋值

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

    volatile修饰的变量,jvm字节码会加上acc_lock修饰,汇编码是lock 前缀,它会锁定变量所在cache line,当对变量执行assign操作时,lock指令会做两件事

    1. 将当前cache line的数据立即回写到系统内存

    2. 这个回写操作会引起在其他内核里缓存了该内存地址的数据失效(MESI)

      对比下总线锁,从一开始read就加总线索,性能必然大大降低

  7. store存储

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

  8. write 写入

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

同步操作规则

交互指令约束

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

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

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

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

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

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

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

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

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

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

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

Java内存模型对并发特征的保证

Java并发编程的三个重要特征

  • 原子性: 不可分割的操作
  • 有序性: 次序,java代码中的次序 和 CPU中的执行顺序(不是一样的)
  • 可见性: 线程内部的私有数据对其他的线程是不可见的

jmm对三大特征的保证

volatile关键字

可见性

对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。

原子性

对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

volatile可以保证内存可见性,不能保证并发有序性

synchronized同步机制

一个线程执行互斥代码过程如下:

  1. 获得同步锁

  2. 清空工作内存

  3. 从主内存拷贝对象副本到工作内存

  4. 执行代码(计算或者输出等)

  5. 刷新主内存数据

  6. 释放同步锁

synchronized既保证了多线程的并发有序性,又保证了多线程的内存可见性。

既然cpu有缓存一致性协议,为什么还需要volatile

volatile是java语言层面给出的保证,MSEI协议是多核cpu保证cache一致性的一种方法

volatile和MESI差着好几层抽象,中间会经历java编译器,java虚拟机和JIT,操作系统,CPU核心。

volatile在Java中的意图是保证变量的可见性。为了实现这个功能,必须保证 1.编译器不能乱序优化;2.指令执行在CPU上要保证读写的fence。

对于x86的体系结构,voltile变量的访问代码会被java编译器生成不乱序的,带有lock指令前缀的机器码。而lock的实现还要区分,这个数据在不在CPU核心的专有缓存中(一般是指L1/L2)。如果在,MESI才有用武之地。如果不满足就会要用其他手段。而这些手段是虚拟机开发者,以及操作系统开发者需要考虑的问题。简而言之,CPU里的缓存,buffer,queue有很多种。MESI只能在一种情况下解决核心专有Cache之间不一致的问题

此外,如果有些CPU不支持MESI协议,那么必须用其他办法来实现等价的效果,比如总是用锁总线的方式,或者明确的fence指令来保证volatile想达到的目标。

如果CPU是单核心的,cache是专供这个核心的,MESI理论上也就没有用了。但是依然要考虑主存和Cache被多个线程切换访问时带来的不一致问题。

总之,volatile是一个高层的表达意图的“抽象”,而MESI是为了实现这个抽象,在某种特定情况下需要使用的一个实现细节。

可以把JSR-133看作是一套UT的规范。不管底下CPU/编译器怎么折腾,只要voltile修饰的变量满足JSR-133所描述的所有场景,就算是一个好的java实现。而基于这个规范,java开发人员才能安心的开发并发代码,而不至于被底层细节搞疯。

参考

Java Memory Model

JVM之内存模型JMM中本地内存的理解

既然CPU有缓存一致性协议(MESI),为什么JMM还需要volatile关键字?

Hotspot 字节码执行与栈顶缓存实现 源码解析