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

【稿件】这篇文章主要介绍模型产生的问题背景,解决的问题,处理思路,相关实现规则,环环相扣,希望读者看完这篇文章后能对 Java 内存模型体系产生一个相对清晰的理解,知其然知其所以然。

10年积累的成都网站设计、成都网站建设经验,可以快速应对客户对网站的新想法和需求。提供各种问题对应的解决方案。让选择我们的客户得到更好、更有力的网络服务。我虽然不认识你,你也不认识我。但先网站策划后付款的网站建设流程,更有樟树免费网站建设让你可以放心的选择与我们合作。

内存模型产生背景

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

物理机遇到的并发问题与虚拟机中的情况有不少相似之处,物理机的解决方案对虚拟机的实现有相当的参考意义。

物理机的并发问题

硬件的效率问题

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

由于计算机的存储设备与处理器的运算速度有几个数量级的差距,为了避免处理器等待缓慢的内存完成读写操作,现代计算机系统通过加入一层读写速度尽可能接近处理器运算速度的高速缓存。

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

缓存一致性问题

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

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

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

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

代码乱序执行优化问题

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

处理器会在计算之后将乱序执行的结果重组,乱序优化可以保证在单线程下该执行结果与顺序执行的结果是一致的,但不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致。

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

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

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

以上图为例进行说明,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 (使用) ,作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时就会执行这个操作。
  • 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 指令之间的一种指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障有序性的。

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

举个例子说明:

 
 
 
 
  1. Store1; 
  2. Store2;   
  3. Load1;   
  4. StoreLoad;  //内存屏障
  5. Store3;   
  6. Load2;   
  7. 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 操作)把数据从工作内存同步回主内存中。
  • Java内存模型原理,你真的理解吗?
    文章URL:http://www.csdahua.cn/qtweb/news26/259076.html

    网站建设、网络推广公司-快上网,是专注品牌与效果的网站制作,网络营销seo公司;服务项目有等

    广告

    声明:本网站发布的内容(图片、视频和文字)以用户投稿、用户转载内容为主,如果涉及侵权请尽快告知,我们将会在第一时间删除。文章观点不代表本网站立场,如需处理请联系客服。电话:028-86922220;邮箱:631063699@qq.com。内容未经允许不得转载,或转载时需注明来源: 快上网