Java内存模型 – 简介

Java内存模型的基础

  在并发编程中,需要处理两个关键问题:线程之间如何通信及线程之间如何同步,通信指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。

  Java语言的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。Java线程之间的通信由Java内存模型简称JMM(Java Memory Mode)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM是这样定义线程和主内存之间的抽象关系的:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。

  主内存主要对应用于Java堆中的对象实例数据部分,而本地内存则对应于虚拟机栈中的部分区域。从更基础的层面上说,主内存直接对应于物理硬件内存,而为了获取更好的运行速度,虚拟机可能会让本地内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是本地内存。

  本地内存是JMM的一个抽象概念,并不是真实存在的。它涵盖了缓存、写缓冲区、寄存器以及其他硬件和编译器优化。Java内存模型的抽象示意图如下所示。

  从示意图中来看,如果线程A与线程B之间要进行通信,必须经历如下2个步骤。

  1. 线程A把本地内存中更新过的共享变量刷新到主内存中。

  2. 线程B从主内存中读取线程A之前更新的共享变量。


重排序

  在执行程序的过程中,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3中类型。

  1. 编译器优化重排序。

  Java虚拟机的即时编译器中存在指令重排序(Instruction Reorder),编译器在不改变单线程程序语义的前提下,可以重新安排语句执行。

  2. 指令级并行的重排序。

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

  3. 内存系统的重排序。

  由于处理器使用缓存和读/写缓冲区,当多个处理器的运算任务都设计同一块内存区域时,数据的加载和存储操作看上去可能是乱序执行的。

  从Java源代码到最终实际执行的指令序列,会分别经历下面3中重排序。

  

  上述1属于编译器重排序,2、3属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,Java编译器在生成指令序列时,插入特定的内存屏障(Memory Barriers)指令,通过内存屏障来禁止特定类型的处理器重排序。

  编译器和处理器为了优化程序性能,可能会对指令序列进行重新排序。下表展示了常见处理器允许重排序的类型列表。(Load:装载  Store:储存)

  Load-Load Load-Store Store-Store Store-Load 数据依赖
SPARC-TSO N N N Y N
x86 N N N Y N
IA64 Y Y Y Y N
PowerPC Y Y Y Y N

 

   表格中“N”表示处理器不允许两个操作重排序,“Y”表示允许重排序。从表中可以看到,常见处理器都允许Store-Load重排序;常见处理器都不允许对存在数据依赖的操作做重排序。SPARC-TSO和X86处理器拥有相对较强的处理器内存模型,它们仅仅允许对写-读操作做重排序(因为它们都使用了写缓冲区)。 

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

屏障类型 指令示例 说明
LoadLoad Barriers Load1;LoadLoad;Load2

确保Load1数据的装载先于Load2及所有后续装载指令的装载。

StoreStore Barriers Store1; StoreStore;Store2

确保Store1数据对其他处理器可见(刷新到内存)先于Store2及后续所有存储指令。

LoadStore Barriers Load1;LoadStore;Store2

确保Load1数据装载先于Store2及所有后续的存储指令刷新到内存。

StoreLoad Barriers

Store1;StoreLoad;Load2

确保Store1数据对其他处理器可见(刷新到内存)先于Load2及所有后续装载指令的装载。

StoreLoad Barriers会使该内存屏障之前的所有内存访问指令(存储和装载)完成之后,

才执行该屏障之后的内存访问指令。

 

并发编程模型

  由于计算机的存储设备和处理器的运算速度有着几个量级的差距,所以现代计算机系统加入一层或者多层读写速度尽可能接近处理器速度的高速缓存(Cache)来作为内存与处理器之间的缓存。写缓冲区可以保证指令流水线持续运行,它避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过批处理的方式刷新写缓冲区,以及合并写缓冲区对同一内存地址的多次写,减少对内存总线的占用。

  高速缓存虽然解决了处理器与内存速度之间的矛盾,但是引入了新的问题:缓存一致性(Cache Chherence)。

  下面用一个例子来具体说明:

  处理器A 处理器B
代码

a = 1; //A1

x = b; //A2

b = 2; //B1

y = a; //B2

运行结果

初始状态:a = b = 0

处理器允许执行后得到结果: x = y = 0

  假设有处理器A和处理器B按照程序顺序并行执行内存访问,最终可能得到 x = y = 0 的结果。具体原因如下图所示。

  这里处理器A和处理器B可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓冲区里中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就有可能得到 a = b = 0 的结果。

  从内存操作的实际发生顺序来看,直到处理器A执行A3来刷新自己的写缓冲区,写操作A1才算真正执行完成。虽然处理器A执行内存操作的顺序为A1 → A2,但内存操作实际发生顺序却是A2 → A1。此时处理器A的内存操作顺序被重排序了(处理器B的情况一样)。

as-if-serial 语义

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

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

  

1 double pi = 3.14;             // A
2 double r = 1.0;               // B
3 double area = pi * r * r;     // C

 

  上面这段代码所示,A和C之间存在数据依赖关系,B和C之间也存在数据依赖关系。因此操作C不能被重排序到A和B前面(这样程序的执行结果将被改变)。但是A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的顺序。这段程序可能存在两种执行顺序,如下图所示。

   那那些操作之间会存在数据依赖呢?如果两个操作访问同一个变量,且者两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为下列3中类型。

名称 代码示例 说明
写后读

a = 1;

b = a;

写一个变量之后,再读这个变量
写后写

a = 1;

a = 2;

写一个变量之后,再写这个变量
读后写

a = b;

b = 1;

读一个变量之后,再写这个变量

 

   以上3种情况,只要重排序两个操作之间的执行顺序,程序的执行结果就会被改变。所以编译器和处理器重排序时,会遵守数据依赖性,不会改变存在数据依赖性的两个操作的执行顺序。但是这里说的数据依赖性仅仅指对单个处理器中的指令序列和单个线程中执行的操作。不同处理器和不同线程之间的数据依赖性不被编译器和处理器考虑。

 

参考资料:《Java并发编程的艺术》、《深入理解Java虚拟机》