Java内存模型

2019-07-30  本文已影响0人  咖啡少年不加糖whm

通讯:指线程之间以何种机制来交换信息
    Java线程之间的通信机制有两种: 内存共享和消息传递。
内存共享的并发模型中,线程之间共享城西的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。
再消息传递的并发模型中,线程之间没有公共状态,线程之间必须通过明确的发送消息来显示进行通讯。

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

Java内存模型的抽象:
    在Java中,所有实例域,静态域和数组元素存储在堆内存中,堆内存在线程之间共享。局部变量,方法定义参数和异常处理器参数不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
   Java线程之间的通信有Java内存模型(JMM)控制,JMM决定了一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程与主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每一个线程都有一个自己私有的本地内存,本地内存中存储了该变量读/写共享变量的副本,本地内存是JMM的一个抽象概念,并不真实存在。

JMM抽象示意图

从上图来看,如何线程A和线程B要通信的话,要如下两个步骤:
1、线程A需要将本地内存A中的共享变量副本刷新到主内存去
2、线程B去主内存读取线程A之前已更新过的共享变量

步骤示意图

例子: 
     本地内存A和B有主内存共享变量X的副本。假设一开始时,这三个内存中X的值都是0。线程A正执行时,把更新后的X值(假设为1)临时存放在自己的本地内存A中,当线程A和B需要通信时,线程A首先会把自己本地内存A中修改后的X值刷新到主内存中,此时主内存中的X值变为了1,随后,线程B到主内存中读取线程A更新后的共享变量X的值,此时线程B的本地内存的X值也变成了1.

重排序

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

从java源代码到最终实际执行的指令序列,会分别经历三种重排序

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

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

处理器重排序

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

假设处理器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的内存操作顺序被重排序了。

这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都允许对写-读操作重排序。

内存屏障指令

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

HAPPENS-BEFORE

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与JMM的关系图

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

数据依赖性

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

上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。

前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

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

AS-IF-SERIAL语义

    as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime和处理器都必须遵守as-if-serial语义。
    为遵守as-if-serial编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是如果操作之间没有数据依赖关系,这些操作就可能被编译器和处理器重排序。
double pi = 3.14;    //A
double r = 1.0;        //B
double area = pi * r * r;     // C

上面三个操作的数据依赖关系图

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

该程序的两种执行顺序

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

重排序对多线程的影响

class Demo{
    int a= 0;
    boolean flag = false:

    public void weite(){
        a = 1;       //1
        flag = true;      //2
    }

    public void read(){
        if(flag)                  //3
            int i = a * a;      //4
    }
}

    由于操作1和2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。
     1、当操作1和操作2重排序时,可能会产生什么效果?

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

    2、当操作3和操作4重排序时会产生什么效果(可以说明控制依赖性)

    在程序中,操作3和操作4存在控制依赖关系。当代码中存在控制依赖关系时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提现读取并计算a * a,然后把计算结果临时保持到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作3的条件判断为真时,就把该计算结果写入变量i中。
    从图中我们可以看出,猜测执行实质上对操作3和4做了重排序。重排序在这里破坏了多线程程序的语义!
    在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程中,对存在控制以来的操作重排序,可能会改变程序的执行结果。

顺序一致性

顺序一致性内存模型

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

顺序一致性内存模型为程序员提供的视图

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

例子:
    假设有两个线程A和B并发执行。其中A线程有三个操作,它们在程序中的顺序是:A1 -> A2   ->  A3。 B线程也有三个操作,它们的程序中的顺序是: B1 -> B2 -> B3.
    假设这两个线程使用监视器锁来正确同步: A线程的三个操作执行后释放监视器锁,随后B线程获取同一个监视器锁。那么程序在顺序一致性模型中的执行效果如果所示:

现在我们在假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型:

执行示意图

    未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程A和B看到的执行顺序都是:B1 -> A1 -> A2 -> B2 -> A3 -> B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。
    但是。在JMM中就没有这个保证。未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,在还没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本还没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存中,这个写操作才能对其他线程可见。在这种情况下,当前线程和其它线程看到的操作执行顺序将不一致。

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

下面我们对前面的实例程序用锁来同步,看看正确同步的程序如果具有顺序一致性。

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

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

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

上面实例代码中,假设A线程执行write()方法后,B线程执行reade()方法。这是一个正确同步的多线程程序。根据JMM规范,该程序的执行结果与该程序在顺序一致性模型中的执行结果相同。

该程序在两个内存模型中的执行时序对比图

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

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

未同步程序的执行特性

    未同步程序在JMM中的执行时,整体上是无序的,其执行结果无法预知。未同步程序在两个模型中的执行特性有下面几个差异:
    1、顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序临界区内的重排序)。
    2、顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。
    3、JMM不保证对64位的long型和double型变量的读 / 写操作具有原子性,而顺序一致性模型保证对所有的内存读 / 写操作都具有原子。

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

总线的工作机制

    如上图所示,假设处理器A、B和C同时向总线发起总线事务,这时总线仲裁会对竞争作出裁决,假设总线在仲裁后判定处理器A在竞争中获胜(总线仲裁会确保所有处理器都能公平的访问内存)。此时处理器A继续它的总线事务,而其它两个处理器则要等待处理器A的总线事务完成后才能开始再次执行内存访问。假设在处理器A执行总线事务期间(不管这个总线事务是读事务还是写事务),处理器D向总线发起了总线事务,此时处理器D的这个请求会被总线禁止。

    总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行;在任意时间点, 最多只能有一个处理器能访问内存。这个特性确保了单个总线事务之中的内存读 / 写操作具有原子性。

    在一些32位的处理器上,如果要求对64位数据的写操作具有原子性,会有比较大的开销。为了照顾这种处理器,Java语言规范鼓励但不强求JVM对64位的long型变量和double型变量的写具有原子性。当JVM在这种处理器上运行时,会把64位long / double型变量的写操作拆分为两个32位的写操作来执行。这两个32位的写操作可能会被分配到不同的总线事务中执行,此时对这个64位变量的写将不具有原子性。

当单个内存操作不具有原子性,将可能会产生意想不到的后果

    如上图所示,假设处理器A写一个long型变量,同时处理器B要读这个long型变量。处理器A中64位的写读操作被拆分为两个32位的写操作,且这两个32位的写操作被分配到不同的写事务中执行。同时处理器B中64位的读操作被分配到单个的读事务中执行。当处理器A和B按上图的时序来执行时,处理器B将看到仅仅被处理器A“写了一半”的无效值。

    注意,在JSR-133之前的旧内存模型中,一个64位long / double 型变量的读 / 写操作可以被拆分为两个32位的读 ./ 写操作来执行。从JSR-133内存模型开始(即从JDK5开始),仅仅只允许把一个64位long / double型变量的写操作拆分为两个32位的写操作来执行,任意的读操作在JSR-133中都必须具有原子性(即任意读写必须要在单个读事务中执行)。

Volatile

Volatile特性

public class VolatileTest{
    volatile long a = 1L;        //使用volatile声明64位的long型

    public void set(long l){
        a = l;      //单个volatile 变量的写
    }
    
    public long get(){
        retron a;                 //单个volatile变量的读
    }

    public void getAndIncreament(){
        a++;                //复合(多个)volatile变量的读 / 写
    }
}

假设有多个线程分别调用上面程序的三个方法,这个程序在语义上和下面程序等价:

public class VolatileTest{
    long a = 1L;                 //64位的long型普通变量

    public synchronized void set(long l){             //对单个普通变量的写用同一个锁同步
        a = l;
    }
    
    public synchronized long get(){                    //对单个普通变量的读用同一个锁同步
        return a;
    }
    
    public void getAndIncreament(){            //普通方法调用
        long temp = get();                                   //调用已同步的读方法
        temp += 1L;                                            //普通写操作
        set(temp);                                               //调用已同步的写方法
    }
}

    如上面示例程序所示,对一个volatile变量的单个读 / 写操作,与对一个普通变量的读 / 写操作使用同一个锁来同步,它们之间的执行效果相同。
    锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
    锁的语义决定了临界区代码的执行具有原子性。这意味着即使是64位的long型和double型变量,只要它是volatile变量,对该变量的读写就将具有原子性。如果是多个volatile操作或类似于volatile++这种符合操作,这些操作整体上不具有原子性。

简而言之,volatile变量自身具有下列特性:
    可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
    原子性:对任意单个volatile变量的读 / 写具有原子性,但类似于volatile++这种复合操作不具有原子性。

VOLATILE 写-读的内存定义

    当写一个volatile变量时,JMM会把该线程对应的 本地内存中的共享变量值刷新到主内存中。
    当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

假设上面的程序flag变量用volatile修饰

VOLATILE内存语义的实现

下面是JMM针对编译器制定的volatile重排序规则表:

    为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

下面是基于保守策略的JMM内存屏障插入策略:
    在每个volatile写操作的前面插入一个StoreStore屏障。
    在每个volatile写操作的后面插入一个StoreLoad屏障。
    在每个volatile读操作的后面插入一个LoadLoad屏障。
    在每个volatile读操作的后面插入一个LoadStore屏障。

下面是保守策略下,volatile写操作:

插入内存屏障后生成的指令序列示意图

下面是在保守策略下,volatile读操作:

插入内存屏障后生成的指令序列示意图

上述volatile写操作和volatile读操作的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。

锁释放和获取的内存语义

    当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
    当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中去读取共享变量。

CONCURRENT包的实现

    如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模型:
        1、首先,声明共享变量为volatile;
        2、然后,使用CAS的原子条件更新来实现线程之间的同步;
        3、同时,配合以volatile的读 / 写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。

AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic包中的类),这些concurrent包中的基础类都是使用这种模式来实现的,而concurrent包中的高层类又是依赖于这些基础类来实现的。从整体来看,concurrent包的实现示意图如下

    final

对于final域,编译器和处理器要遵守两个重排序规则:
    1、在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
    2、初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

写FINAL域的重排序规则

写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包含下面2个方面:
    JMM禁止编译器把final域的写重排序到构造函数之外。
    编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。

读FINAL域的重排序规则

在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。

FINAL域是引用类型

对于引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:
    在构造函数内对一个final引用的对象的成员域的写入,与随后的构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

总结

JMM,处理器内存模型与顺序一致性内存模型之间的关系

JMM是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性内存模型是一个理论参考模型,处理器内存模型和顺序一致性内存模型的强弱对比示意图:

处理器内存模型和顺序一致性内存模型的强弱对比示意图 JMM的设计示意图

JMM的内存可见性保证

Java程序的内存可见性保证按程序类型可以分为下列三类:
    1、单线程程序。单线程程序不会出现内存可见性问题。编译器,runtime和处理器会共同确保单线程程序的执行结果与该线程在顺序一致性模型中的执行结果相同。
    2、正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是JMM关注的重点,JMM通过限制编译器和处理器的重排来为程序员提供内存可见性保证。
    3、为同步/ 为正确同步的多线程。JMM为他们提供了最小安全保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0, null, false))

展示上面说的三类程序在JMM中与在顺序一致性内存模型中的执行结果的异同
上一篇下一篇

猜你喜欢

热点阅读