(第三章)Java内存模型(上)

时间:2023-03-08 21:52:53

一、java内存模型的基础

  1.1 并发编程模型的两个关键问题

    在并发编程中,需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体)。通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。

    在共享内存的模型里,线程之间共享程序的公共状态,通过读-写内存中的公共状态来进行隐式通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显示进行通信。

    同步是指程序中用于控制不同线程间操作发生相对顺序的机制。在共享内存并发模型里,同步是显示进行的。程序员必须显示指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

    Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的Java程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。

  1.2 Java内存模型的抽象结构

    在Java中,所有实体域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享(本章用"共享变量"这个术语来代指实例域,静态域和数组元素)。局部变量、方法定义参数和异常处理器参数不会在线程之间共享,他们不会有内存可见性问题,也不受内存模型的影响。

    从抽象的角度来看,Java内存模型(简称JMM)定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓存区、寄存器以及其他的硬件和编译器优化。

    两个线程之间进行通信的话,需要经历2个步骤:1、线程A把本地内存中更新过的共享变量刷新到主内存中去。2、线程B到主内存中去读取线程A之前已更新过的共享变量。

  1.3 从源代码到指令序列的重排序

    在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。

    1、编译器优化的重排序。编译器在不改变单线程语义的前提下,可以重新安排语句的执行顺序。

    2、指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

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

    从源代码到最终实际执行的指令序列,会经历3种重排序。

    源代码>1:编译器优化重排序>2:指令级并行重排序>3:内存系统重排序>最终执行的指令序列

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

    JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

  1.4 并发编程模型的分类

    现代的处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓存区,以及合并写缓存区中对同一内存地址的多次写,减少对内存总线的占用。虽然写缓存区有那么多好处,但每个处理器上的写缓存区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!

    示例:ProcessorA:a=1;(A1)x=b;(A2)。ProcessorB:b=2;(B1)y=a;(B2)。初始状态a=b=0,假设处理器A和处理器B按程序的顺序并行执行内存访问,最终可能得到x=y=0的结果。

    分析:处理器A和处理器B可以同时把共享变量写入自己的缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到x=y=0的结果。从内存操作实际发生的顺序来看,知道处理器A执行A3来刷新自己的写缓存区,写操作A1才算真正执行了。虽然处理器A执行内存操作的顺序为:A1>A2,但内存操作实际发生的顺序缺是A2>A1。此时,处理器A的内存操作顺序被重排序了,处理器B的情况也是一样。这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作进行重排序。

    为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为4类:

(第三章)Java内存模型(上)

    StoreLoad Barriers是一个"全能型"屏障,它同时具有其他3个屏障的效果。现代的处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中。

  1.5 happens-before简介

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

    与程序员密切相关的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关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。happens-before的定义很微妙,一个happens-before规则对应于一个或多个编译器和处理器重排序规则。对于Java程序员来说,happens-before规则简单易懂,它避免Java程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法。

二、重排序

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

  2.1 数据依赖性

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

    1、写后读,a=1;b=a;写一个变量之后,再读这个变量。

    2、写后写,a=1;a=2;写一个变量之后,再写这个变量。

    3、读后写,a=b;b=1;读一个变量之后,再写这个变量。

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

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

  2.2 as-if-serial语义

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

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

    as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

  2.3 程序顺序规则

    假设这样一种情况:A happens-before B,B happens-before C,A happens-before C。这里的第3个happens-before关系,是根据happens-before的传递性推导出来的。

    这里A happens-before B,但实际执行时B却可以排在A之前执行。如果A happens-before B,JMM并不要求A一定要在B之前执行。JMM仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作A的执行结果不需要对操作B可见;而且重排序操作A和操作B后的执行结果与操作A和操作B按happens-before顺序执行的结果一致。在这种情况下,JMM会认为这种重排序不非法,JMM允许这种重排序。

    在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能提高并行度。编译器和处理器遵从这一目标,从happens-before的定义我们可能看出,JMM同样遵从这一目标。

  2.4 重排序对多线程的影响

    以示例代码来看看重排序是否会改变多线程程序的执行结果:

class ReorderExample {
    int a = 0;
    boolean flag = false;

    public void writer() {
        a = 1;
        flag =
    }

    public void reader() {

        }
    }

}

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

    答案是:不一定能看到。

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

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

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

    在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

三、顺序一致性

    顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。

  3.1 数据竞争与顺序一致性

    当程序未正确同步时,就可能会出现数据竞争。Java内存模型规范对数据竞争的定义如下:

    在一个线程中写一个变量,

    而另一个线程读同一个变量,

    而且写和读没有通过同步来排序。

    当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。

    JMM对正确同步的多线程程序的内存一致性做了如下保证:

    如果程序是正确同步的,程序的执行将具有顺序一致性,即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。这对于程序员来说是一个极强的保证。这里的同步是指广义上的同步,包括对常用同步原语(synchronized、volatile和final)的正确使用。

  3.2 顺序一致性内存模型

    顺序一致性内存模型为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:

    1、一个线程中的所有操作必须按照程序的顺序来执行。

    2、(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

    在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作。在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,开关装置能把所有线程的所有内存读/写操作串行化(即在顺序一致性模型中,所有操作之间具有全序关系)。

  3.3 同步程序的顺序一致性效果

    下面用前面的示例程序ReorderExample用锁来同步,看看正确同步的程序如何具有顺序一致性:

class SynchronizedExample {
    int a = 0;
    boolean flag = false;

    public synchronized void writer() {     //获取锁
        a = 1;
        flag = true;
    }                                          //释放锁

    public synchronized void reader() {     //获取锁
        if (flag) {
            int i = a * a;
        }
    }                                          //释放锁

}

在上面的示例代码中,假设A线程执行完writer()方法后,B线程执行reader()方法。这是一个正确同步的多线程程序。根据JMM规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。下面是该程序在两个内存模型中的执行时序对比图:

(第三章)Java内存模型(上)

    顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在JMM中,临界区内的代码可以重排序(但JMM不允许临界区内的代码"逸出"到临界区之外,那样会破坏监视器的语义)。JMM会在退出临界区和进去临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图。虽然线程A在临界区内做了重排序,但由于监视器互斥执行的特性,这里的线程B根本无法"观察"到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行效果。

    从这里我们可以看到,JMM在具体实现上的基本方针为:在不改变(正确同步的)程序执行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门。

  3.4 未同步程序的执行特性

    对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,Null,False),JMM保证线程读操作读取到的值不会无中生有的冒出来。为了实现最小安全性,JVM在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象(JVM内部会同步这两个操作)。因此,在已清零的内存空间分配对象时,域的默认初始化已经完成了。

    JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为如果想要保证执行结果一致,JMM需要禁止大量的处理器和编译器的优化,这对程序的执行性能会产生很大的影响。而且未同步程序在顺序一致性模型中执行时,整体是无序的,其执行结果往往无法预知。而且,保证未同步程序在这两个模型中的执行结果一致没什么意义。

    未同步程序在JMM中的执行时,整体上是无序的,其执行结果无法预知。未同步程序在两个模型中的执行特性有如下几个差异:

    1、顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行(比如临界区内的重排序)。

    2、顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。

    3、JMM不保证对64位的long型和double型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读/写操作都具有原子性。

    第三个差异与处理器总线的工作机制密切相关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务。总线事物包括读事务和写事务。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字。这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其他的处理器和I/O设备执行内存的读/写。

    在一些32位的处理器上,如果要求对64位数据的写操作具有原子性,会有比较大的开销。为了照顾这种处理器,java语言规范鼓励但不强求JVM对64位的long型变量和double型变量的写操作具有原子性。当JVM在这种处理器上运行时,可能会把一个64位long/double型变量的写操作拆分为两个32位的写操作来执行。这两个32位的写操作可能会被分配到不同的总线事务中执行,此时对这个64位变量的写操作将不具有原子性。在JSR-133之前的旧内存模型中,一个64位long/double型变量的读/写操作可以被拆分为两个32位的读/写操作来执行。从JAR-133内存模型开始(即从JDK 5开始),仅仅只允许把一个64位的long/double型变量的写操作拆分为两个32位的写操作来执行,任意的读操作在JSR-133中都必须具有原子性(即任意读操作必须在单个读事务中执行)。