Java内存模型详解

6次阅读

共计 7548 个字符,预计需要花费 19 分钟才能阅读完成。

前言

Java 内存模型(Java Memory Model, 简称 JMM),即 Java 虚拟机定义的一种用来屏蔽各种硬件和操作系统的内存访问差异,以实现让 java 程序在各种平台下都能够达到一致的内存访问效果的内存模型。本篇文章大致涉及到五个要点:Java 内存模型的基础,主要介绍 JMM 抽象结构;Java 内存模型中内存屏障;Java 内存模型中的重排序;happens-before 原则;顺序一致性内存模型。还有与 JMM 相关的三个同步原语(synchronized,volatile,final)将另分三篇文章介绍。

1.Java 内存模型的抽象结构

在 java 中,共享变量是指所有存储在堆内存中的实例字段,静态字段和数组对象元素,因为堆内存是所有线程共享的数据区。而局部变量,方法定义参数,异常处理参数不会在线程之间共享,它们不存在内存可见性问题,也不会受到 Java 内存模型的影响。

Java 内存模型决定了一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,Java 内存模型定义了线程与主内存之间的抽象关系:线程之间的共享变量存储主内存中,每个线程都有一个私有的本地内存,也叫工作内存,本地内存存储了该线程需要读 / 写的共享变量的副本。本地内存是 JMM 的一个抽象的概念,其实并不真实存在。Java 内存模型的抽象示意图如下:

从上图来看,如果线程 A 和线程 B 之间要通信的话,必须要经历下面的两个过程:

1. 线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。
2. 线程 B 到主内存中去读取线程 A 之前已更新过的共享变量。

下面通过示意图说明以上两个过程:

如上图:假设初始时,X 的值为 0,首先线程 A 要先从主内存中读取共享变量 x 的值,并将其副本存储在自己的本地内存。接着线程 A 要把共享变量 x 的值更新为 1,也就是先把本地内存中的 x 的副本的值更新为 1,然后再把本地内存中刚更新过的共享变量刷新到主内存,此时主内存中共享变量 x 的值为 1。然后线程 A 向线程 B 发送通知:哥们儿,我已更新了共享变量的值。

随后,线程 B 接收到线程 A 发送的通知,也从主内存中读取共享变量 x 的值,并将其副本存储在自己的本地内存,接着线程 B 也要修改共享变量的值,先将本地内存 B 中的副本 x 修改为 2,再将本地内存中的 x 的值刷新到主内存,此时主内存中共享变量 x 的值就被更新为了 2。

从整体上来看,上述的两个过程实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 java 程序员提供内存可见性保证。

2.Java 内存模型的内存屏障

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

  • LoadLoad(Load1,LoadLoad,Load2):确保 load1 数据的装载先于 load2 及所有后序装载指令的装载。
  • LoadStore(Load1,LoadStore,Store2):确保 Load1 数据的装载先于 Store2 及所有后序存储指令刷新内存。
  • StoreStore(Store1,StoreStore,Store2):确保 Store1 数据刷新内存先于 Store2 及所有后序存储指令刷新内存。
  • StoreLoad(Store1,StoreLoad,Load2):确保 Store1 数据刷新内存先于 Load2 及所有后序装载指令的装载。该屏蔽指令会使该屏蔽之前的所有内存访问指令执行完成后才执行屏蔽之后的内存访问指令。并且这个指令是一个全能的指令,同时具备以上三个内存屏蔽指令的功能。

3.Java 内存模型中的重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重排序的一种手段。重排序分为 3 种类型:

  1. 编译器优化重排序。编译器在不改变单线程语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓冲和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从 java 源代码到最终实际执行的指令序列,会经历下面 3 种重排序:

上述 1 属于编译器重排序,编译器将 java 源码编译成字节码时进行一次重排序,2 和 3 属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序。对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成字节码指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。

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

3.1 数据依赖性

如果两个操作访问同一个共享变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖性分为以下 3 种类型:

名称 代码示例 说明
写后读 a= 1 ; b = a 写一个变量之后,再读这个位置的变量值
写后写 a = 1 ; a = 2 写一个变量之后,再继续写这个内存位置的变量
读后写 a = b ; b = 1 读一个变量之后,再写刚读的这个变量

上面的 3 种情况,只要重排序两个操作的执行顺序,程序的结果就可能发生改变。

上面介绍过,编译器和处理器可能会对操作进行重排序。但是编译器和处理器进行重排序时会遵循数据依赖性规则,只要两个操作之间具有数据依赖性,那么编译器和处理器就不会对这两个操作进行重排序,编译器和处理器重排序的原则上是不改变程序的执行结果,从而提高程序执行性能。

这里所说的数据依赖性规则仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同的处理器之间和不同的线程之间的数据依赖性是不被编译器和处理器考虑的。

3.2 as-if-serial 语义

as-if-serial 语义是指:不管怎么重排序,单线程的执行结果是不能被改变的。编译器和处理器都必须遵循 as-if-serial 语义。

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

as-if-serial 语义把单线程程序给保护了起来,遵循 as-if-serial 语义的编译器和处理器共同为编写单线程程序的程序员创造了一个幻觉:单线程程序是按程序代码的先后顺序来执行的。as-if-serial 语义使程序员在单线程下无需担心重排序会影响程序执行结果,也无需担心内存可见性问题。

3.3 重排序对多线程的影响

重排序会可能影响多线程程序的执行结果,请看下面的示例代码:

public class ReorderExample{
    int a = 0; 
    boolean flag = false; 
    
    @Test
    public void writer(){
        a = 1;              //1
        flag = true;     //2
    }
    
    @Test
    public void reader(){if(flag){           //3
            int i = a;     //4
            System.out.print(i);
        }
    }
}

flag 变量是个标记,用来标识变量 a 是否被写入。这里我们假设有两个线程 A 和 B,线程 A 首先执行 writer 方法,随后线程 B 执行 reader 方法。问题是线程 B 在执行操作 4 时,能否看到线程 A 在操作 1 对共享变量 a 的写入呢?

答案是否定的,并不一定能看到。

由于操作 1 和操作 2 不存在数据依赖关系,编译器和处理器可以对这两个操作重排序;同理,操作 3 和操作 4 也不存在数据依赖关系,编译器和处理器也可以对这两个操作重排序。下面我们先来看下,当操作 1 和操作 2 重排序时,会产生什么效果。程序执行时序图如下:

如上图,操作 1 和操作 2 做了重排序。程序执行时,线程 A 首先将标记变量 flag 写为 true,随后线程 B 读取这个变量,由于条件为真,线程 B 将读取共享变量 a,而此时,共享变量 a 还没有被线程 A 写入,所以多线程程序的语义就被重排序破坏了。

下面再看下,当操作 3 和操作 4 重排序时会产生什么效果。下面是操作 3 和操作 4 重排序后程序的执行时序图:

在程序中,操作 3 和操作 4 存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度,为此,编译器和处理器会采用猜测执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程 B 的处理器可以提前读取共享变量 a,然后会把共享变量 a 的值保存到一个名为重排序缓冲(Reorder Buffer,ROD)的硬件缓存中。当操作 3 的条件为真时,就把保存到 ROB 中的共享变量 a 的值写入到变量 i 中。

从上图中我们也可以看出,猜测执行实质上对操作 3 和操作 4 做了重排序。重排序破坏了多线程程序的语义。

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

4. happens-before 原则

从 JDK1.5 开始,Java 使用新的 JSR-133 内存模型,该模型使用 happens-before 原则来阐述操作之间的内存可见性。在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这里提到的两个操作既可以是在单线程内,也可以在多线程之间。

happens-before 规则如下:

  • 程序顺序规则(Program Order Rule): 一个线程中的每个操作先行发生于该线程中的后序任意操作。
  • 监视器锁规则(Monitor Lock Rule): 对一个锁的解锁先行发生于随后对这个锁的加锁。
  • volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的写,先行发生于任意后序对这个 volatile 变量的读。
  • 传递性(Transitivity):如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
  • 线程启动规则(Thread Start Rule):Thread 对象的 start() 方法先行发生于此线程的每一个动作。
  • 线程终止规格(Thread Termination Rule):线程中所有的操作都优先发生于此线程的终止操作。‘’
  • 线程中断规则(Thread Interruption Rule):对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测到是否有中断发生。
  • 对象终结规则(Finalizer Rule):一个对象初始化完成先行发生于它的 finalize() 方法的开始。

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

5. 顺序一致性内存模型

顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。这个内存模型是一个理想化了的理论参考模型。它为程序员提供了一个极强的内存可见性保证。顺序一致性内存模型有两大特性:

  1. 一个线程中的所有操作必须按照程序代码的顺序来执行。
  2. 不管程序是否同步,所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

顺序一致性内存模型为程序员提供的视图如下图所示:

在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每个线程必须按照程序的顺序来执行内存读 / 写操作。从上面的示意图可以看出,在任意时间最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能够把所有 线程的所有内存读 / 写操作串行化。

为了便于大家更好的理解,下面通过两个示意图来对顺序一致性模型的特性做进一步的说明。

假设有两个线程 A 和 B 并发执行。其中 A 线程有三个操作,它们在程序中的顺序是:A1 -> A2 -> A3。B 线程也有三个操作,它们在程序中的顺序是:B1 -> B2 -> B3。

假设这两个程序使用监视器锁来正确同步:A 线程的三个操作执行完后释放监视器锁,随后 B 线程获取同一个监视器锁。那么程序在顺序一致性模型中的执行效果如下图所示:

现在我们再假设这两个线程没有做同步,那么程序在顺序一致性模型中的执行效果如下图所示:

未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,因为不做同步处理,线程 B 并不会等到线程 A 的所有操作都执行完后才执行,而是线程 B 会和线程 A 抢占 CPU 资源,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程 A 和线程 B 看到的执行顺序都是:B1 -> A1 ->A2 -> B2 -> A3 -> B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。

但是,在 JMM 中就没有这个保证。未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本没有被当前线程执行。只有当前线程把本地内存写过的数据刷新到主内存之后,这个写操作才对其他线程可见。在这种情况下,当前线程和其他线程看到的操作执行顺序可能不一致。

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

下面,对前面的示例程序 ReorderExample 用锁来同步,看看正确同步的程序如何具有顺序一致性。

public class ReorderExample{
    int a = 0; 
    boolean flag = false; 
    
    @Test
    public synchronized void writer(){  // 获取锁
        a = 1;              
        flag = true;    
    }   // 释放锁
    
    @Test
    public synchronized void reader(){  // 获取锁
        if(flag){          
            int i = a;     
            System.out.print(i);
        }
    }  // 释放锁
}

在上面的示例代码中,假设线程 A 执行 writer 方法后,线程 B 执行 reader 方法,这是一个正确同步的多线程程序。根据 JMM 规范,该程序的执行结果将与该程序在顺序一致性内存模型中的执行结果相同。下面是该程序在 JMM 内存模型和顺序一致性内存模型中的执行时序对比图:

顺序一致性内存模型中,所有的操作完全按程序顺序串行执行。而在 JMM 中,临界区内的代码指令执行序列可以被重排序。但 JMM 不允许临界区内的代码逃逸到临界区之外,那样会破坏监视器的语义。JMM 会在进入临界区和退出临界区这两个关键时间点做一些特殊的处理,使得线程在这两个时间点具有与顺序一致性内存模型相同的内存视图。虽然线程 A 在临界区内做了重排序,但是由于监视器锁互斥执行的特性,这里的线程 B 无法感知到线程 A 在临界区内做了重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。

从这里我们看到,JMM 具有实现上的基本原则为:在不改变(正确同步的)程序执行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门。

5.2 未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM 只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么就是默认值(0,null,false),JMM 保证线程读操作读取到的值不会无中生有。为了实现最小安全性,JMM 在堆上为对象分配内存时,首先会对内存空间进行清零,然后才会在上面分配对象。因此,在已清零的内存空间分配对象时,域(字段)的默认初始化已经完成了。

JMM 不保证未同步或未正确同步的程序的执行结果与该程序在顺序一致性内存模型中的执行结果一致。因为如果想要保住执行结果一致,JMM 需要禁止大量的编译器和处理器的优化,这对程序的性能会产生很大的影响。而且,未同步程序在这两个模型中的执行结果一致也没有什么实质的意义。

未同步程序在 JMM 中执行时,整体上是无序的,其执行结果也是无法预知的。未同步程序在两个模型中的执行特性有如下三个方面的差异:

1. 顺序一致性内存模型保证单线程内的操作是按程序的顺序执行,而 JMM 不保证单线程内的操作是按程序顺序执行的。
2. 顺序一致性内存模型保证所有线程只能看到一致的操作执行顺序,而 JMM 是不保证这一点的。
3.JMM 不保证对 64 位的 long 和 double 型变量的写操作具有原子性,而顺序一致性保证对所有的内存读 / 写操作都具有原子性。

第三个差异与处理器总线的工作机制密切相关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列的步骤来完成的,这一系列的步骤称之为总线事务。总线事务包括读事务和写事务。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读 / 写内存中的一个或多个物理上连续的字内存空间。这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其他的处理器和 IO 设备执行内存的读 / 写操作。下面,用示意图说明总线的工作机制:

由图可知,假设处理器 A,B 和 C 同时向总线发起总线事务,这时总线仲裁会对竞争做出裁决,这里假设总线在仲裁后判定处理器 A 在竞争中获胜。此时处理器 A 继续它的总线事务,而其他的处理器则要等待处理器 A 的总线事务完成后才能再次执行内存访问。假设处理器 A 执行总线事务期间,处理器 B 向总线发起了总线事务请求,此时处理器 B 的总线请求是会被禁止的。

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

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

参考书籍:

1.Java 并发编程的艺术:本文主要整理了此书,这本书对 Java 内存模型的讲解已经很透彻,所以将书中内容做了整理。

2. 深入理解 Java 虚拟机:参考了此书 Java 内存模型的部分,此书的 8 个内存交互指令在 JSR133,也就是从 JDK1.5 起就不再使用了,所以本文不再介绍。

正文完
 0

Java内存模型详解

6次阅读

共计 7548 个字符,预计需要花费 19 分钟才能阅读完成。

前言

Java 内存模型(Java Memory Model, 简称 JMM),即 Java 虚拟机定义的一种用来屏蔽各种硬件和操作系统的内存访问差异,以实现让 java 程序在各种平台下都能够达到一致的内存访问效果的内存模型。本篇文章大致设涉及到五个要点:Java 内存模型的基础,主要介绍 JMM 抽象结构;Java 内存模型中内存屏障;Java 内存模型中的重排序;happens-before 原则;顺序一致性内存模型。还有与 JMM 相关的三个同步原语(synchronized,volatile,final)将另分三篇文章介绍。

1.Java 内存模型的抽象结构

在 java 中,共享变量是指所有存储在堆内存中的实例字段,静态字段和数组对象元素,因为堆内存是所有线程共享的数据区。而局部变量,方法定义参数,异常处理参数不会在线程之间共享,它们不存在内存可见性问题,也不会受到 Java 内存模型的影响。

Java 内存模型决定了一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,Java 内存模型定义了线程与主内存之间的抽象关系:线程之间的共享变量存储主内存中,每个线程都有一个私有的本地内存,也叫工作内存,本地内存存储了该线程需要读 / 写的共享变量的副本。本地内存是 JMM 的一个抽象的概念,其实并不真实存在。Java 内存模型的抽象示意图如下:

从上图来看,如果线程 A 和线程 B 之间要通信的话,必须要经历下面的两个过程:

1. 线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。
2. 线程 B 到主内存中去读取线程 A 之前已更新过的共享变量。

下面通过示意图说明以上两个过程:

如上图:假设初始时,X 的值为 0,首先线程 A 要先从主内存中读取共享变量 x 的值,并将其副本存储在自己的本地内存。接着线程 A 要把共享变量 x 的值更新为 1,也就是先把本地内存中的 x 的副本的值更新为 1,然后再把本地内存中刚更新过的共享变量刷新到主内存,此时主内存中共享变量 x 的值为 1。然后线程 A 向线程 B 发送通知:哥们儿,我已更新了共享变量的值。

随后,线程 B 接收到线程 A 发送的通知,也从主内存中读取共享变量 x 的值,并将其副本存储在自己的本地内存,接着线程 B 也要修改共享变量的值,先将本地内存 B 中的副本 x 修改为 2,再将本地内存中的 x 的值刷新到主内存,此时主内存中共享变量 x 的值就被更新为了 2。

从整体上来看,上述的两个过程实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与内个线程的本地内存之间的交互,来为 java 程序员提供内存可见性保证。

2.Java 内存模型的内存屏障

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

  • LoadLoad(Load1,LoadLoad,Load2):确保 load1 数据的装载先于 load2 及所有后序装载指令的装载。
  • LoadStore(Load1,LoadStore,Store2):确保 Load1 数据的装载先于 Store2 及所有后序存储指令刷新内存。
  • StoreStore(Store1,StoreStore,Store2):确保 Store1 数据刷新内存先于 Store2 及所有后序存储指令刷新内存。
  • StoreLoad(Store1,StoreLoad,Load2):确保 Store1 数据刷新内存先于 Load2 及所有后序装载指令的装载。该屏蔽指令会使该屏蔽之前的所有内存访问指令执行完成后才执行屏蔽之后的内存访问指令。并且这个指令是一个全能的指令,同时具备以上三个内存屏蔽指令的功能。

3.Java 内存模型中的重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重排序的一种手段。重排序分为 3 中类型:

  1. 编译器优化重排序。编译器在不改变单线程语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓冲和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

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

上述 1 属于编译器重排序,编译器将 java 源码编译成字节码时进行一次重排序,2 和 3 属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序。对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成字节码指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。

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

3.1 数据依赖性

如果两个操作访问同一个共享变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖性分为以下 3 中类型:

名称 代码示例 说明
写后读 a= 1 ; b = a 写一个变量之后,再读这个位置的变量值
写后写 a = 1 ; a = 2 写一个变量之后,再继续写这个内存位置的变量
读后写 a = b ; b = 1 读一个变量之后,再写刚读的这个变量

上面的 3 中情况,只要重排序两个操作的执行顺序,程序的结果就可能发生改变。

上面介绍过,编译器和处理器可能会对操作进行重排序。但是编译器和处理器进行重排序时会遵循数据依赖性规则,只要两个操作之间具有数据依赖性,那么编译器和处理器就不会对这两个操作进行重排序,编译器和处理器重排序的原则上是不改变程序的执行结果,从而提高程序执行性能。

这里所说的数据依赖性规则仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同的处理器之间和不同的线程之间的数据依赖性是不被编译器和处理器考虑的。

3.2 as-if-serial 语义

as-if-serial 语义是指:不管怎么重排序,单线程的执行结果是不能被改变的。编译器和处理器都必须遵循 as-if-serial 语义。

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

as-if-serial 语义把单线程程序给保护了起来,遵循 as-if-serial 语义的编译器和处理器共同为编写单线程程序的程序员创造了一个幻觉:单线程程序是按程序代码的先后顺序来执行的。as-if-serial 语义使程序员在单线程下无需担心重排序会影响程序执行结果,也无需担心内存可见性问题。

3.3 重排序对多线程的影响

重排序会可能影响多线程程序的执行结果,请看下面的示例代码:

public class ReorderExample{
    int a = 0; 
    boolean flag = false; 
    
    @Test
    public void writer(){
        a = 1;              //1
        flag = true;     //2
    }
    
    @Test
    public void reader(){if(flag){           //3
            int i = a;     //4
            System.out.print(i);
        }
    }
}

flag 变量是个标记,用来标识变量 a 是否被写入。这里我们假设有两个线程 A 和 B,线程 A 首先执行 writer 方法,随后线程 B 执行 reader 方法。问题是线程 B 在执行操作 4 时,能否看到线程 A 在操作 1 对共享变量 a 的写入呢?

答案是否定的,并不一定能看到。

由于操作 1 和操作 2 不存在数据依赖关系,编译器和处理器可以对这两个操作重排序;同理,操作 3 和操作 4 也不存在数据依赖关系,编译器和处理器也可以对这两个操作重排序。下面我们先来看下,当操作 1 和操作 2 重排序时,会产生什么效果。程序执行时序图如下:

如上图,操作 1 和操作 2 做了重排序。程序执行时,线程 A 首先将标记变量 flag 写为 true,随后线程 B 读取这个变量,由于条件为真,线程 B 将读取共享变量 a,而此时,共享变量 a 还没有被线程 A 写入,所以多线程程序的语义就被重排序破坏了。

下面再看下,当操作 3 和操作 4 重排序时会产生什么效果。下面是操作 3 和操作 4 重排序后程序的执行时序图:

在程序中,操作 3 和操作 4 存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度,为此,编译器和处理器会采用猜测执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程 B 的处理器可以提前读取共享变量 a,然后会把共享变量 a 的值保存到一个名为重排序缓冲(Reorder Buffer,ROD)的硬件缓存中。当操作 3 的条件为真时,就把保存到 ROB 中的共享变量 a 的值写入到变量 i 中。

从上图中我们也可以看出,猜测执行实质上对操作 3 和操作 4 做了重排序。重排序破坏了多线程程序的语义。

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

4. happens-before 原则

从 JDK1.5 开始,Java 使用新的 JSR-133 内存模型,该模型使用 happens-before 原则来阐述操作之间的内存可见性。在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这里提到的两个操作既可以是在单线程内,也可以在多线程之间。

happens-before 规则如下:

  • 程序顺序规则(Program Order Rule): 一个线程中的每个操作先行发生于该线程中的后序任意操作。
  • 监视器锁规则(Monitor Lock Rule): 对一个锁的解锁先行发生于随后对这个锁的加锁。
  • volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的写,先行发生于任意后序对这个 volatile 变量的读。
  • 传递性(Transitivity):如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
  • 线程启动规则(Thread Start Rule):Thread 对象的 start() 方法先行发生于此线程的每一个动作。
  • 线程终止规格(Thread Termination Rule):线程中所有的操作都线程发生于此线程的终止操作。‘’
  • 线程中断规则(Thread Interruption Rule):对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测到是否有中断发生。
  • 对象终结规则(Finalizer Rule):一个对象初始化完成先行发生于它的 finalize() 方法的开始。

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

5. 顺序一致性内存模型

顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。这个内存模型是一个理想化了的理论参考模型。它为程序员提供了一个极强的内存可见性保证。顺序一致性内存模型有两大特性:

  1. 一个线程中的所有操作必须按照程序代码的顺序来执行。
  2. 不管程序是否同步,所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

顺序一致性内存模型为程序员提供的视图如下图所示:

在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每个线程必须按照程序的顺序来执行内存读 / 写操作。从上面的示意图可以看出,在任意时间最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能够把所有 线程的所有内存读 / 写操作串行化。

为了便于大家更好的理解,下面通过两个示意图来对顺序一致性模型的特性做进一步的说明。

假设有两个线程 A 和 B 并发执行。其中 A 线程有三个操作,它们在程序中的顺序是:A1 -> A2 -> A3。B 线程也有三个操作,它们在程序中的顺序是:B1 -> B2 -> B3。

假设这两个程序使用监视器锁来正确同步:A 线程的三个操作执行完后释放监视器锁,随后 B 线程获取同一个监视器锁。那么程序在顺序一致性模型中的执行效果如下图所示:

现在我们再假设这两个线程没有做同步,那么程序在顺序一致性模型中的执行效果如下图所示:

未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,因为不做同步处理,线程 B 并不会等到线程 A 的所有操作都执行完后才之心,而是线程 B 会和线程 A 抢占 CPU 资源,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程 A 和线程 B 看到的执行顺序都是:B1 -> A1 ->A2 -> B2 -> A3 -> B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。

但是,在 JMM 中就没有这个保证。未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本没有被当前线程执行。只有当前线程把本地内存写过的数据刷新到主内存之后,这个写操作才对其他线程可见。在这种情况下,当前线程和其他线程看到的操作执行顺序可能不一致。

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

下面,对前面的示例程序 ReorderExample 用锁来同步,看看正确同步的程序如何具有顺序一致性。

public class ReorderExample{
    int a = 0; 
    boolean flag = false; 
    
    @Test
    public synchronized void writer(){  // 获取锁
        a = 1;              
        flag = true;    
    }   // 释放锁
    
    @Test
    public synchronized void reader(){  // 获取锁
        if(flag){          
            int i = a;     
            System.out.print(i);
        }
    }  // 释放锁
}

在上面的示例代码中,假设线程 A 执行 writer 方法后,线程 B 执行 reader 方法,这是一个正确同步的多线程程序。根据 JMM 规范,该程序的执行结果将与该程序在顺序一致性内存模型中的执行结果相同。下面是该程序在 JMM 内存模型和顺序一致性内存模型中的执行时序对比图:

顺序一致性内存模型中,所有的操作完全按程序顺序串行执行。而在 JMM 中,临界区内的代码指令执行序列可以被重排序。但 JMM 不允许临界区内的代码逃逸到临界区之外,那样会破坏监视器的语义。JMM 会在进入临界区和退出临界区这两个关键时间点做一些特殊的处理,使得线程在这两个时间点具有与顺序一致性内存模型相同的内存视图。虽然线程 A 在临界区内做了重排序,但是由于监视器锁互斥执行的特性,这里的线程 B 无法感知到线程 A 在临界区内做了重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。

从这里我们看到,JMM 具有实现上的基本原则为:在不改变(正确同步的)程序执行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门。

5.2 未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM 只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么就是默认值(0,null,false),JMM 保证线程读操作读取到的值不会无中生有。为了实现最小安全性,JMM 在堆上为对象分配内存时,首先会对内存空间进行清零,然后才会在上面分配对象。因此,在已清零的内存空间分配对象时,域(字段)的默认初始化已经完成了。

JMM 不保证未同步或未正确同步的程序的执行结果与该程序在顺序一致性内存模型中的执行结果一致。因为如果想要保住执行结果一致,JMM 需要禁止大量的编译器和处理器的优化,这对程序的性能会产生很大的影响。而且,未同步程序在这两个模型中的执行结果一致也没有什么实质的意义。

未同步程序在 JMM 中执行时,整体上是无序的,其执行结果也是无法预知的。未同步程序在两个模型中的执行特性有如下三个方面的差异:

1. 顺序一致性内存模型保证单线程内的操作是按程序的顺序执行,而 JMM 不保证单线程内的操作是按程序顺序执行的。
2. 顺序一致性内存模型保证所有线程只能看到一致的操作执行顺序,而 JMM 是不保证这一点的。
3.JMM 不保证对 64 位的 long 和 double 型变量的写操作具有原子性,而顺序一致性保证对所有的内存读 / 写操作都具有原子性。

第三个差异与处理器总线的工作机制密切相关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列的步骤来完成的,这一系列的步骤称之为总线事务。总线事务包括读事务和写事务。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读 / 写内存中的一个或多个物理上连续的字内存空间。这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其他的处理器和 IO 设备执行内存的读 / 写操作。下面,用示意图说明总线的工作机制:

由图可知,假设处理器 A,B 和 C 同时向总线发起总线事务,这时总线仲裁会对竞争做出裁决,这里假设总线在仲裁后判定处理器 A 在竞争中获胜。此时处理器 A 继续它的总线事务,而其他的处理器则要等待处理器 A 的总线事务完成后才能再次执行内存访问。假设处理器 A 执行总线事务期间,处理器 B 向总线发起了总线事务请求,此时处理器 B 的总线请求是会被禁止的。

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

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

参考书籍:

1.Java 并发编程的艺术:本文主要整理了此书,这本书对 Java 内存模型的讲解已经很透彻,所以将书中内容做了整理。

2. 深入理解 Java 虚拟机:参考了此书 Java 内存模型的部分,此书的 8 个内存交互指令在 JSR133,也就是从 JDK1.5 起就不再使用了,所以本文不再介绍。

正文完
 0