Skip to content

内存模型

Java Memory Model

Java 内存模型是 Java Memory Model(JMM),本身是一种抽象的概念,实际上并不存在,描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式

JMM 作用:

  • 屏蔽各种硬件和操作系统的内存访问差异,实现让 Java 程序在各种平台下都能达到一致的内存访问效果
  • 规定了线程和内存之间的一些关系

根据 JMM 的设计,系统存在一个主内存(Main Memory),Java 中所有变量都存储在主存中,对于所有线程都是共享的;每条线程都有自己的工作内存(Working Memory),工作内存中保存的是主存中某些变量的拷贝,线程对所有变量的操作都是先对变量进行拷贝,然后在工作内存中进行,不能直接操作主内存中的变量;线程之间无法相互直接访问,线程间的通信(传递)必须通过主内存来完成

An image

主内存和工作内存:

  • 主内存:计算机的内存,也就是经常提到的 8G 内存,16G 内存,存储所有共享变量的值
  • 工作内存:存储该线程使用到的共享变量在主内存的的值的副本拷贝

JVM 和 JMM 之间的关系:JMM 中的主内存、工作内存与 JVM 中的 Java 堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果两者一定要勉强对应起来:

  • 主内存主要对应于 Java 堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域
  • 从更低层次上说,主内存直接对应于物理硬件的内存,工作内存对应寄存器和高速缓存

内存交互

Java 内存模型定义了 8 个操作来完成主内存和工作内存的交互操作,每个操作都是原子

非原子协定:没有被 volatile 修饰的 long、double 外,默认按照两次 32 位的操作

An image

  • lock:作用于主内存,将一个变量标识为被一个线程独占状态(对应 monitor enter)
  • unlock:作用于主内存,将一个变量从独占状态释放出来,释放后的变量才可以被其他线程锁定(对应 monitor exit)
  • read:作用于主内存,把一个变量的值从主内存传输到工作内存中
  • load:作用于工作内存,在 read 之后执行,把 read 得到的值放入工作内存的变量副本中
  • use:作用于工作内存,把工作内存中一个变量的值传递给执行引擎,每当遇到一个使用到变量的操作时都要使用该指令
  • assign:作用于工作内存,把从执行引擎接收到的一个值赋给工作内存的变量
  • store:作用于工作内存,把工作内存的一个变量的值传送到主内存中
  • write:作用于主内存,在 store 之后执行,把 store 得到的值放入主内存的变量中

三大特性

可见性

可见性:是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值

存在不可见问题的根本原因是由于缓存的存在,线程持有的是共享变量的副本,无法感知其他线程对于共享变量的更改,导致读取的值不是最新的。但是 final 修饰的变量是不可变的,就算有缓存,也不会存在不可见的问题

main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:

java
static boolean run = true;  //添加volatile
public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(()->{
        while(run){
        // ....
        }
    });
    t.start();
    sleep(1);
    run = false; // 线程t不会如预想的停下来
}

原因:

  • 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存
  • 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器(即时编译器)会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率
  • 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值

An image

原子性

原子性:不可分割,完整性,也就是说某个线程正在做某个具体业务时,中间不可以被分割,需要具体完成,要么同时成功,要么同时失败,保证指令不会受到线程上下文切换的影响

定义原子操作的使用规则:

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

有序性

有序性:在本线程内观察,所有操作都是有序的;在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序

CPU 的基本工作是执行存储的指令序列,即程序,程序的执行过程实际上是不断地取出指令、分析指令、执行指令的过程,为了提高性能,编译器和处理器会对指令重排,一般分为以下三种:

tex
源代码 -> 编译器优化的重排 -> 指令并行的重排 -> 内存系统的重排 -> 最终执行指令

现代 CPU 支持多级指令流水线,几乎所有的冯•诺伊曼型计算机的 CPU,其工作都可以分为 5 个阶段:取指令、指令译码、执行指令、访存取数和结果写回,可以称之为五级指令流水线。CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(每个线程不同的阶段),本质上流水线技术并不能缩短单条指令的执行时间,但变相地提高了指令地吞吐率

处理器在进行重排序时,必须要考虑指令之间的数据依赖性

  • 单线程环境也存在指令重排,由于存在依赖性,最终执行结果和代码顺序的结果一致
  • 多线程环境中线程交替执行,由于编译器优化重排,会获取其他线程处在不同阶段的指令同时执行

补充知识:

  • 指令周期是取出一条指令并执行这条指令的时间,一般由若干个机器周期组成
  • 机器周期也称为 CPU 周期,一条指令的执行过程划分为若干个阶段(如取指、译码、执行等),每一阶段完成一个基本操作,完成一个基本操作所需要的时间称为机器周期
  • 振荡周期指周期性信号作周期性重复变化的时间间隔

缓存机制

缓存结构

在计算机系统中,CPU 高速缓存(CPU Cache,简称缓存)是用于减少处理器访问内存所需平均时间的部件;在存储体系中位于自顶向下的第二层,仅次于 CPU 寄存器;其容量远小于内存,但速度却可以接近处理器的频率

CPU 处理器速度远远大于在主内存中的,为了解决速度差异,在它们之间架设了多级缓存,如 L1、L2、L3 级别的缓存,这些缓存离 CPU 越近就越快,将频繁操作的数据缓存到这里,加快访问速度

An image

从 CPU 到大约需要的时钟周期
寄存器1 cycle (4GHz 的 CPU 约为 0.25ns)
L13~4 cycle
L210~20 cycle
L340~45 cycle
内存120~240 cycle

缓存使用

当处理器发出内存访问请求时,会先查看缓存内是否有请求数据,如果存在(命中),则不用访问内存直接返回该数据;如果不存在(失效),则要先把内存中的相应数据载入缓存,再将其返回处理器

缓存之所以有效,主要因为程序运行时对内存的访问呈现局部性(Locality)特征。既包括空间局部性(Spatial Locality),也包括时间局部性(Temporal Locality),有效利用这种局部性,缓存可以达到极高的命中率

伪共享

缓存以缓存行 cache line 为单位,每个缓存行对应着一块内存,一般是 64 byte(8 个 long),在 CPU 从主存获取数据时,以 cache line 为单位加载,于是相邻的数据会一并加载到缓存中

缓存会造成数据副本的产生,即同一份数据会缓存在不同核心的缓存行中,CPU 要保证数据的一致性,需要做到某个 CPU 核心更改了数据,其它 CPU 核心对应的整个缓存行必须失效,这就是伪共享

An image

解决方法:

  • padding:通过填充,让数据落在不同的 cache line 中
  • @Contended:原理参考 无锁 → Adder → 优化机制 → 伪共享

Linux 查看 CPU 缓存行:

  • 命令:cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size64
  • 内存地址格式:[高位组标记] [低位索引] [偏移量]

缓存一致

缓存一致性:当多个处理器运算任务都涉及到同一块主内存区域的时候,将可能导致各自的缓存数据不一样

An image

MESI(Modified Exclusive Shared Or Invalid)是一种广泛使用的支持写回策略的缓存一致性协议,CPU 中每个缓存行(caceh line)使用 4 种状态进行标记(使用额外的两位 bit 表示):

  • M:被修改(Modified)

    该缓存行只被缓存在该 CPU 的缓存中,并且是被修改过的,与主存中的数据不一致 (dirty),该缓存行中的内存需要写回 (write back) 主存。该状态的数据再次被修改不会发送广播,因为其他核心的数据已经在第一次修改时失效一次

    当被写回主存之后,该缓存行的状态会变成独享 (exclusive) 状态

  • E:独享的(Exclusive)

    该缓存行只被缓存在该 CPU 的缓存中,是未被修改过的 (clear),与主存中数据一致,修改数据不需要通知其他 CPU 核心,该状态可以在任何时刻有其它 CPU 读取该内存时变成共享状态 (shared)

    当 CPU 修改该缓存行中内容时,该状态可以变成 Modified 状态

  • S:共享的(Shared)

    该状态意味着该缓存行可能被多个 CPU 缓存,并且各个缓存中的数据与主存数据一致,当 CPU 修改该缓存行中,会向其它 CPU 核心广播一个请求,使该缓存行变成无效状态 (Invalid),然后再更新当前 Cache 里的数据

  • I:无效的(Invalid)

    该缓存是无效的,可能有其它 CPU 修改了该缓存行

解决方法:各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,协议主要有 MSI、MESI 等

处理机制

单核 CPU 处理器会自动保证基本内存操作的原子性

多核 CPU 处理器,每个 CPU 处理器内维护了一块内存,每个内核内部维护着一块缓存,当多线程并发读写时,就会出现缓存数据不一致的情况。处理器提供:

  • 总线锁定:当处理器要操作共享变量时,在 BUS 总线上发出一个 LOCK 信号,其他处理器就无法操作这个共享变量,该操作会导致大量阻塞,从而增加系统的性能开销(平台级别的加锁
  • 缓存锁定:当处理器对缓存中的共享变量进行了操作,其他处理器有嗅探机制,将各自缓存中的该共享变量的失效,读取时会重新从主内存中读取最新的数据,基于 MESI 缓存一致性协议来实现

有如下两种情况处理器不会使用缓存锁定:

  • 当操作的数据跨多个缓存行,或没被缓存在处理器内部,则处理器会使用总线锁定
  • 有些处理器不支持缓存锁定,比如:Intel 486 和 Pentium 处理器也会调用总线锁定

总线机制:

  • 总线嗅探:每个处理器通过嗅探在总线上传播的数据来检查自己缓存值是否过期了,当处理器发现自己的缓存对应的内存地址的数据被修改,就将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行操作时,会重新从内存中把数据读取到处理器缓存中
  • 总线风暴:当某个 CPU 核心更新了 Cache 中的数据,要把该事件广播通知到其他核心(写传播),CPU 需要每时每刻监听总线上的一切活动,但是不管别的核心的 Cache 是否缓存相同的数据,都需要发出一个广播事件,不断的从主内存嗅探和 CAS 循环,无效的交互会导致总线带宽达到峰值;因此不要大量使用 volatile 关键字,使用 volatile、syschonized 都需要根据实际场景