Java内存模型(JMM)

125 阅读15分钟

1 CPU的内存模型

1.1 局部性原理

在CPU访问存储设备时,无论是存取数据抑或存取指令,都趋于聚集在一片连续的区域中,这就被称为局部性原理。

  • 时间局部性(Temporal Locality):如果一个信息项正在被访问,那么在近期它很可能还会被再次访问。比如循环、递归、方法的反复调用等。
  • 空间局部性(Spatial Locality):如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。 比如顺序执行的代码、连续创建的两个对象、数组等

1.2 内存模型

为了解决CPU处理速度和内存处理速度不对等的问题。计算机在CPU和主内存(Memory)之间增加了高速缓存cache。

带有高速缓存的CPU执行计算的流程

  • 程序以及数据被加载到主内存
  • 指令和数据被加载到CPU的高速缓存 (多三级缓存,数据加载顺序:L3->L2->L1)
  • CPU执行指令,把结果写到高速缓存
  • 高速缓存中的数据写回主内存

image.png

1.3 三级缓存

现代的高速缓存通常有三层,分别叫 L1、L2、L3 Cache。其中L3是多CPU共享,而L1和L2是每个CPU独占的。

image.png

cache line cache由cache line组成,每个cache line 64位(根据不同架构,也可能是32位或128位),计算机将数据从主存读入Cache时,是以cache line为单位,这也是局部性原理的体现。 TODO

1.4 CPU缓存一致性问题

CPU缓存一致性(Cache Coherence)问题指 CPU Cache与内存的不一致性问题。 当多个线程并行执行一个共享数据时,都同时在本cpu的高速缓存中的时候,最新的数据对其他的cpu是不可见的。

单核CPU中,只需要考虑Cache与内存的一致性。但是在多核CPU中,每个核心都有一份独占的 Cache,就会存在一个核心修改数据后,两个核心 Cache 数据不一致的问题。因此,CPU 的缓存一致性问题有两个维度:

  • Cache 与内存的一致性问题
  • 多核心Cache的一致性问题

为了解决该问题。所以CPU又引入了总线锁与缓存锁。

1.4.1 总线加LOCK#锁

在早期的CPU当中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从其内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。

缺点 锁住总线期间,其他CPU无法访问内存,导致效率低下。

1.4.2 缓存一致性协议MESI

缓存一致性协议指的是在 CPU 高速缓存与主内存交互的时候需要遵守的原则和规范。不同的CPU中,缓存一致性协议通常也会有所不同。MESI协议是当前最主流的缓存一致性协议。

MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

在MESI协议中,每个缓存行有4个状态,MESI 协议其实是 CPU Cache的有限状态机:

  • M(Modified,已修改): 表明 Cache 块被修改过,但未同步回内存;
  • E(Exclusive,独占): 表明 Cache 块被当前核心独占,而其它核心的同一个 Cache 块会失效;
  • S(Shared,共享): 表明 Cache 块被多个核心持有且都是有效的;
  • I(Invalidated,已失效): 表明 Cache 块的数据是过时的。

MESI协议在以下两种情况中会失效

  • CPU不支持缓存一致性协议。

  • 当变量超过一个缓存行的大小,缓存一致性协议是针对单个缓存行进行加锁,此时,只能改用总线加锁的方式

MESI协议只能保证并发编程中的可见性,并未解决原子性和有序性的问题。

2 JAVA内存模型

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

在并发编程中,需要处理两个关键问题:线程之间如何通信和线程之间如何同步:

  • 线程通信:是指线程之间以何种机制来交换信息,在命令式编程中,线程之间的通信机制有两种:共享内存消息传递。Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行。
  • 线程同步:是指程序用于控制不同线程之间操作发生相对顺序的机制。在Java中,可以通过volatile,synchronized, 锁等方式实现同步。

2.2 重排序

2.2.1 重排序

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。

重排序分三类

  • 1、编译器优化的重排序
  • 2、指令级并行的重排序
  • 3、内存系统的重排序

Java源代码会经历编译器优化重排 —> 指令并行重排 —> 内存系统重排的过程,最终才变成操作系统可执行的指令序列。
重排序可以保证串行语义一致,但在多线程下,对存在控制依赖操作的重排序,可能改变程序的执行结果。

  • 对于编译器(1编译器),通过禁止特定类型的编译器重排序的方式来禁止重排序。
  • 对于处理器(2指令级和3内存系统),通过插入内存屏障 Memory Barrier的方式来禁止特定类型的处理器重排序。指令并行重排和内存系统重排都属于是处理器级别的指令重排序。

2.2.2 内存屏障

内存屏障分为两种:Load Barrier Store Barrier, 即读屏障和写屏障。

  • 对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据;
  • 对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

内存屏障有两个作用:

  • 阻止屏障两侧的指令重排序;
  • 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。

java的内存屏障通常所谓的四种,实际上也是上述两种的组合,完成一系列的屏障和数据同步功能。

  • LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。

  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。

  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。

  • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

2.3 Java内存模型(Java Memory Model)

2.3.1 为什么需要 JMM?

一般来说,编程语言也可以直接复用操作系统层面的内存模型。不过,不同的操作系统内存模型不同。Java 语言是跨平台的,它需要自己提供一套内存模型以屏蔽系统差异。

JMM看作是Java定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从Java源代码到CPU可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范。

2.3.2 Java内存模型的抽象结构

在java中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享。Java线程之间的通信由java内存模型(JMM)控制。 线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读写共享变量的副本。本地内存是抽象概念,并不真实存在。

Java内存模型规定了线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递(通信)均需要在主内存来完成。

image.png

2.3.3 同步操作

JMM又是如何保证主内存和工作内存中的变量一致性?Java内存模型定义了8种操作。

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

如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行read和load操作,如果要把变量从工作内存同步回主内存,就要按顺序执行store和write操作。注意,Java内存模型只要求上述两个操作必须按顺序执行,但不要求是连续执行。

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

  • 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。
  • 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use、store操作之前,必须先执行assign和load操作。
  • 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值。
  • 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。

线程 ----- 工作内存 ----- 主存
use <-- load <-- read <--lock
assign --> store --> write --> unlock

2.4 一致性问题

Java内存模型就是为了解决多线程环境下共享变量的一致性问题,一致性主要包含三大特性:原子性、可见性、有序性,

2.4.1 volatilen内存语义

如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

volatile的两条实现原则。

  • Lock前缀指令会引起处理器缓存回写到内存
  • 一个处理器的缓存回写到内存会导致其他处理器的缓存无效

volatile变量自身具有下列特性

  • 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入
  • 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障(禁止前面的写与volatile写重排序)

  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障(禁止volatile写与后面可能有的读和写重排序)

  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障(禁止volatile读与后面的读操作重排序)

  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障(禁止volatile读与后面的写操作重排序)

其中重点说下StoreLaod屏障,它是确保可见性的关键,因为它会将屏障之前的写缓冲区中的数据全部刷新到主内存中。

2.4.2 原子性

由Java内存模型来直接保证的原子性操作包括read、load、use、assign、store、write这两个操作,我们可以大致认为基本类型变量的读写是具备原子性的。

如果应用需要一个更大范围的原子性,Java内存模型还提供了lock和unlock这两个操作来满足这种需求,尽管不能直接使用这两个操作,但我们可以使用它们更具体的实现synchronized来实现。

lock和unlock是实现synchronized的基础,Java并没有把lock和unlock操作直接开放给用户使用,但是却提供了两个更高层次的指令来隐式地使用这两个操作,即moniterenter和moniterexit。

2.4.3 可见性

普通变量与volatile变量的主要区别是是否会在修改之后立即同步回主内存,以及是否在每次读取前立即从主内存刷新

synchronized的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中,即执行store和write操作”这条规则获取的。

final的可见性是指被final修饰的字段在构造器中一旦被初始化完成,那么其它线程中就能看见这个final字段了。

2.4.4 有序性

如果在本线程中观察,所有的操作都是有序的;如果在另一个线程中观察,所有的操作都是无序的。 前半句是指线程内表现为串行的语义,后半句是指“指令重排序”现象和“工作内存和主内存同步延迟”现象。 Java中提供了volatilesynchronized两个关键字来保证有序性。 volatile天然就具有有序性,因为其禁止重排序。 synchronized的有序性是由“一个变量同一时刻只允许一条线程对其进行lock操作”这条规则获取的。