Java 内存模型详解

一、基础

(一)并发编程模型的分类

       在并发编程中,我们需要处理两个关键问题:线程之间如何通信线程之间如何同步(这里的线程是指并发执行的活动实体)。

      通信:指线程之间以何种机制来交换信息

      同步:指程序用于控制不同线程之间操作发生相对顺序的机制

       线程之间的通信机制有两种:共享内存和消息传递Java 的并发采用的是共享内存模型,Java 线程之间的通信总是隐式进行

       共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写 - 读内存中的公共状态来隐式进行通信。同步是显式进行的。必须显式指定某个方法或某段代码需要在线程之间互斥执行

       消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

(二)Java 内存模型的抽象

        在 Java 中,所有实例域、静态域和数组元素存储在堆内存中堆内存在线程之间共享(本文使用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量(Local variables),方法定义参数(java 语言规范称之为 formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响

       Java 线程之间的通信由 Java 内存模型(本文简称为 JMM)控制JMM 决定一个线程对共享变量的写入何时对另一个线程可见

       从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系线程之间的共享变量存储在主内存(main memory)中每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读 / 写共享变量的副本。本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化

       JMM 通过控制主内存与每个线程的本地内存之间的交互,来提供内存可见性保证。

(三)重排序

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

1、编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

2、指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序

3、内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

      对于处理器重排序,JMM 提供内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。

       JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,提供一致的内存可见性保证。

(四)处理器重排序与内存屏障指令

       现代的处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。

       同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见;而且处理器都会对写、读做重排序,这个特性会导致有序性问题。

      下面是常见处理器允许的重排序类型的列表:

重排序类型列表

上表单元格中的“N”表示处理器不允许两个操作重排序,“Y”表示允许重排序。

※注 1:sparc-TSO 是指以 TSO(Total Store Order) 内存模型运行时,sparc 处理器的特性。

※注 2:上表中的 x86 包括 x64 及 AMD64。

※注 3:由于 ARM 处理器的内存模型与 PowerPC 处理器的内存模型非常类似,本文将忽略它。

※注 4:数据依赖性后文会专门说明。

       为了保证内存可见性,Java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM 把内存屏障指令分为下列四类:

内存屏障指令

       StoreLoad Barriers 会使该屏障之前的所有内存访问指令存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。StoreLoad Barriers 是一个“全能型”的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush)

(五)happens-before

       从 JDK5 开始,Java 使用新的 JSR -133 内存模型。JSR-133 提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系这两个操作既可以是在一个线程之内,也可以是在不同线程之间。 

       与程序员密切相关的 happens-before 规则(前4重要)如下:

1、程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。

2、监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。

3、volatile 变量规则:对一个 volatile 域的写,happens- before 于任意后续对这个 volatile 域的读。

4、传递性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。

5、start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。

6、join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

7、程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。

8、对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。

注意:       

       两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。

       happens-before 与 JMM 的关系如下图所示:

        如上图所示,一个 happens-before 规则通常对应于多个编译器重排序规则和处理器重排序规则。


二、重排序

(一)数据依赖性

       如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。

       编译器和处理器在重排序时,会遵守数据依赖性编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

(二)as-if-serial 语义

       as-if-serial 语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变编译器,runtime 和处理器都必须遵守 as-if-serial 语义。

       为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。


(三)程序顺序规则

       在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能的开发并行度。编译器和处理器遵从这一目标,从 happens- before 的定义我们可以看出,JMM 同样遵从这一目标。

(四)重排序对多线程的影响

       在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是 as-if-serial 语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。


三、顺序一致性

(一)数据竞争与顺序一致性保证

       当程序未正确同步时,就会存在数据竞争。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。

       JMM 对正确同步的多线程程序的内存一致性做了如下保证:

       如果程序是正确同步的,程序的执行将具有顺序一致性。这里的同步是指广义上的同步,包括对常用同步原语(lock,volatile 和 final)的正确使用

(二)顺序一致性内存模型

      顺序一致性内存模型提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:

      1、一个线程中的所有操作必须按照程序的顺序来执行

       不管程序是否同步,所有线程都只能看到一个单一的操作执行顺序在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。

      同步程序的顺序一致性效果

       在顺序一致性模型中,所有操作完全按程序的顺序串行执行而在 JMM 中,临界区内的代码可以重排序但 JMM 不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。

       在不改变(正确同步的)程序执行结果的前提下,尽可能的为编译器和处理器的优化打开方便之门。

2、未同步程序的执行特性

       对于未同步或未正确同步的多线程程序,JMM 只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false),JMM 保证线程读操作读取到的值不会无中生有(out of thin air)的冒出来

       为了实现最小安全性,JVM 在堆上分配对象时,首先会清零内存空间,然后才会在上面分配对象(JVM 内部会同步这两个操作)。因此,在以清零的内存空间(pre-zeroed memory)分配对象时,域的默认初始化已经完成了。

       未同步程序在这两个模型中的执行特性有下面几个差异:

       1、顺序一致性模型保证单线程内的操作会按程序的顺序执行,而 JMM 不保证单线程内的操作会按程序的顺序执行

       2、顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。

       3、JMM 不保证对 64 位的 long 型和 double 型变量的读 / 写操作具有原子性,而顺序一致性模型保证对所有的内存读 / 写操作都具有原子性。

        在计算机中,数据通过总线在处理器和内存之间传递每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(bus transaction)。总线事务包括读事务(read transaction)和写事务(write transaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读 / 写内存中一个或多个物理上连续的字。这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其它所有的处理器和 I/O 设备执行内存的读 / 写。

       总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行在任意时间点,最多只能有一个处理器能访问内存这个特性确保了单个总线事务之中的内存读 / 写操作具有原子性。

       在一些 32 位的处理器上,如果要求对 64 位数据的读 / 写操作具有原子性,会有比较大的开销。为了照顾这种处理器,Java 语言规范鼓励但不强求 JVM 对 64 位的 long 型变量和 double 型变量的读 / 写具有原子性。当 JVM 在这种处理器上运行时,会把一个 64 位 long/ double 型变量的读 / 写操作拆分为两个 32 位的读 / 写操作来执行。这两个 32 位的读 / 写操作可能会被分配到不同的总线事务中执行,此时对这个 64 位变量的读 / 写将不具有原子性。


四、总结

JMM 的设计

       从 JMM 设计者的角度来说,在设计 JMM 时,需要考虑两个关键因素:   

1、程序员对内存模型的使用。程序员希望内存模型易于理解,易于编程。程序员希望基于一个强内存模型来编写代码。

2、编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。

       由于这两个因素互相矛盾,所以 JSR-133 专家组在设计 JMM 时的核心目标就是找到一个好的平衡点:一方面要为程序员提供足够强的内存可见性保证另一方面,对编译器和处理器的限制要尽可能的放松。

因此,JMM 把 happens- before 要求禁止的重排序分为了下面两类:

1、会改变程序执行结果的重排序。

2、不会改变程序执行结果的重排序。

JMM 对这两种不同性质的重排序,采取了不同的策略:

1、对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。

2、对于不会改变程序执行结果的重排序,JMM 对编译器和处理器不作要求(JMM 允许这种重排序)。

       JMM 向程序员提供的 happens- before 规则能满足程序员的需求。JMM 的 happens- before 规则不但简单易懂,而且也向程序员提供了足够强的内存可见性保证。JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行

JMM 的内存可见性保证

Java 程序的内存可见性保证按程序类型可以分为下列三类:

1、单线程程序单线程程序不会出现内存可见性问题。编译器,runtime 和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。

2、正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。JMM 通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。

3、未同步 / 未正确同步的多线程程序,JMM 为它们提供了最小安全性保障。线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false)。

JSR-133 对旧内存模型的修补

JSR-133 对 JDK5 之前的旧内存模型的修补主要有两个:

1、增强 volatile 的内存语义。旧内存模型允许 volatile 变量与普通变量重排序。JSR-133 严格限制 volatile 变量与普通变量的重排序,使 volatile 的写 - 读和锁的释放 - 获取具有相同的内存语义。

2、增强 final 的内存语义。在旧内存模型中,多次读取同一个 final 变量的值可能会不相同。为此,JSR-133 为 final 增加了两个重排序规则。现在,final 具有了初始化安全性

你可能感兴趣的:(Java 内存模型详解)