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访问其值。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,743评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,296评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,285评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,485评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,581评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,821评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,960评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,719评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,186评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,516评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,650评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,329评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,936评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,757评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,991评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,370评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,527评论 2 349

推荐阅读更多精彩内容