java内存模型

Java内存模型(Java Memory Model,JMM)是Java虚拟机规范定义的,用来屏蔽掉Java程序在各种不同的硬件和操作系统对内存的访问的差异。

常常和Java内存结构混淆,这是两个不同的概念。

Java内存结构是指 JVM运行时将数据分区域存储 ,简单的说就是不同的数据放在不同的地方。通常又叫 运行时数据区域

1、为什么要有Java内存模型?

简单的说是屏蔽硬件的差异

Java虚拟机规范中试图定义一种Java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异。——《深入理解Java虚拟机》

Java程序运行在不同配置的服务器上,CPU和内存的配置都不一样,如何保证数据的一致性,就需要Java内存模型了,定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

CPU和内存是不直接通讯的,因为两者的运行效率是不一样的,为了提高效率,计算机引入高速缓存来充当介质。在多核CPU中,每个CPU都拥有自己的缓存,那同一个数据,在CPU各自的高速缓存中,以及内存中,可能就不一致了。

为了解决这一问题,又引出了缓存一致性协议(MESI)。在读写时要根据协议进行操作,来维护缓存的一致性。

MESI的详解可参考:https://www.cnblogs.com/yanlong300/p/8986041.html

å¾ç

 

CPU和内存这么复杂,在写Java程序的时候, 跨平台部署,都要考虑底层的硬件差异,程序员肯定不干啊,那这时候,就出现了Java内存模型,充当一个媒介。

 

2. 内存模型概念

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

不同架构的物理计算机可以有不一样的内存模型,Java 虚拟机也有自己的内存模型。

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

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

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

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

3. Java 内存模型的组成

主内存

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

工作内存

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

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

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

图片

 

4. JVM 内存操作的并发问题

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

工作内存数据一致性 

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

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

指令重排序优化 

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

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

同样的,指令重排序不是随意重排序,它需要满足以下两个条件:

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

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

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

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

 

5、内存交互

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

交互操作流程

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

图片

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

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

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

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

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

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

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

  • lock(锁定):作用于主内存的变量,一个变量在同一时间只能一个线程锁定,该操作表示这条线程独占这个变量

  • unlock(解锁):作用于主内存的变量,表示这个变量的状态由处于锁定状态被释放,这样其他线程才能对该变量进行锁定

  • read(读取):作用于主内存变量,表示把一个主内存变量的值传输到线程的工作内存,以便随后的load操作使用

  • load(载入):作用于线程的工作内存的变量,表示把read操作从主内存中读取的变量的值放到工作内存的变量副本中(副本是相对于主内存的变量而言的)

  • use(使用):作用于线程的工作内存中的变量,表示把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行该操作

  • assign(赋值):作用于线程的工作内存的变量,表示把执行引擎返回的结果赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就会执行该操作

  • store(存储):作用于线程的工作内存中的变量,把工作内存中的一个变量的值传递给主内存,以便随后的write操作使用

  • write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中。

 

å¾ç

 

6、并发内存模型

上述提到的内存交互过程,在多线程并发情况下,数据就会有非一致性问题。

以上述例子来说,线程1、线程2同时向主存取值x,x初始值为0,同时加1,线程1更新x的值为1之后,写到主存;线程2还没拿到x的最新值,又加1,把1又回写主存,就样就会有脏数据。

并发模型为了解决这一问题,设计了三个特性:

  • 原子性

 

 

一个操作或者多个操作,要么全部执行,要么就都不执行,执行的过程不会被任何因素打断。对基本类型的单次读取和赋值操作都是原子性操作。

由Java内存模型来直接保证原子性的变量操作包括read、load、use、assign、store、write这6个动作,虽然存在long和double的特例,但基本可以忽略不计,目前虚拟机基本都对其实现了原子性。

 

如果需要更大范围的控制,lock和unlock也可以满足需求。lock和unlock虽然没有被虚拟机直接开给用户使用,但是提供了字节码层次的指令monitorentermonitorexit对应这两个操作,对应到java代码就是synchronized关键字,因此在synchronized块之间的代码都具有原子性。

注意:volatile并不能解决原子性问题。

  • 可见性

    可见性是指一个线程修改了一个变量的值后,其他线程立即可以感知到这个值的修改。正如前面所说,volatile类型的变量在修改后会立即同步给主内存,在使用的时候会从主内存重新读取,是依赖主内存为中介来保证多线程下变量对其他线程的可见性的。

    Java最常用的就是提供volatile保持可见性,synchronizedfinal也可以实现可见性。synchronized关键字是通过unlock之前必须把变量同步回主内存来实现的,final则是在初始化后就不会更改,所以只要在初始化过程中没有把this指针传递出去也能保证对其他线程的可见性。

  • 有序性

    有序性规则表现在以下两种场景:

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

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

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

保证有序性的关键字有volatilesynchronizedvolatile禁止了指令重排序,而synchronized则由“一个变量在同一时刻只能被一个线程对其进行lock操作,串行操作”来保证。

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 操作)。

 

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

  • 规则 1、规则 2,工作内存中的共享变量作为主内存的副本,主内存变量的值同步到工作内存需要 read 和 load 一起使用。

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

  • 规则 3、规则 4,由于工作内存中的共享变量是主内存的副本,为保证数据一致性,当工作内存中的变量被字节码引擎重新赋值,必须同步回主内存。如果工作内存的变量没有被更新,不允许无原因同步回主内存。

  • 规则 5,由于工作内存中的共享变量是主内存的副本,必须从主内存诞生。

  • 规则 6、7、8、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 变量后面的语句放到其前面执行。

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

举个例子:

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 型变量使用场景

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

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。


7、总结

Java内存模型定义了线程和内存间的抽象关系,在硬件的体现就是cpu核线程,高速缓存和主存间的关系。在并发情况下,Java通过引入synchronized和volatile解决 可见性、有序性 问题。

 

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值