jmm俩大原则之happens-before和as-if-serial

概述

本文大部分整理自《Java并发编程的艺术》,温故而知新,加深对基础的理解程度。

指令序列的重排序

我们在编写代码的时候,通常自上而下编写,那么希望执行的顺序,理论上也是逐步串行执行,但是为了提高性能,编译器和处理器常常会对指令做重排序。

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

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

1849204142.png

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

happens-before的定义

happens-before的概念最初由Leslie Lamport在其中一篇影响深远的论文(《Time,Clocks and the Ordering of Events in a Distributed System》)中提出。Leslie Lamport在这篇论文中给出了一个分布式算法,该算法可以将偏序关系扩展为某种全序关系。

JSR-133使用happens-before的概念来指定俩个操作之间的执行顺序。由于这俩个操作可以在一个线程之内,也可以是在不同线程之间。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。

《JSR-133:Java Memory Model and Thread Specification》中对happens-before关系的定义如下:

如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序在第二个操作之前。

两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行(并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行),happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second),如果重排序之后的执行结果,与按照happens-before关系来执行的结果一致,那么这种重排序并不非法,JMM允许这种重排序。

happens-before语义

从JDK 5开始,Java使用新的内存模型,使用happens-before的概念来阐述操作之间的内存可见性。那到底什么是happens-before呢?

在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系,这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

happens-before规则如下

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()操作成功返回

JMM的设计

首先,让我们来看JMM的设计意图,从JMM设计者的角度,在设计JMM时,需要考虑俩个关键因素。

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

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

happens-before与JMM的关系

test2 (1).png
test3.png

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

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

由于处理器、编译器和程序员理解的俩个因素互相矛盾,所以JSR-133专家组在设计JMM时的核心目标就是找到一个好的平衡点:

  • 一方面,要为程序员提供足够强的内存可见性保证

  • 另一方面,对编译器和处理器的限制要尽可能地放松。

数据依赖性

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


test4.png

上面情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。而编译器和处理器可能会对操作做重排序,但是编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

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

as-if-serial语义

as-if-serial语义的意思是:不管怎么重排序,单线程程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。所以编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

下面还是以书中的实例(计算圆的面积)进行说明:

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

上面3个操作的数据依赖关系如图所示:


test5.png

A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面(因为C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。

该程序的两种可能执行顺序:

test6.png

as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。

程序顺序规则

根据happens-before的程序顺序规则,上面计算圆的面积的示例代码存在3个happens-before关系。

1.A happens-before B。
2.B happens-before C。
3.A happens-before C。

而这里的第3个happens-before关系,是根据happens-before的传递性推导出来的。

在3个happens-before关系中,2和3是必需的,但1是不必要的。因此,JMM把happens-before要求禁止的重排序分为了下面俩类。

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

2.不会改变程序执行的重排序。

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

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

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

重排序对多线程的影响

重排序是否会改变多线程程序的执行结果?还是借用书中的一个例子:

class ReorderExample {

  int a = 0;

  boolean flag = false;

    public void writer() {

      a = 1; // 1

      flag = true; // 2

  }

  public void reader() {

    if (flag) { // 3

        int i = a * a; // 4

    }

  }

}

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

答案是:不一定能看到。

由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。

当操作1和操作2重排序时,可能会产生什么效果?(虚箭线标识错误的读操作,用实箭线标识正确的读操作。)

test7.png

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

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

test8.png

在程序中,操作3和操作4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲的硬件缓存中。当操作3的条件判断为真时,就把该计算结果写入变量i中。猜测执行实质上对操作3和4做了重排序,在这里重排序破坏了多线程程序的语义!

注意:
在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果。
在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

总结

因此happens-before关系本质上和as-if-serial语义是一回事。

  • as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变
  • as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按照程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。

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

1.volatile读-写建立的happens-before关系图

test9.png
  1. start()规则,假设线程A在执行的过程中,通过执行ThreadB.start()来启动线程B;同时,假设线程A在执行ThreadB.start()之前修改了一些共享变量,线程B在开始执行后会读取这些共享变量
test10.png

3.join规则。假设线程A在执行的过程中,通过执行ThreadB.join()来等待线程B终止;同时,假设线程B在终止之前修改了一些共享变量,线程A从ThreadB.join()返回后读取这些共享变量。

test11.png

2022.07.07补充
写屏障的作用就是禁止了指令的重排序,并且配合C语言中的volatile关键字(C中的volatile关键字只能保证可见性不能保证有序性),通过添加内存屏障+C中的Volatile实现了类似Java中的Volatile关键字语义,即在putObjectVolatile方法中通过内存屏障保证了有序性,再通过volatile保证将对指定地址的操作是马上写入到共享的主存中而不是线程自身的本地工作内存中,这样配合下面的getObjectVolatile方法,就可以确保每次读取到的就是最新的数据

对于getObjectVolatile而言,可以看到它在返回前加了read_barrier,这个读屏障的作用就是强制去读取主存中的数据而不是线程自己的本地工作内存,这样就确保了读取到的一定是最新的数据。
最后就是putOrderedObject,这个方法和putObjectVolatile的区别源码中在于没有加write_barrier,这个方法只保证了更新数据的可见性,但是无法保证有序性,因为没有添加屏障可能会导致最终生成的汇编指令被重排序优化,不过在ConcurrentHashMap中使用到这个方法的地方主要是在put方法更新数据的时候用到了,而关于put是加锁了的,所以在加锁的代码区域,用putOrderedObject比putObjectVolatile好在不需要添加屏障,因为只会有一个线程进行操作,从而允许进行指令优化重排序,性能会更好。

这里贴出Java和C++中volatile的区别:

1.Java中的volatile:在Java内存模型中,线程共享的资源放在主存中,每个线程同时拥有自己的本地内存。而本地内存中存放了被该线程使用到的主内存变量的拷贝。线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。由此可能导致线程间无法读取变量的最新状态。被volatile修饰的变量在修改时会被强制写到主存中,从而保证该变量对其他线程的可见性。

2.C++中的volatile:在单任务的环境中,一个函数体内部,如果在两次读取变量的值之间的语句没有对变量的值进行修改,那么编译器就会设法对可执行代码进行优化。由于访问寄存器的速度要快过RAM(从RAM中读取变量的值到寄存器),以后只要变量的值没有改变,就一直从寄存器中读取变量的值,而不对RAM进行访问。被volatile修饰的变量将一直从RAM访问其值。

你可能感兴趣的:(jmm俩大原则之happens-before和as-if-serial)