何为内存模型(JMM)?

前言

任何一门语言都有其语言规范,从逻辑上我们可划分为语法规范和语义规范,语法规范则是描述了如何通过相关语法编写可执行的程序,而语义规范则是指通过语法编写的程序所构造出的具体含义。语言只要具备存储(比如堆、栈),我们此时必须定义存储行为规则,这种行为规则就是内存模型。Java初始版本内存模型允许行为安全泄漏,此外,它阻止了几乎所有的单线程编译器优化操作,因此,从Java 1.5开始,引入了新的内存模型来修复这些缺陷,接下来我们来详细了解看看其内存模型到底是啥玩意,若有错误之处,还望批评指正。

内存模型(JMM)

我们知道大多数情况下编写的程序按顺序而执行,此时也是按照对应顺序存储在内存中,很显然,读取应遵循该顺序进行的最新写入,这是最原始的单核模型,随着时代的进步、技术也才随之发展,此时出现了多处理器体系结构,线程共享内存已凸显出对于并发编程的优势,但共享内存必然要使用同步机制使得内存中的数据一致,而同步机制却对系统性能产生很大影响,为了避免这种情况,通过对应策略使得存储数据一致性,当然这些策略是放宽的,如此将导致意外情况的出现,因为开发者很难推理执行程序最终结果,所以在多线程情况下我们尤其关心内存模型,但是问题随之变得复杂了起来,内存模型定义了在实现该内存的共享内存体系结构上运行的多线程程序的所有可能结果,从本质上讲,可认为它是对可能值的规范,它允许返回对内存的读取访问,从而指定平台的多线程语义。 Java内存模型(JMM)设计有两个目标:应该允许尽可能多的编译器优化、一般情况下开发者不必了解其所有复杂性可以更容易进行多线程编程,但是这项任务巨大,从某种意义上来讲,这种模型增加了太多的不确定性,为了实现第二个目标,为了开发者能够更好的可编程,于是JMM提供一个较弱的保证,称之为无数据竞争保证(Data Race Free),简称为DRF,它保证:如果程序不包含数据竞争,则允许行为可以通过交错语义来描述,换句话说,如果程序的所有顺序一致的执行没有数据争用,则所有执行似乎都是顺序一致的,所以我们可认为JMM是无数据争用的内存模型,DRF通过顺序一致性来保证。这里我们只是抽象概括了内存模型,接下来将通过大量的篇幅来进一步分析顺序一致性以及通过顺序一致性怎么就保证了内存模型。

顺序一致性(Sequential Consistency简称SC)

我们知道在多线程情况下由于竞争条件的存在会发生数据竞争,那么如何解决数据竞争呢?这就涉及到数据竞争自由度(Data Race Freeness)的概念:程序结果计算的正确性依赖于运行时的相关时序或多线程交替时就会产生竞争条件从而发生数据竞争,那么反过来讲,数据竞争自由度则是正确同步的程序具有顺序一致性,那么到底何为顺序一致性(sequential consistency)呢?执行结果都与所有处理器的操作相同按顺序执行,每个操作处理器按程序指定的顺序依次出现,单个内存引用(加载或存储)完成的顺序称为执行顺序,语句在原始代码中的排序方式称为程序顺序(Program Order以下简称为PO),执行顺序决定总的顺序,执行顺序与程序顺序(每个线程中的指令顺序所决定的顺序)一致,并且每次读取存储位置时都会看到写入的最后一个值,这意味着,执行顺序好像具有按总顺序(Total Order)执行的结果形态,但执行的实际顺序不一定按总顺序进行,因为编译器的优化和指令的并行执行是允许的。讲到这里,感觉很抽象,接下来我们通过简单的例子来介绍执行顺序、总顺序、程序顺序概念。

public class Main {
    public static void main(String[] args) {
        int x = 1;
        int y, z, m;
        if (x == 1) {
            y = 2;
        } else {
            z = 1;
        }
        z = y;
    }
}

上述我们初始化分配x==1,而y、z、m都等于0,接下来进入判断语句,总顺序则是由read(x):2、write(y,2)、read(y):2组成,程序顺序就是总顺序,而执行顺序是实际在内存中的操作顺序,由于缓存一致性协议的存在改善了系统处理性能,同时为了解决缓存副本需要使用同步机制使得缓存副本一致,但是同步机制大大降低了性能,于是通过重排序进一步改善性能,接踵而来的重排序能够保持SC(顺序一致性)吗,我们看如下例子:

public class Main {
    public static void main(String[] args) {
        int a = 1;
        int b = 2;
        System.out.println(a);
        System.out.println(b);
    }
}

上述在多线程情况下进行重排序可能先打印出2,然后打印出1,重排序并未影响实际打印结果,重排序优化了性能,使得代码运行的更快,但是要是如下例子呢?

class ReadWriteExample {
    int a, b = 0;

    void write() {
        b = 2;
        a = 1;
    }

    void read() {
        int r1 = a;
        int r2 = b;
        System.out.println(r1);
        System.out.println(r2);
    }
}

 如果按照SC定义在多线程情况下执行,要么a = 1最后执行,要么r2 = b最后执行,所以r1和r2可能的结果为(0,*)或者(*,2),但是若按照如下形式执行重排序,此时r1和r2的结果为(1,0),所以我们可得出结论:重排序并不能保持SC,可能会打破SC

因为指令重排序的可能性,所以顺序一致性并不意味着操作按照特定的总顺序执行,尽管顺序一致性具有非常清晰而明确的语义,但编译器很难静态地确定它是否会进行指令重排序或允许并行执行操作以保留安全性,这种语义阻止了许多(但不是全部)针对顺序代码的常见编译器优化,因此,对于未正确同步(即包含数据竞争)的程序,对顺序一致性采取了比较弱的含义。那么问题来了,Java中的内存模型究竟强内存模型还是弱内存模型呢?操作系统的内存模型是强内存模型,因为它对正常内存操作和同步操作做出明确的区分,而Java的内存模型是弱内存模型,它对正常内存操作和同步操作没有做出具体的区分,尤其是针对同步操作,它仅仅只提供了指导方向或基本思想即:同步操作在执行过程中要引起其他操作的可见性和顺序一致性限制。在弱内存模型中,并不对所有动作进行排序,仅对一些有限的原语施加硬性排序,在JMM中,这些原语包装在它们各自的同步动作中。接下来我们进入到进入到利用同步操作构建弱模型的排序情况。在《Java并发编程实战》一书中对内存模型的定义为:通过动作的形式进行描述的、所谓动作,包括变量的读写、监视器的加锁和释放锁、线程的启动和拼接,这里指代的动作即为同步动作(Synchronization Action),通过volatile进行读写、通过lock进行读锁和释放锁、线程的启动(thread.start)、线程的终止(thread.join)。既然讲到同步动作对内存模型的定义,那么逃脱不了对同步顺序(Synchronization Order以下简称为SO)的详细了解,因为同步操作来源于同步顺序,同步顺序(SO)是涵盖所有同步操作的总顺序,JMM提供了两个附加约束:SO-PO一致性和SO一致性。 接下来我们通过简单的例子来解开这些约束。

class ReadWriteExample {
    volatile int x, y = 0;

    void m1() {
        x = 1;
        int r1 = y;
    }

    void m2() {
        y = 1;
        int r2 = x;
    }
}

上述通过volatile关键字修饰变量x和y,因此满足SO,正常PO是write(x,1)、read(y,?)、write(y,1)、read(x,?),但是SO则可能是:【write(x,1)、read(y,?)、read(x,?)、write(y,1),SO与PO执行不一致】和【write(x,1)、read(y,?)、write(y,1)、read(x,?),SO与PO执行一致】和【write(x,1)、write(y,1)、read(x,?)、read(y,?),SO与PO执行一致】。通过分析我们知道SO-PO一致性就是和正常执行程序操作一样,而SO一致性告诉我们要知道SO所有在此之前的动作,尤其是在不同线程中。所以我们可推出:同步操作(SA)是顺序一致性(SC)的,在声明为volatile的变量程序中,我们可以对结果进行推理,由于SA是SC,通过SO就足以推理结果,即使是所有动作进行了交替执行。然而SO并不能构建实用的弱内存模型,只能说SO构建了弱内存模型的基本骨架,其原因是:要么将所有操作转换为SA,要么让非SA操作不受限制的进行排序,很显然这样还是会破坏程序实际结果,若为了达到SC,需要将整个程序进行锁定,但是这又以牺牲性能为代价。接下来我们继续看看如下例子:

class ReadWriteExample {
    int x;
    volatile int y;

    void m1() {
        x = 1;
        y = 1;
    }

    void m2() {
        int r1 = y;
        int r2 = x;
    }
}

由于我们通过volatile修改了变量y,所以会对y进行SO,我们可以正确读取到y = 1,但是对于非SA操作即x变量的值读取,到底是0还是1呢?不得而知。SO即使以牺牲性能为代价保证了顺序一致性,但对非SA操作还需要一个非常弱的语义保证,那就是事先发生(happens-before)。那么什么是事先发生呢?为了捕获有关内存操作的基本顺序和可见性要求,JMM基于事先发生规则(happens-before)【深入理解Java虚拟机将其翻译为先行发生】, 此规则确定了在任何其他动作之前必须发生的动作,换句话说,此顺序指定任何读取必须看到的内存更新,仅允许执行不违反此顺序的命令。由于此模型提供的保证非常弱,因此可允许单线程编译器优化,但是,发生在模型允许通过执行动作的循环证明而凭空产生值的执行之前发生,为避免此类循环证明并保证DRF,目前的JMM比模型初始版本内存模型处理起来要复杂得多。那么事先发生规则是如何解决非SA操作的问题呢?通过引入SO的子顺序来描述数据的流转或者说以此来连接线程之间的状态,我们称之为Synchronization-with Order简称为SW,构造SW相当容易,SW并不是完整的顺序,不能覆盖所有同步操作对。我们继续来看上述代码,SW仅对看到的彼此进行操作配对,例如如上通过volatile修饰的变量y,对变量y的写入与随后所有y的读取都将同步,所以SW是根据SO来定义的,由于SO的一致性,对于变量y写入1仅与读取1同步,在此示例中,我们看到了读取和写入两个操作之间的SW,该子顺序为我们提供了线程之间的桥梁,但适用于同步操作,同样也可以扩展到非SA操作,上述程序在多线程情况下,将通过PO和SW的并集而得到HB(happens-before),从某种意义上讲,HB同时获得线程间和线程内的语义,PO将与每个线程内的顺序操作有关的信息传输到HB中,而在状态同步时通过SW传输,HB是部分排序,并允许使用指令重排序的动作构造等效执行,所以上述可能的执行顺序是(write(x,1)、write(y,1)、read(y,1)、read(x,1)),再通俗一点讲则是,当执行写入y时,由于SW(基于SO)的存在立马对y的读取完全可见,所以整个HB顺序由写入x到写入y,很自然的过度到读取y和读取x,这是可能性情况之一,如果在执行HB一致性之前就进行了读取,那么x和y值可能就是0和0,这属于HB一致性的边界情况分析,当然,有的时候结果也会出乎意料,比如在进行x的读取和写入时没有做到HB,可能结果为0和1,那说明存在竞争,换句话说没有遵守HB一致性,因此不能用来推断结果。谨记不要将SO一致性和HB一致性概念混淆:SO一致性规则指出同步操作应查看SO中最新的相关内容,而HB一致性规则指出它指示特定读取可以观察到哪些写入。

 

Java内存模型定义了8种操作(lock、unlock、read、load、use、assign、store、write)来进行主内存和工作内存的交互细节且为原子性,同时针对8种操作之间的规则限定实践起来非常繁琐,所以最终通过引入事先发生(happens-before)规则来保证在并发下的线程安全,关于以上8种操作的细节请参看《深入理解Java虚拟机》一书。本文详细介绍了内存模型就是一种规范,提供了可能允许的结果,它的本质是提供了一个弱的DRF保证即顺序一致性,而顺序一致性则是通过事先发生(happens-before)来保证,而事先发生(happens-before)则是8大规则:程序次序规则、监视器锁定规则、volatile变量规则、线程启动规则、线程终止规则、线程中断规则、对象终结规则、传递性。那么内存模型的具体含义是什么呢?JMM正式定义的基本组成部分是:动作,执行和验证动作的提交过程,而提交过程又会验证完整的执行。而动作则是(例如对变量的赋值),而动作汇总于执行,验证执行通过(po、so、sw、hb)有效执行产生所需的结果,最终提交允许该结果。

 

总结

本文我们步步分析而引入JMM的概念及其本质,最重要的是我们需要明确知道几个概念:PO、SO、SW、HB,这几个概念就是对DRF的保证, 程序顺序(PO)是对线程内的语义描述,同步顺序(SO)是同步操作的总顺序,同步子顺序(SW)是基于SO连接线程状态的桥梁,事先发生(HB)是操作有序性的保障。

posted @ 2020-03-20 00:26  Jeffcky  阅读(1013)  评论(2编辑  收藏  举报