Java内存模型原理,你真的理解透彻了吗?

内存模型产生背景编程

在介绍 Java 内存模型以前,咱们先了解一下物理计算机中的并发问题,理解这些问题能够搞清楚内存模型产生的背景。缓存

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

物理机的并发问题多线程

硬件的效率问题架构

计算机处理器处理绝大多数运行任务都不可能只靠处理器“计算”就能完成,处理器至少须要与内存交互,如读取运算数据、存储运算结果,这个 I/O 操做很难消除(没法仅靠寄存器完成全部运算任务)。并发

因为计算机的存储设备与处理器的运算速度有几个数量级的差距,为了不处理器等待缓慢的内存 完成 读写操做,现代计算机系统经过加入一层读写速度尽量接近处理器运算速度的高速缓存。app

缓存做为内存和处理器之间的缓冲: 将运算须要使用到的数据复制到缓存中,让运算能快速运行,当运算结束后再从缓存同步回内存之中。 编程语言

缓存一致性问题性能

基于高速缓存的存储系统交互很好的解决了处理器与内存速度的矛盾,可是也为计算机系统带来更高的复杂度,由于引入了一个新问题:缓存一致性。学习

在多处理器的系统中(或者单处理器多核的系统),每一个处理器(每一个核)都有本身的高速缓存,而它们有共享同一主内存(Main Memory)。

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

为此,须要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操做,来维护缓存的一致性。

代码乱序执行优化问题

为了使得处理器内部的运算单元尽可能被充分利用,提升运算效率,处理器可能会对输入的代码进行乱序执行。

处理器会在计算以后将乱序执行的结果重组,乱序优化能够保证在单线程下该执行结果与顺序执行的结果是一致的,但不保证程序中各个语句计算的前后顺序与输入代码中的顺序一致。在此我向你们推荐一个架构学习交流裙。交流学习裙号:821169538,里面会分享一些资深架构师录制的视频录像 

乱序执行技术是处理器为提升运算速度而作出违背代码原有顺序的优化。在单核时代,处理器保证作出的优化不会致使执行结果远离预期目标,但在多核环境下却并不是如此。

在多核环境下, 若是存在一个核的计算任务依赖另外一个核计算任务的中间结果。

并且对相关数据读写没作任何防御措施,那么其顺序性并不能靠代码的前后顺序来保证,处理器最终得出的结果和咱们逻辑获得的结果可能会大不相同。

以上图为例进行说明,CPU 的 core2 中的逻辑 B 依赖 core1 中的逻辑 A 先执行:

正常状况下,逻辑 A 执行完以后再执行逻辑 B。

在处理器乱序执行优化状况下,有可能致使 flag 提早被设置为 true,致使逻辑 B 先于逻辑 A 执行。

Java 内存模型的组成分析

内存模型概念

为了更好解决上面提到的系列问题,内存模型被总结提出,咱们能够把内存模型理解为在特定操做协议下,对特定的内存或高速缓存进行读写访问的过程抽象。

不一样架构的物理计算机能够有不同的内存模型,Java 虚拟机也有本身的内存模型。

Java 虚拟机规范中试图定义一种 Java 内存模型(Java Memory Model,简称 JMM)来屏蔽掉各类硬件和操做系统的内存访问差别,以实现让 Java 程序在各类平台下都能达到一致的内存访问效果,没必要由于不一样平台上的物理机的内存模型的差别,对各平台定制化开发程序。

更具体一点说,Java 内存模型提出目标在于,定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

此处的变量(Variables)与 Java 编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数值对象的元素,但不包括局部变量与方法参数,由于后者是线程私有的。

注:若是局部变量是一个 reference 类型,它引用的对象在 Java 堆中可被各个线程共享,可是 reference 自己在 Java 栈的局部变量表中,它是线程私有的。

Java 内存模型的组成

主内存

Java 内存模型规定了全部变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件的主内存名字同样,二者能够互相类比,但此处仅是虚拟机内存的一部分)。

工做内存

每条线程都有本身的工做内存(Working Memory,又称本地内存,可与前面介绍的处理器高速缓存类比),线程的工做内存中保存了该线程使用到的变量的主内存中的共享变量的副本拷贝。

工做内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其余的硬件和编译器优化。

Java 内存模型抽象示意图以下:

JVM 内存操做的并发问题

结合前面介绍的物理机的处理器处理内存的问题,能够类比总结出 JVM 内存操做的问题, 下面介绍的 Java 内存模型的执行处理将围绕解决这两个问题展开。

工做内存数据一致性 

各个线程操做数据时会保存使用到的主内存中的共享变量副本,当多个线程的运算任务都涉及同一个共享变量时,将致使各自的共享变量副本不一致,若是真的发生这种状况,数据同步回主内存以谁的副本数据为准? 

Java 内存模型主要经过一系列的数据同步协议、规则来保证数据的一致性,后面再详细介绍。

指令重排序优化 

Java 中重排序一般是编译器或运行时环境为了优化程序性能而采起的对指令进行从新排序执行的一种手段。

重排序分为两类: 编译期重排序和运行期重排序,分别对应编译时和运行时环境。 

一样的,指令重排序不是随意重排序,它须要知足如下两个条件:

在单线程环境下不能改变程序运行的结果。 即时编译器(和处理器)须要保证程序可以遵照 as-if-serial 属性。

通俗地说,就是在单线程状况下,要给程序一个顺序执行的假象。即通过重排序的执行结果要与顺序执行的结果保持一致。

存在数据依赖关系的不容许重排序。

多线程环境下,若是线程处理逻辑之间存在依赖关系,有可能由于指令重排序致使运行结果与预期不一样,后面再展开 Java 内存模型如何解决这种状况。

Java 内存间的交互操做

在理解 Java 内存模型的系列协议、特殊规则以前,咱们先理解 Java 中内存间的交互操做。

交互操做流程

为了更好理解内存的交互操做,以线程通讯为例,咱们看看具体如何进行线程间值的同步:

线程 1 和线程 2 都有主内存中共享变量 x 的副本,初始时,这 3 个内存中 x 的值都为 0。

线程 1 中更新 x 的值为 1 以后同步到线程 2 主要涉及两个步骤: 

线程 1 把线程工做内存中更新过的 x 的值刷新到主内存中。

线程 2 到主内存中读取线程 1 以前已更新过的 x 变量。

从总体上看,这两个步骤是线程 1 在向线程 2 发消息,这个通讯过程必须通过主内存。

JMM 经过控制主内存与每一个线程本地内存之间的交互,来为各个线程提供共享变量的可见性。

内存交互的基本操做

关于主内存与工做内存之间的具体交互协议,即一个变量如何从主内存拷贝到工做内存、如何从工做内存同步回主内存之类的实现细节,Java 内存模型中定义了下面 8 种操做来完成。

虚拟机实现时必须保证下面介绍的每种操做都是原子的,不可再分的(对于 double 和 long 型的变量来讲,load、store、read、和 write 操做在某些平台上容许有例外)。

8 种基本操做,以下图:

lock (锁定) , 做用于主内存的变量,它把一个变量标识为一条线程独占的状态。

unlock (解锁) , 做用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才能够被其余线程锁定。

read (读取) , 做用于主内存的变量,它把一个变量的值从主内存传输到线程的工做内存中,以便随后的 load 动做使用。

load (载入) , 做用于工做内存的变量,它把 read 操做从主内存中获得的变量值放入工做内存的变量副本中。

use (使用) , 做用于工做内存的变量,它把工做内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个须要使用到变量的值的字节码指令时就会执行这个操做。在此我向你们推荐一个架构学习交流裙。交流学习裙号:821169538,里面会分享一些资深架构师录制的视频录像

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

store (存储) , 做用于工做内存的变量,它把工做内存中一个变量的值传送到主内存中,以便随后 write 操做使用。

write (写入) , 做用于主内存的变量,它把 Store 操做从工做内存中获得的变量的值放入主内存的变量中。

Java 内存模型运行规则

内存交互基本操做的 3 个特性

在介绍内存交互的具体的 8 种基本操做以前,有必要先介绍一下操做的 3 个特性。

Java 内存模型是围绕着在并发过程当中如何处理这 3 个特性来创建的,这里先给出定义和基本实现的简单介绍,后面会逐步展开分析。

原子性(Atomicity) 

原子性,即一个操做或者多个操做要么所有执行而且执行的过程不会被任何因素打断,要么就都不执行。

即便在多个线程一块儿执行的时候,一个操做一旦开始,就不会被其余线程所干扰。

可见性(Visibility) 

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

正如上面“交互操做流程”中所说明的同样,JMM 是经过在线程 1 变量工做内存修改后将新值同步回主内存,线程 2 在变量读取前从主内存刷新变量值,这种依赖主内存做为传递媒介的方式来实现可见性。

有序性(Ordering) 

有序性规则表如今如下两种场景:

线程内, 从某个线程的角度看方法的执行,指令会按照一种叫“串行”(as-if-serial)的方式执行,此种方式已经应用于顺序编程语言。

线程间, 这个线程“观察”到其余线程并发地执行非同步的代码时,因为指令重排序优化,任何代码都有可能交叉执行。

惟一块儿做用的约束是:对于同步方法,同步块(synchronized 关键字修饰)以及 volatile 字段的操做仍维持相对有序。

Java 内存模型的一系列运行规则看起来有点繁琐,但总结起来,是围绕原子性、可见性、有序性特征创建。

归根究底,是为实现共享变量的在多个线程的工做内存的数据一致性,多线程并发,指令重排序优化的环境中程序能如预期运行。

happens-before 关系

介绍系列规则以前,首先了解一下 happens-before 关系:用于描述下 2 个操做的内存可见性。若是操做 A happens-before 操做 B,那么 A 的结果对 B 可见。

happens-before 关系的分析须要分为单线程和多线程的状况:

单线程下的 happens-before, 字节码的前后顺序自然包含 happens-before 关系:由于单线程内共享一份工做内存,不存在数据一致性的问题。 

在程序控制流路径中靠前的字节码 happens-before 靠后的字节码,即靠前的字节码执行完以后操做结果对靠后的字节码可见。

然而,这并不意味着前者必定在后者以前执行。实际上,若是后者不依赖前者的运行结果,那么它们可能会被重排序。

多线程下的 happens-before, 多线程因为每一个线程有共享变量的副本,若是没有对共享变量作同步处理,线程 1 更新执行操做 A 共享变量的值以后,线程 2 开始执行操做 B,此时操做 A 产生的结果对操做 B 不必定可见。

为了方便程序开发,Java 内存模型实现了下述支持 happens-before 关系的操做: 

程序次序规则, 一个线程内,按照代码顺序,书写在前面的操做 happens-before 书写在后面的操做。

锁定规则, 一个 unLock 操做 happens-before 后面对同一个锁的 lock 操做。

volatile 变量规则, 对一个变量的写操做 happens-before 后面对这个变量的读操做。

传递规则, 若是操做 A happens-before 操做 B,而操做 B 又 happens-before 操做 C,则能够得出操做 A happens-before 操做 C。

线程启动规则, Thread 对象的 start() 方法 happens-before 此线程的每一个一个动做。

线程中断规则, 对线程 interrupt() 方法的调用 happens-before 被中断线程的代码检测到中断事件的发生。

线程终结规则, 线程中全部的操做都 happens-before 线程的终止检测,咱们能够经过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程已经终止执行。

对象终结规则, 一个对象的初始化完成 happens-before 它的 finalize() 方法的开始。

内存屏障

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

内存屏障是被插入两个 CPU 指令之间的一种指令,用来禁止处理器指令发生重排序(像屏障同样),从而保障有序性的。

另外,为了达到屏障的效果,它也会使处理器写入、读取值以前,将主内存的值写入高速缓存,清空无效队列,从而保障可见性。

举个例子说明:

Store1;Store2;Load1;StoreLoad;//内存屏障 Store3;Load2;Load3;

对于上面的一组 CPU 指令(Store 表示写入指令,Load 表示读取指令,StoreLoad 表明写读内存屏障),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 中对内存屏障的使用在通常的代码中不太容易见到,常见的有 volatile 和 synchronized 关键字修饰的代码块(后面再展开介绍),还能够经过 Unsafe 这个类来使用内存屏障。

8 种操做同步的规则

JMM 在执行前面介绍 8 种基本操做时, 为了保证内存间数据一致性,JMM 中规定须要知足如下规则:

规则 1: 若是要把一个变量从主内存中复制到工做内存,就须要按顺序的执行 read 和 load 操做,若是把变量从工做内存中同步回主内存中,就要按顺序的执行 store 和 write 操做。

但 Java 内存模型只要求上述操做必须按顺序执行,而没有保证必须是连续执行。

规则 2: 不容许 read 和 load、store 和 write 操做之一单独出现。

规则 3: 不容许一个线程丢弃它的最近 assign 的操做,即变量在工做内存中改变了以后必须同步到主内存中。

规则 4: 不容许一个线程无缘由的(没有发生过任何 assign 操做)把数据从工做内存同步回主内存中。

规则 5: 一个新的变量只能在主内存中诞生,不容许在工做内存中直接使用一个未被初始化(load 或 assign )的变量。

即对一个变量实施 use 和 store 操做以前,必须先执行过了 load 或 assign 操做。

规则 6: 一个变量在同一个时刻只容许一条线程对其进行 lock 操做,但 lock 操做能够被同一条线程重复执行屡次,屡次执行 lock 后,只有执行相同次数的 unlock 操做,变量才会被解锁。因此 lock 和 unlock 必须成对出现。

规则 7: 若是对一个变量执行 lock 操做,将会清空工做内存中此变量的值,在执行引擎使用这个变量前须要从新执行 load 或 assign 操做初始化变量的值。

规则 8: 若是一个变量事先没有被 lock 操做锁定,则不容许对它执行 unlock 操做;也不容许去 unlock 一个被其余线程锁定的变量。

规则 9: 对一个变量执行 unlock 操做以前,必须先把此变量同步到主内存中(执行 store 和 write 操做)。

看起来这些规则有些繁琐,其实也不难理解:

规则 一、规则 2, 工做内存中的共享变量做为主内存的副本,主内存变量的值同步到工做内存须要 read 和 load 一块儿使用。

工做内存中的变量的值同步回主内存须要 store 和 write 一块儿使用,这 2 组操做各自都是一个固定的有序搭配,不容许单独出现。

规则 三、规则 4, 因为工做内存中的共享变量是主内存的副本,为保证数据一致性,当工做内存中的变量被字节码引擎从新赋值,必须同步回主内存。若是工做内存的变量没有被更新,不容许无缘由同步回主内存。

规则 5, 因为工做内存中的共享变量是主内存的副本,必须从主内存诞生。

规则 六、七、八、9, 为了并发状况下安全使用变量,线程能够基于 lock 操做独占主内存中的变量,其余线程不容许使用或 unlock 该变量,直到变量被线程 unlock。

volatile 型变量的特殊规则

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

volatile 的语义

volatile 主要有下面 2 种语义:

保证可见性

禁止进行指令重排序

保证可见性, 保证了不一样线程对该变量操做的内存可见性。 这里保证可见性不等同于 volatile 变量并发操做的安全性, 保证可见性具体一点解释:

线程对变量进行修改以后,要马上回写到主内存。

线程对变量读取的时候,要从主内存中读,而不是从线程的工做内存。

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

举个例子: 定义 volatile int count = 0,2 个线程同时执行 count++ 操做,每一个线程都执行 500 次,最终结果小于 1000。

缘由是每一个线程执行 count++ 须要如下 3 个步骤:

线程从主内存读取最新的 count 的值。

执行引擎把 count 值加 1,并赋值给线程工做内存。

线程工做内存把 count 值保存到主内存。

有可能某一时刻 2 个线程在步骤 1 读取到的值都是 100,执行完步骤 2 获得的值都是 101,最后刷新了 2 次 101 保存到主内存。

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

当程序执行到 volatile 变量的读操做或者写操做时, 在其前面的操做的更改确定所有已经进行,且结果已经对后面的操做可见;在其后面的操做确定尚未进行。

在进行指令优化时, 不能将在对 volatile 变量访问的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行。

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

举个例子:

volatilebooleaninitialized =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 内存屏障插入策略:在此我向你们推荐一个架构学习交流裙。交流学习裙号:821169538,里面会分享一些资深架构师录制的视频录像

在每一个 volatile 写操做的前面插入一个 StoreStore 屏障。 该屏障除了保证了屏障以前的写操做和该屏障以后的写操做不能重排序,还会保证了 v olatile 写操做以前,任何的读写操做都会先于 volatile 被提交。

在每一个 olatile 写操做的后面插入一个 StoreLoad 屏障。 该屏障除了使 v olatile 写操做不会与以后的读操做重排序外,还会刷新处理器缓存,使 v olatile 变量的写更新对其余线程可见。

在每一个 volatile 读操做的后面插入一个 LoadLoad 屏障。 该屏障除了使 volatile 读操做不会与以前的写操做发生重排序外,还会刷新处理器缓存,使 volatile 变量读取的为最新值。

在每一个 volatile 读操做的后面插入一个 LoadStore 屏障。 该屏障除了禁止了 volatile 读操做与其以后的任何写操做进行重排序,还会刷新处理器缓存,使其余线程 volatile 变量的写更新对 volatile 读操做的线程可见。

volatile 型变量使用场景

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

final 型变量的特殊规则

咱们知道,final 成员变量必须在声明的时候初始化或者在构造器中初始化,不然就会报编译错误。 

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

synchronized 的特殊规则

经过 synchronized 关键字包住的代码区域,对数据的读写进行控制:

读数据, 当线程进入到该区域读取变量信息时,对数据的读取也不能从工做内存读取,只能从内存中读取,保证读到的是最新的值。

写数据, 在同步区内对变量的写入操做,在离开同步区时就将当前线程内的数据刷新到内存中,保证更新的数据对其余线程的可见性。

long 和 double 型变量的特殊规则

Java 内存模型要求 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。

总结

因为 Java 内存模型涉及系列规则,网上的文章大部分就是对这些规则进行解析,可是不少没有解释为何须要这些规则,这些规则的做用。

其实这是不利于初学者学习的,容易绕进这些繁琐规则不知因此然,下面谈谈个人一点学习知识的我的体会:

学习知识的过程不是等同于只是理解知识和记忆知识,而是要对知识解决的问题的输入和输出创建链接。

知识的本质是解决问题,因此在学习以前要理解问题,理解这个问题要的输入和输出,而知识就是输入到输出的一个关系映射。

知识的学习要结合大量的例子来理解这个映射关系,而后压缩知识,华罗庚说过:“把一本书读厚,而后再读薄”,解释的就是这个道理,先结合大量的例子理解知识,而后再压缩知识。

相关文章
相关标签/搜索