Java 内存模型

📦 本文以及示例源码已归档在 javacorehtml

Java 内存模型(Java Memory Model),简称 JMMjava

JVM 中试图定义一种 JMM 来屏蔽各类硬件和操做系统的内存访问差别,以实现让 Java 程序在各类平台下都能达到一致的内存访问效果git

1、物理内存模型

物理机遇到的并发问题与虚拟机中的状况有很多类似之处,物理机对并发的处理方案对于虚拟机的实现也有至关大的参考意义。github

硬件处理效率

物理内存的第一个问题是:硬件处理效率。编程

  • 绝大多数的运算任务都不可能只靠处理器“计算”就能完成,处理器至少须要与内存交互,如读取运算数据、存储运算结果,这个 I/O 操做是很难消除的(没法仅靠寄存器完成全部运算任务)。
  • 因为计算机的存储设备与处理器的运算速度有几个数量级的差距 ,这种速度上的矛盾,会下降硬件的处理效率。因此,现代计算机都不得不 加入高速缓存(Cache) 来做为内存和处理器之间的缓冲。将须要用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步会内存中,这样处理器就无需等待缓慢的内存读写了。

缓存一致性

高速缓存解决了 硬件效率问题,可是引入了一个新的问题:缓存一致性(Cache Coherence)缓存

在多处理器系统中,每一个处理器都有本身的高速缓存,而它们又共享同一主内存。当多个处理器的运算任务都涉及同一块主内存区域时,将可能致使各自的缓存数据不一致。安全

为了解决缓存一致性问题,须要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操做markdown

代码乱序执行优化

除了高速缓存之外,为了使得处理器内部的运算单元尽可能被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化。处理器会在计算以后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但不保证程序中各个语句计算的前后顺序与输入代码中的顺序一致。多线程

乱序执行技术是处理器为提升运算速度而作出违背代码原有顺序的优化。架构

  • 单核环境下,处理器保证作出的优化不会致使执行结果远离预期目标,但在多核环境下却并不是如此。
  • 多核环境下, 若是存在一个核的计算任务依赖另外一个核的计算任务的中间结果,并且对相关数据读写没作任何防御措施,那么其顺序性并不能靠代码的前后顺序来保证。

2、Java 内存模型

内存模型 这个概念。咱们能够理解为:在特定的操做协议下,对特定的内存或高速缓存进行读写访问的过程抽象。不一样架构的物理计算机能够有不同的内存模型,JVM 也有本身的内存模型。

JVM 中试图定义一种 Java 内存模型(Java Memory Model, JMM)来屏蔽各类硬件和操做系统的内存访问差别,以实现让 Java 程序 在各类平台下都能达到一致的内存访问效果

主内存和工做内存

JMM 的主要目标是 定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量(Variables)与 Java 编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数值对象的元素,但不包括局部变量与方法参数,由于后者是线程私有的,不会被共享,天然就不会存在竞争问题。为了得到较好的执行效能,JMM 并无限制执行引擎使用处理器的特定寄存器或缓存来和主存进行交互,也没有限制即便编译器进行调整代码执行顺序这类优化措施。

JMM 规定了全部的变量都存储在主内存(Main Memory)中

每条线程还有本身的工做内存(Working Memory),工做内存中保留了该线程使用到的变量的主内存的副本。工做内存是 JMM 的一个抽象概念,并不真实存在,它涵盖了缓存,写缓冲区,寄存器以及其余的硬件和编译器优化。

线程对变量的全部操做都必须在工做内存中进行,而不能直接读写主内存中的变量。不一样的线程间也没法直接访问对方工做内存中的变量,线程间变量值的传递均须要经过主内存来完成

说明:

这里说的主内存、工做内存与 Java 内存区域中的堆、栈、方法区等不是同一个层次的内存划分。

JMM 内存操做的问题

相似于物理内存模型面临的问题,JMM 存在如下两个问题:

  • 工做内存数据一致性 - 各个线程操做数据时会保存使用到的主内存中的共享变量副本,当多个线程的运算任务都涉及同一个共享变量时,将致使各自的的共享变量副本不一致。若是真的发生这种状况,数据同步回主内存以谁的副本数据为准? Java 内存模型主要经过一系列的数据同步协议、规则来保证数据的一致性。
  • 指令重排序优化 - Java 中重排序一般是编译器或运行时环境为了优化程序性能而采起的对指令进行从新排序执行的一种手段。重排序分为两类:编译期重排序和运行期重排序,分别对应编译时和运行时环境。 一样的,指令重排序不是随意重排序,它须要知足如下两个条件:
    • 在单线程环境下不能改变程序运行的结果。即时编译器(和处理器)须要保证程序可以遵照 as-if-serial 属性。通俗地说,就是在单线程状况下,要给程序一个顺序执行的假象。即通过重排序的执行结果要与顺序执行的结果保持一致。
    • 存在数据依赖关系的不容许重排序。
    • 多线程环境下,若是线程处理逻辑之间存在依赖关系,有可能由于指令重排序致使运行结果与预期不一样。

内存间交互操做

JMM 定义了 8 个操做来完成主内存和工做内存之间的交互操做。JVM 实现时必须保证下面介绍的每种操做都是 原子的(对于 double 和 long 型的变量来讲,load、store、read、和 write 操做在某些平台上容许有例外 )。

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

若是要把一个变量从主内存中复制到工做内存,就须要按序执行 readload 操做;若是把变量从工做内存中同步回主内存中,就须要按序执行 storewrite 操做。但 Java 内存模型只要求上述操做必须按顺序执行,而没有保证必须是连续执行。

JMM 还规定了上述 8 种基本操做,须要知足如下规则:

  • read 和 load 必须成对出现store 和 write 必须成对出现。即不容许一个变量从主内存读取了但工做内存不接受,或从工做内存发起回写了但主内存不接受的状况出现。
  • 不容许一个线程丢弃它的最近 assign 的操做,即变量在工做内存中改变了以后必须把变化同步到主内存中。
  • 不容许一个线程无缘由的(没有发生过任何 assign 操做)把数据从工做内存同步回主内存中
  • 一个新的变量只能在主内存中诞生,不容许在工做内存中直接使用一个未被初始化(load 或 assign )的变量。换句话说,就是对一个变量实施 use 和 store 操做以前,必须先执行过了 load 或 assign 操做。
  • 一个变量在同一个时刻只容许一条线程对其进行 lock 操做,但 lock 操做能够被同一条线程重复执行屡次,屡次执行 lock 后,只有执行相同次数的 unlock 操做,变量才会被解锁。因此 lock 和 unlock 必须成对出现
  • 若是对一个变量执行 lock 操做,将会清空工做内存中此变量的值,在执行引擎使用这个变量前,须要从新执行 load 或 assign 操做初始化变量的值。
  • 若是一个变量事先没有被 lock 操做锁定,则不容许对它执行 unlock 操做,也不容许去 unlock 一个被其余线程锁定的变量。
  • 对一个变量执行 unlock 操做以前,必须先把此变量同步到主内存中(执行 store 和 write 操做)

3、Java 内存模型规则

内存交互操做的三大特性

上文介绍了 Java 内存交互的 8 种基本操做,它们遵循 Java 内存三大特性:原子性、可见性、有序性。

而这三大特性,归根结底,是为了实现多线程的 数据一致性,使得程序在多线程并发,指令重排序优化的环境中能如预期运行。

原子性

原子性即一个操做或者多个操做,要么所有执行(执行的过程不会被任何因素打断),要么就都不执行。即便在多个线程一块儿执行的时候,一个操做一旦开始,就不会被其余线程所干扰。

在 Java 中,为了保证原子性,提供了两个高级的字节码指令 monitorentermonitorexit。这两个字节码,在 Java 中对应的关键字就是 synchronized

所以,在 Java 中可使用 synchronized 来保证方法和代码块内的操做是原子性的。

可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其余线程可以当即看获得修改的值

JMM 是经过 "变量修改后将新值同步回主内存变量读取前从主内存刷新变量值" 这种依赖主内存做为传递媒介的方式来实现的。

Java 实现多线程可见性的方式有:

  • volatile
  • synchronized
  • final

有序性

有序性规则表如今如下两种场景: 线程内和线程间

  • 线程内 - 从某个线程的角度看方法的执行,指令会按照一种叫“串行”(as-if-serial)的方式执行,此种方式已经应用于顺序编程语言。
  • 线程间 - 这个线程“观察”到其余线程并发地执行非同步的代码时,因为指令重排序优化,任何代码都有可能交叉执行。惟一块儿做用的约束是:对于同步方法,同步块(synchronized 关键字修饰)以及 volatile 字段的操做仍维持相对有序。

在 Java 中,可使用 synchronizedvolatile 来保证多线程之间操做的有序性。实现方式有所区别:

  • volatile 关键字会禁止指令重排序。
  • synchronized 关键字经过互斥保证同一时刻只容许一条线程操做。

先行发生原则

JMM 为程序中全部的操做定义了一个偏序关系,称之为 先行发生原则(Happens-Before)

先行发生原则很是重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,咱们能够经过几条规则一揽子地解决并发环境下两个操做间是否可能存在冲突的全部问题。

  • 程序次序规则 - 一个线程内,按照代码顺序,书写在前面的操做先行发生于书写在后面的操做。
  • 管程锁定规则 - 一个 unLock 操做先行发生于后面对同一个锁的 lock 操做。
  • volatile 变量规则 - 对一个 volatile 变量的写操做先行发生于后面对这个变量的读操做。
  • 线程启动规则 - Thread 对象的 start() 方法先行发生于此线程的每一个一个动做。
  • 线程终止规则 - 线程中全部的操做都先行发生于线程的终止检测,咱们能够经过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程已经终止执行。
  • 线程中断规则 - 对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,能够经过 Thread.interrupted() 方法检测到是否有中断发生。
  • 对象终结规则 - 一个对象的初始化完成先行发生于它的 finalize() 方法的开始。
  • 传递性 - 若是操做 A 先行发生于 操做 B,而操做 B 又 先行发生于 操做 C,则能够得出操做 A 先行发生于 操做 C。

内存屏障

Java 中如何保证底层操做的有序性和可见性?能够经过内存屏障。

内存屏障是被插入两个 CPU 指令之间的一种指令,用来禁止处理器指令发生重排序(像屏障同样),从而保障有序性的。另外,为了达到屏障的效果,它也会使处理器写入、读取值以前,将主内存的值写入高速缓存,清空无效队列,从而保障可见性

举个例子:

Store1;
Store2;
Load1;
StoreLoad;  //内存屏障
Store3;
Load2;
Load3;
复制代码复制代码

对于上面的一组 CPU 指令(Store 表示写入指令,Load 表示读取指令),StoreLoad 屏障以前的 Store 指令没法与 StoreLoad 屏障以后的 Load 指令进行交换位置,即重排序。可是 StoreLoad 屏障以前和以后的指令是能够互换位置的,即 Store1 能够和 Store2 互换,Load2 能够和 Load3 互换。

常见有 4 种屏障

  • LoadLoad 屏障 - 对于这样的语句 Load1; LoadLoad; Load2,在 Load2 及后续读取操做要读取的数据被访问前,保证 Load1 要读取的数据被读取完毕。
  • StoreStore 屏障 - 对于这样的语句 Store1; StoreStore; Store2,在 Store2 及后续写入操做执行前,保证 Store1 的写入操做对其它处理器可见。
  • LoadStore 屏障 - 对于这样的语句 Load1; LoadStore; Store2,在 Store2 及后续写入操做被执行前,保证 Load1 要读取的数据被读取完毕。
  • StoreLoad 屏障 - 对于这样的语句 Store1; StoreLoad; Load2,在 Load2 及后续全部读取操做执行前,保证 Store1 的写入对全部处理器可见。它的开销是四种屏障中最大的(冲刷写缓冲器,清空无效化队列)。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

Java 中对内存屏障的使用在通常的代码中不太容易见到,常见的有 volatilesynchronized 关键字修饰的代码块(后面再展开介绍),还能够经过 Unsafe 这个类来使用内存屏障。

volatile 变量的特殊规则

volatile 是 JVM 提供的 最轻量级的同步机制

volatile 的中文意思是不稳定的,易变的,用 volatile 修饰变量是为了保证变量在多线程中的可见性。

volatile 变量的特性

volatile 变量具备两种特性:

  • 保证变量对全部线程的可见性。
  • 禁止进行指令重排序
保证变量对全部线程的可见性

这里的可见性是指当一条线程修改了 volatile 变量的值,新值对于其余线程来讲是能够当即得知的。而普通变量不能作到这一点,普通变量的值在线程间传递均须要经过主内存来完成。

线程写 volatile 变量的过程:

  1. 改变线程工做内存中 volatile 变量副本的值
  2. 将改变后的副本的值从工做内存刷新到主内存

线程读 volatile 变量的过程:

  1. 从主内存中读取 volatile 变量的最新值到线程的工做内存中
  2. 从工做内存中读取 volatile 变量的副本

注意:保证可见性不等同于 volatile 变量保证并发操做的安全性

在不符合如下两点的场景中,仍然要经过枷锁来保证原子性:

- 运算结果并不依赖变量的当前值,或者可以确保只有单一的线程修改变量的值。

- 变量不须要与其余状态变量共同参与不变约束。

可是若是多个线程同时把更新后的变量值同时刷新回主内存,可能致使获得的值不是预期结果:

举个例子: 定义 volatile int count = 0,2 个线程同时执行 count++ 操做,每一个线程都执行 500 次,最终结果小于 1000,缘由是每一个线程执行 count++ 须要如下 3 个步骤:

  1. 线程从主内存读取最新的 count 的值
  2. 执行引擎把 count 值加 1,并赋值给线程工做内存
  3. 线程工做内存把 count 值保存到主内存 有可能某一时刻 2 个线程在步骤 1 读取到的值都是 100,执行完步骤 2 获得的值都是 101,最后刷新了 2 次 101 保存到主内存。
语义 2 禁止进行指令重排序

具体一点解释,禁止重排序的规则以下:

  • 当程序执行到 volatile 变量的读操做或者写操做时,在其前面的操做的更改确定所有已经进行,且结果已经对后面的操做可见;在其后面的操做确定尚未进行;
  • 在进行指令优化时,不能将在对 volatile 变量访问的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行。

普通的变量仅仅会保证该方法的执行过程当中全部依赖赋值结果的地方都能获取到正确的结果,而不能保证赋值操做的顺序与程序代码中的执行顺序一致。

举个例子:

volatile boolean initialized = false;

// 下面代码线程A中执行
// 读取配置信息,当读取完成后将initialized设置为true以通知其余线程配置可用
doSomethingReadConfg();
initialized = true;

// 下面代码线程B中执行
// 等待initialized 为true,表明线程A已经把配置信息初始化完成
while (!initialized) {
     sleep();
}
// 使用线程A初始化好的配置信息
doSomethingWithConfig();
复制代码复制代码

上面代码中若是定义 initialized 变量时没有使用 volatile 修饰,就有可能会因为指令重排序的优化,致使线程 A 中最后一句代码 "initialized = true" 在 “doSomethingReadConfg()” 以前被执行,这样会致使线程 B 中使用配置信息的代码就可能出现错误,而 volatile 关键字就禁止重排序的语义能够避免此类状况发生。

volatile 的原理

具体实现方式是在编译期生成字节码时,会在指令序列中增长内存屏障来保证,下面是基于保守策略的 JMM 内存屏障插入策略:

  • 在每一个 volatile 写操做的前面插入一个 StoreStore 屏障。 该屏障除了保证了屏障以前的写操做和该屏障以后的写操做不能重排序,还会保证了 volatile 写操做以前,任何的读写操做都会先于 volatile 被提交。
  • 在每一个 volatile 写操做的后面插入一个 StoreLoad 屏障。 该屏障除了使 volatile 写操做不会与以后的读操做重排序外,还会刷新处理器缓存,使 volatile 变量的写更新对其余线程可见。
  • 在每一个 volatile 读操做的后面插入一个 LoadLoad 屏障。 该屏障除了使 volatile 读操做不会与以前的写操做发生重排序外,还会刷新处理器缓存,使 volatile 变量读取的为最新值。
  • 在每一个 volatile 读操做的后面插入一个 LoadStore 屏障。 该屏障除了禁止了 volatile 读操做与其以后的任何写操做进行重排序,还会刷新处理器缓存,使其余线程 volatile 变量的写更新对 volatile 读操做的线程可见。

volatile 的使用场景

总结起来,就是“一次写入,处处读取”,某一线程负责更新变量,其余线程只读取变量(不更新变量),并根据变量的新值执行相应逻辑。例如状态标志位更新,观察者模型变量值发布。

long 和 double 变量的特殊规则

JMM 要求 lock、unlock、read、load、assign、use、store、write 这 8 种操做都具备原子性,可是对于 64 位的数据类型(long 和 double),在模型中特别定义相对宽松的规定:容许虚拟机将没有被 volatile 修饰的 64 位数据的读写操做分为 2 次 32 位的操做来进行,即容许虚拟机可选择不保证 64 位数据类型的 load、store、read 和 write 这 4 个操做的原子性。因为这种非原子性,有可能致使其余线程读到同步未完成的“32 位的半个变量”的值。

不过实际开发中,Java 内存模型强烈建议虚拟机把 64 位数据的读写实现为具备原子性,目前各类平台下的商用虚拟机都选择把 64 位数据的读写操做做为原子操做来对待,所以咱们在编写代码时通常不须要把用到的 long 和 double 变量专门声明为 volatile。

final 型量的特殊规则

咱们知道,final 成员变量必须在声明的时候初始化或者在构造器中初始化,不然就会报编译错误。 final 关键字的可见性是指:被 final 修饰的字段在声明时或者构造器中,一旦初始化完成,那么在其余线程无须同步就能正确看见 final 字段的值。这是由于一旦初始化完成,final 变量的值马上回写到主内存。

参考资料

相关文章
相关标签/搜索