Java内存模型(JMM)详解

hresh 848 0

Java内存模型(JMM)详解

紧接前文,在了解了计算机的相关概念后,我们正式进入 Java 并发知识的学习。本文主要讲解 Java 内存模型(Java Memory Model,JMM),它是 Java 虚拟机规范中定义的一种模型,用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的并发效果,JMM 规范了 Java 虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

好了,废话不多说,直接进入主题。

主内存与工作内存

Java 内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。 此处的变量(Variables) 与 Java 编程中所说的变量有所区别,它包括了实例字段、 静态字段和构成数组对象的元素, 但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。为了获得更好的执行效能,Java内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互, 也没有限制即时编译器是否要进行调整代码执行顺序这类优化施。

Java 内存模型规定了所有的变量都存储在主内存(Main Memory) 中。这里的主内存与上文硬件内存结构中的主内存虽然名字一样,但不是一回事,它是虚拟机内存的一部分。每个线程又有自己的工作内存,工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、 赋值等)都必须在工作内存中进行, 而不能直接操作主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成, 线程、 主内存、 工作内存三者的交互关系如下图所示:

Java内存模型(JMM)详解

关于这里提到的主内存、工作内存与 Java 运行时内存介绍的堆、栈、方法区并不是同一个层次的划分,它们之间没有关系。不过根据定义,我们可以勉强将彼此对应起来。比如说所有的变量都存储在主内存,加之变量包括了实例字段、 静态字段和构成数组对象的元素,这不就对应堆内存存储的数据嘛。而工作内存则对应虚拟机栈中的部分区域。

重排序

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

1、编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。在讲解关于即时编译器的其他一些优化手段时提到过,比如说循环外提,循环展开等等。

2、指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。(这点与上一篇文章中提到的指令乱序其实是一回事)

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

从 Java 源代码到最终实际执行的指令序列,会分别经历上面3种重排序,如下所示:

Java内存模型(JMM)详解

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

关于重排序有四点内容要讲,首先是允许重排序需要遵循的三项规则,以及重排序在多线程中的问题。

数据依赖性

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

Java内存模型(JMM)详解

as-if-serial语义

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

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

int a = 3;    //1
int b = 4;    //2
int c = a * b;//3

步骤3和步骤1、2都存在依赖性,所以步骤3必须要在最后。但步骤1和2不存在依赖性,所以编译器和处理器可以对它们进行重排序。

程序顺序规则

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

  • 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
  • 监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。
  • volatile变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
  • 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。

一个happens-before规则对应于一个或多个编译器和处理器重排序规则

重排序对多线程的影响

通过一个案例让我们看看重排序是否会改变多线程程序的执行结果。

@Log
public class ReorderExample {

  int a = 0;
  boolean flag = false;

  public void write() {
    flag = true;    //1
    a = 2;//2
  }

  public void read() {
    int i = 0;
    if (flag) {//3
      i = a * a;    //4
      log.info("true i:" + i);
    }else{
      log.info("false i:"+i);
    }
  }

  public static void main(String[] args) {
    for (int i = 0; i < 10000; i++) {
      ReorderExample obj = new ReorderExample();
      new Thread(() -> {
        obj.write();
      },"A").start();

      new Thread(() -> {
        obj.read();
      },"B").start();
    }
  }
}

说实话,自己实测过程中并没有得到想要的结果,即使换一种程序写法,比如参考本文的代码,仍然没有得到重排序的结果,具体原因不清楚,可能与执行环境有关系吧。我们还是继续往下分析。

线程 A首先执行 writer()方法,随后B线程接着执行 reader()方法。线程B在执行操作3时,并不一定能看到线程A在操作1对 flag 的写入。

由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序。当操作1和操作2重排序时,程序执行时序图如下所示:

Java内存模型(JMM)详解

操作1和操作2做了重排序。程序执行时,线程A首先写共享变量a,然后才写变量 flag,随后线程B读 flag 变量,结果条件判断为假,在
这里多线程程序的语义被重排序破坏了。

内存间交互操作

Java 线程之间的通信由Java内存模型(JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(即工作内存),本地内存中存储了该线程以读/写共享变量的副本。

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

Java内存模型(JMM)详解

根据上图可知,线程间通信必须要经过主内存,从而保证内存可见性。

如下,如果线程A与线程B之间要通信的话,必须要经历下面2个步骤:

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

Java内存模型(JMM)详解

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

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的 load 动作使用
  • load(载入):作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的 write 的操作。
  • write(写入):作用于主内存的变量,它把 store 操作从工作内存中一个变量的值传送到主内存的变量中。

Java 内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

  • 如果要把一个变量从主内存中复制到工作内存,就需要按顺序地执行 read 和 load 操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行 store 和 write 操作。但 Java 内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
  • 不允许 read 和 load、store 和 write 操作之一单独出现
  • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量执行 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。
  • 一个变量在同一时刻只允许一条线程对其进行 lock 操作,但lock操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。lock 和 unlock 必须成对出现
  • 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值
  • 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量。
  • 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)。

原子性、可见性和有序性

原子性

由 Java 内存模型来直接保证的原子性变量操作包括 read、 load、 assign、 use、 store和write这六个,我们大致可以认为, 基本数据类型的访问、 读写都是具备原子性的(long和double 型变量除外)。

如果应用场景需要一个更大范围的原子性保证(经常会遇到),Java 内存模型还提供了 lock 和 unlock 操作来满足这种需求, 尽管虚拟机未把 lock 和 unlock 操作直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorenter 和 monitorexit 来隐式地使用这两个操作。这两个字节码指令反映到 Java 代码中就是同步块——synchronized 关键字,因此在 synchronized 块之间的操作也具备原子性。

综上,想要保证原子性,可以通过 synchronized 关键字。

可见性

可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。Java 内存模型是通过在变量修改后将新值同步回主内
存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是 volatile 变量都是如此。普通变量与 volatile 变量的区别是,volatile 的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。 因此我们可以说 volatile 保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。

除了 volatile 之外, Java 还有两个关键字能实现可见性,它们是 synchronized 和 final。同步块的可见性是由“对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行store、 write操作) ”这条规则获得的。 而 final 关键字的可见性是指:被 final 修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情, 其他线程有可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程中就能看见 final 字段的值。

综上,synchronized、final 和 volatile 可以保证可见性。

有序性

Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内似表现为串行的语义”(Within-Thread As-If-Serial Semantics) , 后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。

Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 关键字本身就包含了禁止指令重排序的语义,而 synchronized 则是由“一个变量在同一个时刻只允许一条线程对其进行 lock 操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。

参考文献

《Java并发编程的艺术》

《深入理解Java虚拟机》

发表评论 取消回复
表情 图片 链接 代码

分享