Java 并发 - Java 内存模型

324

一、JUC

  • 什么是 JUC ?

    java.util.concurrent 为 java 并法包,JUC 就是它的简称

  • 原子包?

    java.util.atomic 为具原子性的类,具有原子引用的特性!

二、JMM

2.1 概念

Java memory model,java 内存模型,是一种抽象概念,真实中不存在的,描述的是一组规则或者说是规范,这个规范定义了程序中各个变量的访问方式,定义了 JVM 与计算机内存是如何协同工作的

而且,JMM 规范是大多数多线程开发需要遵守的规范

2.2 定义

JMM 规定,所有变量都存在主内存,主内存是共享区域,所有线程都可以访问,但对变量的操作,像读取赋值等,必须在自己的共享区域中进行,过程如下:

将变量从主内存拷贝到自己的工作内存、对变量进行相应操作、完成操作、将变量写回主内存

从中发现,每个线程的工作内存存的是主内存中变量的拷贝副本,所以,不同线程无法访问彼此的工作内存,线程间的通讯通过主内存来完成

2.3 组成

既然是模型,肯定有着各自的组成部分,这个很重要,最好记下来,大概的描述如下:

2.4 特性

该模型定义了三个特性:

  • 可见性
  • 原子性
  • 有序性

保证可见性的方式:

  1. volatile
  2. synchronized
  3. final

保证原子性的方式:

  1. 原子类
  2. synchronized

保证有序性的方式:

  1. volatile
  2. synchronized

三、架构联系

3.1 与计算机体系结构

  • JMM

    Java 内存模型,定义了一类 JVM 与计算机协同工作的规范,有着自己几个部分定义:

    • 线程
    • 本地内存
    • 主内存
    • JMM 控制规范

  • 硬件内存结构

    • 外存:现实中,数据存储在硬盘上,硬盘就是所谓的外存

    • 内存:如果想要运行程序,需要把程度读入内存,这个内存就是常说的内存条的那个内存

    • 高速缓存:而程序读入内存后,需要 CPU 访问内存,然后 CPU 解析为指令集由 CPU 寄存器去执行,而 CPU 寄存器是极其快的,比内存快很多倍,CPU 来回访问内存浪费时间,于是在 CPU 设置了一个高速缓存来解决效率问题

      如何解决?

      CPU 将运算的数据复制一份到 CPU 的高速缓存中,CPU 进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中

    • CPU 寄存器:CPU 每次去查高速缓存的数据,指令运行在 CPU 寄存器上,如果有,直接返回数据;如果没有就去内存查找数据并载入缓存,再从缓存中将数据返回给 CPU,这个高速缓存是刻在 CPU 硬件上的,程序员无法直接干预它

      总结一下,可知程序数据的大概流向就是在外存、内存、高速缓存、CPU 寄存器 之间流动的。以上就是所谓的硬件内存结构,以及它们的组成部分。

  • 联系

    这两个概念从抽象角度来看是有联系的,联系如下

    • 硬件内存的高速缓存 & CPU 寄存器对应工作内存
    • 硬件的内存对应主内存

3.2 与 JVM 内存结构

  • JMM

    Java 内存模型,定义了一类 JVM 与计算机协同工作的规范,有着自己几个部分定义:

    • 线程
    • 本地内存
    • 主内存
    • JMM 控制规范
  • JVM 内存结构

    是一种虚拟机的规范,划分了虚拟机中内存的区域,具体由以下及部分组成

    • JDK 1.6 版 JVM 内存结构

  • 联系

    从中其实可以说并没有啥联系。因为,JVM 运行在硬件结构的那个内存上,JVM 把执行 Java 程序中使用的内存进行管理,对管理的内存划分为不同的数据区域,从而有了 JVM 内存结构这么个东西。

    所以,在内存中根本没有栈、堆的说法,它们两大部分都是在内存的;同时,栈、堆有时也会有一部分是位于硬件的 CPU 寄存器和高速缓存中。

    JVM 内存结构完全是基于硬件内存中的一块区域的角度来看的。

    JMM 则是对应着硬件的高速缓存、CPU 寄存器、内存这个角度来看的。

    两者就不是一个级别的东西

    如果硬要说 JMM 于 JVM 内存结构对应的关系,从缓存一致性的含义上来对应,可以说:

    • 线程私有区的 JVM 栈的部分区域对应本地内存
    • 堆内存对应主内存
    • 线程私有区的部分对应线程

    因为,变量的拷贝、赋值操作等在 JVM 栈的栈帧中完成,所以对应本地内存;而堆中的对象是线程所共享的,所以可以对应主内存

四、缓存一致性

4.1 硬件结构缓存一致性

硬件内存结构是有缓存一致性问题的,因为有了高速缓存的存在,如果是单核 CPU 在单线程环境下没啥问题,但如果是在多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。这时 CPU 缓存中的值可能和缓存中的值不一样

所以因为缓存,就会带来数据不一致的问题

4.2 JMM 缓存一致性

先前已经说了 JMM 规范中,与硬件是真实有对应的联系的(CPU & 高速缓存对应本地内存,内存对应主内存),同时 JMM 也能体现缓存一致性问题,并给出一系列解决方案。

JMM 中看的缓存一致性问题,是从本地内存与主内存来体现的,从主内存拷贝一份变量到本地内存,完成操作修改后写回主内存,但如果是多线程,其他线程又无法马上收到变量已经修改的通知,从而导致其他线程本地内存中的变量副本与主内存中被拷贝变量数据不一致的问题

4.3 JVM 内存结构缓存一致性

从上面可知,JVM 内存结构本就是一块 RAM 中的内存而已,根本就没有啥高速缓存的概念,所以不会有缓存一致性问题,但真的硬要扯蛋,有一点联系,就可以说:

  • 线程私有区的 JVM 栈的部分区域对应本地内存
  • 堆内存对应主内存
  • 线程私有区的部分对应线程

这个是从软件,线程共享性来看的,但实际上里面到底为什么会发生这些缓存一致性问题,原理是 JMM 的设计规则

五、操作指令

5.1 指令

定义了模型,那么执行一些变量操作,就需要指令执行流程如下:

类似的指令,一共定义了八条:

  • lock(锁定):作用主内存变量,标识该变量线程独占
  • unlock(解锁):作用于主内存变量,与 lock 相对应释放锁
  • read(读取):作用于主内存变量,直接从主内存中读取变量到工作内存中
  • load(载入):作用于工作内存变量,把 read 操作得到的变量值放入工作内存的变量副本中
  • use(使用):作用于工作内存变量,传递其到执行引擎,每当虚拟机遇到指令需要变量值的时候就会执行该操作。
  • assign(复制):作用于工作内存变量,把执行引擎结果存储到工作内存变量中,虚拟机每遇到赋值操作指令时会执行该操作。
  • store(存储):作用于工作内存变量,将其传送到主内存中,供 write 使用
  • write(写入):作用于主内存的变量,将 store 传入的变量值写入到主内存变量中

5.2 举例

一个变量的使用与更新指令流程:

  • read 从主内存读取变量到工作内存
  • load 把上面读取的来的变量放入到工作内存的一个变量副本中
  • use 虚拟机遇到指令执行需要该变量时执行,此时把工作内存的变量传递给线程
  • assign 虚拟机遇到赋值指令时执行,此时把该变量存储到工作内存变量中
  • store 把工作内存的内存变量,传送到主内存中
  • write 把工作内存传送过来的变量写入到主内存变量中

值得注意的是,多个变量的 read & load 或 store / write 不需要保证顺序写

六、volatile

6.1 作用

volatile 是一种轻量级的同步机制,作用于变量,被它修饰的变量具有可见性 & 有序性,而其中的实现原理是通过内存屏障来实现的,内存屏障通过禁止指令重排,来实现有序性

6.2 内存屏障

对于一个变量,它只有可能是读取 / 写入这两个操作:

  • 读取,JMM 对应的是 load
  • 写入,JMM 对应的是 store

而指令重排,其实也只有四种关键的可能,假设现在有 a 和 b 两个变量,则发生的指令重排组合:

  • Load a; Load b;
  • Store a; Store b;
  • Load a; Store b;
  • Store a; Load b;

JMM 根据这四种可能的指令重排,导致缓存一致性的问题,设计了四个内存屏障,也就是四种重排现象,对应上面四种分别是

  1. LoadLoad 屏障和 LoadStore 屏障:读屏障,在读指令前插入读屏障,让高速缓存中的数据失效,重新从主内存载入数据,保证读取的是最新的数据
  2. StoreStore 屏障和 StoreLoad 屏障:写屏障,在指令后插入写屏障,让高速缓存中最新的数据写回到主内存中,保证写入的数据对其他线程是可见的

一般来说,如果写入也就是 store 了一个变量之后,马上 load 也就是使用了 StoreLoad 屏障,那么其他屏障基本就不需要了,因为直接写了,保证其他处理器可见,得到的变量就是最新值了啊!

所以,StoreLoad 在四种屏障指令中也称之为万能屏障,它具备其他三种内存屏障的功能,但处理的开销代价也是四种的最大的

6.3 使用举例

读屏障是用于读指令之前的,写屏障是用于写指令之后的,使用的大概意思,如下:

  • Load a; LoadLoad; Load b; // 作用于 Load a
  • Store a; StoreStore; Store b; // 作用于 Store a
  • Load a; LoadLoad; Store b; // 作用于 Load a
  • Store a; StoreLoad; Load b; // 作用于 Store a

七、Happens-Before

JVM 规定了先行发生原则,这个规定了一个操作无需控制就能先于另一个操作完成

八条原则如下:

  1. 程序次序原则:一个线程内按照代码顺序执行,写在前的操作先行发生于写在后面的操作,保证语义串行性
  2. 锁规则:解锁必然发生在随后的加锁前
  3. volatile 原则: volatile变量的写先发生于读,保证其volatile变量的可见性
    • 写volatile变量操作与该操作之前的任何读写不会发生重排序
    • 读volatile变量操作与该操作之后的任何读写不会发生重排序
  4. 传递性: A 先于 B,B 先于C,那么 A 必然先于 C
  5. 线程启动原则:线程的 start() 方法先于它的每一个动作
  6. 线程加入原则:线程所有操作先于线程的的 join() 返回(Thread.join)
  7. 线程中断原则:线程中断 interrupt() 先于被中断线程的代码
  8. 对象终结原则:对象构造函数执行结束先于 finalize 方法

参考