天天看点

JMM的happens-before的原则

JMM的happens-before的原则

    • 前言
    • 重排序带来的问题
    • JMM设计
    • happens-before原则
      • happens-before关系定义
      • happens-before 规则定义
    • 总结

前言

我是在并发场景下通过单例模式使用volatile关键字,在学习 volatile 的内存语义实现原理时了解到了 JMM 解决指令重排其实是定义了一项 happens-before 规则,所以我今天就喝大家来一窥究竟,到底happens-before原则是什么?这样设计有什么好处?

重排序带来的问题

不熟悉volatile的可以看一下我之前写过的文章:高并发场景下单例模式中的volatile关键字的作用。

这篇文章讲到该情境下出现的问题,并且使用volatile关键字可以解决这一问题。

  • 重排序提高了 CPU 的利用率没错,提高了程序性能没错,但是程序得到的结果可能是错误的。

JMM设计

要学习 happens-before 这里首先介绍下JMM的设计意图。这个问题首先从实际出发:

  1. 我们程序员写代码时,是要求内存模型易于理解,易于编程,所以我们需要依赖一个强内存模型来编码。 也就是说向公理一样,定义好的规则,我们遵守规则写代码就完事了。
  2. 对于编译器和处理器的实现来说,它们希望约束尽量少一些,毕竟你限制它们肯定影响它们的执行效率,不能让他们尽己所能的优化来提供性能。所以他们需要一个弱内存模型。

好了,上面谈到的这两点明显就是冲突的,作为程序员我们希望JMM提供给我们一个强内存模型,而底层的编译器和处理器又需要一个弱内存模型来提高自己的性能。

在计算机领域,这种需要权衡的场景非常多,比如内存和CPU寄存器,就引入了CPU多级缓存来解决性能问题,不过也引入了多核cpu并发场景下的各种问题。 所以这里也一样,我们需要找到一个平衡点,来满足程序员的需求,同时也尽可能满足编译器和处理器限制放松,性能最大化。

因此JMM在设计时,定义了如下策略:

  1. 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
  2. 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种重排序)。

下面结合这幅JMM设计图来理解下:

JMM的happens-before的原则

从上图中可以看到,JMM向我们程序员提供了足够强的内存可见性保证,在不影响程序执行结果的情况下,有些可见性保证并一定存在,比如下面的程序,A happens-before B 并不保证,因为其不影响程序执行结果;

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

这就引出了另一个方面,JMM为了满足编译器和处理器的约束尽可能少,它遵循的规则是:只要不改变程序的执行结果,编译器和处理器想怎么优化就怎么优化。例如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再如,如果编译器经过细致的分析后,认定一个volatile变量只会被单个线程访问,那么编译器可以把这个volatile变量当作一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。

happens-before原则

happens-before关系定义

  • 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果就会对第二个操作可见
  • 两个操作之间如果存在 happens-before 关系,并不意味着 Java 平台的具体实现就必须按照 happens-before 关系指定的顺序来执行.如果重排序之后的执行结果,与按照 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。

JMM为程序员提供的视角就是按顺序执行的,且满足一个操作 happens-before 于另一个操作,那么第一个操作的执行结果将对第二个执行结果可见,而且第一个操作的执行顺序排在第二个顺序之前。注意,这是 JMM向程序员做出的保证。

但其实,JMM在对编译器和处理器进行约束时,如前面所说,遵循的规则是:如果重排序之后的执行结果,与按照 happens-before 关系来执行的结果一致,那么JMM也允许这样的重排序。也就是说两个操作之间存在 happens-before 规则,但Java平台并不一定按照规则定义的顺序来执行。 这么做的原因是因为,我们程序员并不关心两个操作是否被重排序,只要保证程序执行时语义不能改变就好了。

happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

理解了 happens-before 的含义后,我们一起来看下具体的 happens-before 规则定义。

happens-before 规则定义

  1. 程序顺序规则
一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。这个还是非常好理解的,比如上面那三行代码,第一行的 "double pi = 3.14; " happens-before 于 “double r = 1.0;”,这就是规则1的内容,比较符合单线程里面的逻辑思维,很好理解。
double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C
           
  1. 监视器锁规则

    对一个锁的解锁,happens-before于随后对这个锁的加锁。

    这个规则中说的锁其实就是Java里的 synchronized。例如下面的代码,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。

synchronized (this) { //此处自动加锁
  // x是共享变量,初始值=10
  if (this.x < 12) {
    this.x = 12; 
  }  
} //此处自动解锁
           

所以结合锁规则,可以理解为:假设 x 的初始值是 10,线程 A 执行完代码块后 x 的值会变成 12(执行完自动释放锁),线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x==12。这个也是符合我们直觉的,非常好理解。

  1. volatile变量规则

对一个volatile域的写,happens-before于任意后续对这个volatile域的读

这个就有点费解了,对一个 volatile 变量的写操作相对于后续对这个 volatile 变量的读操作可见,这怎么看都是禁用缓存的意思啊,貌似和 1.5 版本以前的语义没有变化啊(前面讲的1.5版本前允许volatile变量和普通变量之间重排序)?如果单看这个规则,的确是这样,但是如果我们关联一下规则 4,你就能感受到变化了

  1. 传递性

    如果A happens-before B,且B happens-before C,那么A happens-before C。

    我们将规则 4 的传递性应用到我们下面的例子中,会发生什么呢?

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }
  public void reader() {
    if (v == true) {
      // 这里x会是多少呢?
    }
  }
}
           

看下面这幅图:

JMM的happens-before的原则

从图中,我们可以看到:

“x=42” Happens-Before 写变量 “v=true” ,这是规则 1 的内容;

写变量“v=true” Happens-Before 读变量 “v=true”,这是规则 3 的内容 。

再根据这个传递性规则,我们得到结果:“x=42” Happens-Before 读变量“v=true”。这意味着什么呢?

如果线程 B 读到了“v=true”,那么线程 A 设置的“x=42”对线程 B 是可见的。也就是说,线程 B 能看到 “x == 42” ,有没有一种恍然大悟的感觉?这就是 1.5 版本对 volatile 语义的增强,这个增强意义重大,1.5 版本的并发工具包(java.util.concurrent)就是靠 volatile 语义来搞定可见性的。

  1. start()规则

    这条是关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。

  2. join()规则

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

通过上面6中 happens-before 规则的组合就能为我们程序员提供一致的内存可见性。 常用的就是规则1和其他规则结合,为我们编写并发程序提供可靠的内存可见性模型。

总结

  • 在 Java 语言里面,Happens-Before 的语义本质上是一种可见性,A Happens-Before B 意味着 A 事件对 B 事件来说是可见的,无论 A 事件和 B 事件是否发生在同一个线程里。例如 A 事件发生在线程 1 上,B 事件发生在线程 2 上,Happens-Before 规则保证线程 2 上也能看到 A 事件的发生。
  • JMM的设计分为两部分,一部分是面向我们程序员提供的,也就是happens-before规则,它通俗易懂的向我们程序员阐述了一个强内存模型,我们只要理解 happens-before规则,就可以编写并发安全的程序了。 另一部分是针对JVM实现的,为了尽可能少的对编译器和处理器做约束,从而提高性能,JMM在不影响程序执行结果的前提下对其不做要求,即允许优化重排序。 我们只需要关注前者就好了,也就是理解happens-before规则。