JVM深入理解(四)-内存模型【上】

内存模型

  1.   并发模型分类
  2.        Java内存模型抽象
  3.        重排序
  4.        数据依赖性
  5.        数据竞争与顺序一致性保证

1、并发模型分类 

  在并发编程中,我们要解决线程之间通信及同步,通常考的是两种机制:内存共享、消息传递

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

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

2、Java内存模型抽象 

  Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。

  Java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java内存模型的抽象示意图如下:

  所以如果两个线程要通信的话,一个线程要把本地中的变量刷新到主内存,另一个再从主内存读取
 

3、重排序

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

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

  我们看到除了指令和编译层面的优化,还有一个内存的重排序。这是为了避免频繁的同步主内存而进行的优化。

  对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel称之为memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。而内存重排序,则是通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

Processor AProcessor B
a = 1; //A1 b = 2; //B1
x = b; //A2 y = a; //B2

  初始状态:a = b = 0
  处理器允许执行后得到结果:x = y = 0

  
  

  这是因为 处理器A执行A3后来刷新自己的写缓存区,写操作A1才算真正执行了。虽然处理器A执行内存操作的顺序为:A1->A2,但内存操作实际发生的顺序却是:A2->A1。

  这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操做重排序。
为了保证内存可见性,java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为下列四类:

  屏障类型 指令示例 说明 Load - Load Load1; LoadLoad; Load2 确保Load1数据的装载,之前于Load2及所有后续装载指令的装载。 Store - Store Store1; StoreStore; Store2 确保Store1数据对其他处理器可见(刷新到内存),之前于Store2及所有后续存储指令的存储。 Load - Store Load1; LoadStore; Store2 确保Load1数据装载,之前于Store2及所有后续的存储指令刷新到内存。 Store - Load Store1; StoreLoad; Load2 确保Store1数据对其他处理器变得可见(指刷新到内存),之前于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。
Load - Load Load1; LoadLoad; Load2 确保Load1数据的装载,之前于Load2及所有后续装载指令的装载。
Store - Store Store1; StoreStore; Store2 确保Store1数据对其他处理器可见(刷新到内存),之前于Store2及所有后续存储指令的存储。
Load - Store Load1; LoadStore; Store2 确保Load1数据装载,之前于Store2及所有后续的存储指令刷新到内存。
Store - Load Store1; StoreLoad; Load2 确保Store1数据对其他处理器变得可见(指刷新到内存),之前于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。

  StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他三个屏障的效果。

happens-before

  如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 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仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。

 

4、数据依赖性

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

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

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

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

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

    如果A happens - before B,JMM并不要求A一定要在B之前执行。JMM仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作A的执行结果不需要对操作B可见;而且重排序操作A和操作B后的执行结果,与操作A和操作B按happens- before顺序执行的结果一致。在这种情况下,JMM会认为这种重排序并不非法(not illegal),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
            ……
        }
    }
}

  由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。
  但是在重排序后会出现非预期结果,这是因为代码中存在控制依赖性,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程 B 的处理器可以提前读取并计算 a*a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作 3 的条件判断为真时,就把该计算结果写入变量 i 中。

5、数据竞争与顺序一致性保证 

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

  1. 在一个线程中写一个变量,
  2. 在另一个线程读同一个变量,
  3. 而且写和读没有通过同步来排序。

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

  • 顺序一致性内存模型
    如果程序是正确同步的,程序的执行将具有顺序一致性(sequentially consistent),即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。这里的同步是指广义上的同步,包括对常用同步原语(lock,volatile和final)的正确使用。
    顺序一致性内存模型有两大特性:

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

    为了更好的理解,下面我们通过两个示意图来对顺序一致性模型的特性做进一步的说明。

    假设有两个线程A和B并发执行。其中A线程有三个操作,它们在程序中的顺序是:A1->A2->A3。B线程也有三个操作,它们在程序中的顺序是:B1->B2->B3。

    假设这两个线程使用监视器来正确同步:A线程的三个操作执行后释放监视器,随后B线程获取同一个监视器。那么程序在顺序一致性模型中的执行效果将如下图所示:

  
  现在我们再假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型中的执行示意图:
  
  
  • 未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程A和B看到的执行顺序都是:B1->A1->A2->B2->A3->B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。
  • 同步程序的顺序一致性效果

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线程执行writer()方法后,B线程执行reader()方法。这是一个正确同步的多线程程序。根据JMM规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。下面是该程序在两个内存模型中的执行时序对比图:

  
  

在顺序一致性模型中,所有操作完全按程序的顺序串行执行。

  • 未同步程序的执行特性
    对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false),JMM保证线程读操作读取到的值不会无中生有(out of thin air)的冒出来。

JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为未同步程序在顺序一致性模型中执行时,整体上是无序的,其执行结果无法预知。

转载自链接:https://www.jianshu.com/p/88390b53646b
作者:莫库施勒

原文地址:https://www.cnblogs.com/tuanz/p/15129633.html