3-Java内存模型

2018-11-15  本文已影响0人  加夕

1.Java内存模型的基础

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

线程之间如何通信、线程之间如何同步

通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存消息传递

共享内存的并发模型通过写-读内存中的公共状态进行隐式通信。消息传递的并发模型必须通过发送消息来显式进行通信。

同步是指程序中用于控制不同线程间操作发生相对顺序的机制。

共享内存的并发模型同步是显式进行的。消息传递的并发模型同步是隐式进行的。

Java的并发采用的是共享内存模型。

②Java内存模型的抽象结构

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

Java线程之间的通信由Java内存模型(JMM)控制。

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

重排序分3种类型:

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

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

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

1属于编译器重排序,2和3属于处理器重排序。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。

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

④并发编程模型的分类

由于每个处理器上写缓冲区,仅仅对它所在的处理器可见,处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!

下表是常见处理器允许的重排序类型的列表:N表示处理器不允许两个操作重排序,Y表示允许重排序。

Load-读,Store-写

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

​ Store Load Barriers 是一个“全能型”的屏障,同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)

⑤happens-before简介

从JDK5开始,Java使用新的JSR-133内存模型。JSR-133使用happens-before的概念来阐述操作之间的内存可见性。JMM中,如果一个操作执行的结果需要对另一个操作可见,那么两个操作之间必须要存在happens-before关系。两个操作既可以是在一个线程之内,也可以是在不同线程之间。

与程序员密切相关的happens-before规则:

注意:

两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作自谦。

2.重排序

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

①数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,这两个操作之间就存在数据依赖性。编译器和处理器在重排序时,不会改变存在数据依赖关系的两个操作的执行顺序(只针对单个处理器中执行的指令序列和单个线程中执行的操作,多个的时候不考虑数据依赖性)。

②as-if-serial语义

as-if-serial语义意思是:不管怎么重排序,单线程程序的执行结果不能被改变。

as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

③程序顺序规则

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

1)A happens-before B。

2)B happens-before C。

3)A happens-before C。

重排序操作A和操作B后的执行结果与操作A和操作B按happens-before顺序执行的结果一致。这种情况下,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
      ......
    }
  }
}

3和4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此编译器和处理器会采用猜测执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算记过临时保存到一个名为重排序缓冲的硬件缓存中。当3的条件判断为真时,就把该计算结果写入变量i中。单线程程序对存在控制依赖的操作重排序不会改变执行结果,但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

3.顺序一致性

①数据竞争与顺序一致性

Java内存模型规范对数据竞争的定义如下:

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

②顺序一致性内存模型

顺序一致性内存模型:是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。

顺序一致性内存模型两大特性:

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

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

两个线程使用监视器锁正确同步:

两个线程没有做同步:整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。顺序一致性内存模型中的每个操作必须立即对任意线程可见。

但是JMM中没有这个保证。未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。(比如当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,写操作仅对当前线程可见,其他线程会认为这个写操作根本没有被当前线程执行,这种情况下,当前线程和其他线程看到的操作执行顺序将不一致)。

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

对前面ReorderExample用锁来同步,看看正确同步的程序如何具有顺序一致性。

class SynchronizedExample {
  int a = 0;
  boolean flag= false;
  public synchronized void writer() { //获取锁
    a ==1;          //1
    flag == true;   //2
  }                                 //释放锁
  public synchronized void reader() {  //获取锁
    if(flag) {      //3
      int i = a * a; //4
      ......
    }
  }                                 //释放锁
}

④未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false)。

JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。

未同步程序在JMM和顺序一致性模型中的执行特性有如下几个差异:

1)顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证。

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

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

第3个差异与处理器总线的工作机制密切相关。

总线事务:每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务。

总线事务包括读事务和写事务。

在一个处理器执行总线事务期间,总线会禁止其他的处理器和I/O设备执行内存的读/写。

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

在一些32位的处理器上,如果要求对64位数据的写操作具有原子性,会有比较大的开销。JMM对64位变量的写操作不具有原子性。

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

4.volatile的内存语义

①volatile的特性

理解volatile特性的一个好方法是把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。

class VolatileFeaturesExample {
  volatile long vl = 0L;        //使用volatile声明64位的long型变量
  public void set(long l) {
    vl = l;                     //单个volatile变量的写
  }
  public void getAndIncrement() {
    vl++;                       //复合(多个)volatile变量的读/写
  }
  public long get() {
    return vl;                  //单个volatile变量的读
  }
}

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

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

volatile变量自身具有下列特性:

②volatile写-读建立的happens-before关系

从内存语义的角度来说

class VolatileExample {
  int a = 0;
  volatile boolean flag = false;
  public void writer() {
    a ==1;          //1
    flag == true;   //2
  }
  public void reader() {
    if(flag) {      //3
      int i = a; //4
      ......
    }
  }
}

1)根据程序次序规则:1 happens-before 2;3 happens-before 4。

2)根据volatile规则:2 happens-before 3。

3)根据happens-before的传递性规则,1 happens-before 4。

黑色箭头:程序顺序规则。

橙色箭头:volatile规则。

蓝色箭头:组合这些规则后提供的happens-before保证。

③volatile写-读的内存语义

volatile写的内存语义:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。

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

volatile写和volatile读的内存语义总结:

④volatile内存语义的实现

JMM针对编译器指定的volatile重排序规则表。

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

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

class VolatileBarrierExample {
  int a;
  volatile int v1 = 1;
  volatile int v2= 2;
  void readAndWrite() {
    int i = v1;     //第一个volatile读
    int j = v2;     //第二个volatile读
    a = i + j;      //普通写
    v1 = i + 1;     //第一个volatile写
    v2 = j + 2;     //第二个volatile写
  }
  ......        //其他方法
}

最后的StoreLoad屏障不能省略。编译器可能无法精确断定第二个写之后是否会有volatile读或写。

由于X86处理器仅会对写-读操作重排序,所以在X86中,JMM仅需在volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义。

⑤JSR-133为什么要增强volatile的内存语义

在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许volatile变量与普通变量重排序。在旧的内存模型中,VolatileExample示例程序可能被重排序成下列时序来执行:

为了提供一种比锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的内存语义。如果想在程序中用volatile代替锁,请一定谨慎,具体详情参阅Brian Goetz的文章《Java理论与实践:正确使用Volatile变量》。

5.锁的内存语义

①锁的释放-获取 建立的happens-before关系

class MonitorExample {
  int a = 0;
  public synchronized void writer() {   //1
    a++;                            //2
  }                                 //3
  public synchronized void reader() {   //4
    int i = a;                      //5
    ......
  }                                 //6
}

线程A执行writer()方法,线程B执行reader()方法,根据happens-before规则,这个过程包含的happens-before关系:

1)根据程序次序规则:1 happens-before 2,2 happens-before 3,4 happens-before 5,5 happens-before 6。

2)根据监视器锁规则:3 happens-before 4。

3)根据happens-before的传递性:2 happens-before 5。

黑色箭头:程序顺序规则。

橙色箭头:监视器锁规则。

蓝色箭头:组合这些规则后提供的happens-before保证。

②锁的释放和获取的内存语义

当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。

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

以MonitorExample为例:

A线程释放锁后,共享数据的状态示意图:

锁获取的状态示意图:

锁释放(对应volatile写)和锁获取(对应volatile读)的内存语义总结:

③锁内存语义的实现

class ReentrantLockExample {
  int a = 0;
  ReentrantLock lock = new ReentrantLock();
  public void writer() {
    lock.lock();        //获取锁
    try {
      a++;
    } finally {
      lock.unlock();        //释放锁
    }
  }
  public void reader() {
    lock.lock();        //获取锁
    try {
      int i = a;
      ......
    } finally {
      lock.unlock();        //释放锁
    }
  }
}

ReentrantLock的实现依赖于Java同步器框架AbstractQueuedSynchronizer(本文简称为AQS)。AQS使用一个整型的volatile变量state来维护同步状态。

ReentrantLock分为公平锁和非公平锁。

公平锁,lock()调用轨迹:

1)ReentrantLock:lock()。

2)FairSync:lock()。

3)AbstractQueuedSynchronizer:acquire(int arg)。

4)ReentrantLock.FairSync:tryAcquire(int acquires)。

第4步真正开始加锁,下面是该方法源代码

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState(); //获取锁的开始,首先读volatile变量state
    if (c == 0) {
        if (!hasQueuedPredecessors() &&
                compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

公平锁,unlock()调用轨迹:

1)ReentrantLock:unlock()。

2)AbstractQueuedSynchronizer:release(int arg)。

3)ReentrantReadWriteLock.Sync:tryRelease(int releases)。

protected final boolean tryRelease(int releases) {
    if (!isHeldExclusively())
        throw new IllegalMonitorStateException();
    int nextc = getState() - releases;
    boolean free = exclusiveCount(nextc) == 0;
    if (free)
        setExclusiveOwnerThread(null);
    setState(nextc);//释放锁的最后,写volatile变量state
    return free;
}

从上面源码中,可以看出加锁方法先读volatile变量state。在锁释放的最后写volatile变量state。根据volatile的happens-before规则,释放锁的线程在写volatile变量之前可见的共享变量,在获取锁的线程读取同一个volatile变量后将立即变得对获取锁的线程可见。

非公平锁,lock()调用轨迹:

1)ReentrantLock:lock()。

2)ReentrantLock.NonfairSync:lock()。

3)AbstractQueuedSynchronizer:compareAndSetState(int expect, int update)。

第3步真正开始加锁,下面是该方法源代码:

protected final boolean compareAndSetState(int expect, int update) {
    // See below for intrinsics setup to support this
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

非公平锁,unlock()调用轨迹:同公平锁

公平锁和非公平锁的内存语义总结:

锁释放-获取的内存语义的实现至少有两种方式:

1)利用volatile变量的写-读所具有的内存语义。

2)利用CAS所附带的volatile读和volatile写的内存语义。

④concurrent包的实现

Java线程之间的通信有4种方式:

1)A线程写volatile变量,随后B线程读这个volatile变量。

2)A线程写volatile变量,随后B线程用CAS更新这个volatile变量。

3)A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。

4)A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量。

分析concurrent包的源代码实现,会发现一个通用化的实现模式:

6.final域的内存语义

①final域的重排序规则

对于final域,编译器和处理器要遵守两个重排序规则:

public class FinalExample {
  int i;                    //普通变量
  final int j;              //final变量
  static FinalExample obj;
  public FinalExample () {      //构造函数
    i = 1;                  //写普通域
    j = 2;                  //写final域
  }
  public static void writer () {    //写线程A执行
    obj = new FinalExample ();
  }
  public static void reader () {    //读线程B执行
    FinalExample object = obj;      //读对象引用
    int a = object.i;           //读普通域
    int b = object.j;           //读final域
  }
}

②写final域的重排序规则

写final域的重排序规则禁止把final域的写重排序到构造函数之外。规则实现包括:

写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确的初始化过了,而普通域不具有这个保障。

③读final域的重排序规则

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

读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用,而普通域不具有这个保障。

④final域为引用类型

引用类型示例代码:

public class FinalReferenceExample {
  final int[] intArray;             //final是引用类型
  static FinalReferenceExample obj;
  public FinalReferenceExample () {  //构造函数
    intArray = new int[1];          //1
    intArray[0] = 1;                //2
  }
  public static void writerfOne () {  //写线程A执行
    obj = new FinalReferenceExample ();//3
  }
  public static void writerTwo () {  //写线程B执行
    obj.intArray[0] = 2;            //4
  }
  public static void reader () {    //读线程C执行
    if (obj != null) {              //5
      int temp1 = obj.intArray[0];   //6
    }
  }
}

对于引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:

在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。(2和3不能重排序)

线程C不一定能看到线程B的操作。如果要确保能看到,写线程B和读线程C之间需要使用同步原语(lock或volatile)来确保内存可见性。

⑤为什么final引用不能从构造函数内“溢出”

public class FinalReferenceEscapeExample {
  final int i;
  static FinalReferenceEscapeExample obj;
  public FinalReferenceEscapeExample () {
    i = 1;              //1写final域
    obj = this;         //2 this引用在此“逸出”
  }
  public static void writer () {
    new FinalReferenceEscapeExample ();
  }
  public static void reader () {
    if (obj != null) {      //3
      int temp = obj.i;     //4
    }
  }
}

从图可以看出:在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此时的final域可能还没有被初始化。在构造函数返回后,任意线程都将保证能看到final域正确初始化之后的值。

我的理解:不能在构造器中让obj=this,这样会造成this还没有构造完,引用就溢出了

⑥final语义在处理器中的实现

X86处理器中,final域的读/写不会插入任何内存屏障!

⑦JSR-133为什么要增强final的语义

在旧的Java内存模型中,一个最严重的缺陷是线程可能看到final域的值会改变(先看到未初始化的默认值,再看到初始化之后的值)。最常见的例子是在旧的Java内存模型中,String的值可能会改变。

JSR-133专家组增强了final的语义,通过为final域增加写和读重排序规则,可以为Java程序员提供初始化安全保证:只要对象是正确构造的(被构造对象的引用在构造函数中没有"逸出"),那么不需要使用同步(lock和volatile的使用)就可以保证任意线程都能看到这个final域在构造函数中被初始化之后的值。

7.happens-before

①JMM的设计

double pi = 3.14;   //A
double r = 1.0; //B
double area = pi * r * r;   //C

A happens-before B, B happens-before C, A happens-before C。(1不必要,2和3是必需的)

JMM把happens-before要求禁止的重排序分为两类:

JMM遵循一个基本原则:只要不改变程序的执行结果(指的是单线程和正确同步的多线程程序),编译器和处理器怎么优化都行。例如如果编译器经过细致的分析后,认定一个锁/volatile只会被单个线程访问,那么这个锁/volatile可消除。

②happens-before的定义

happens-before关系的定义如下:(单线程和正确同步的多线程程序)

happens-before与as-if-serial类似:

③happens-before规则

happens-before规则:

8.双重检查锁定与延迟初始化

①双重检查锁定的由来

在Java多线程程序中,有时候需要采用延迟初始化来降低初始化类和创建对象的开销。双重检查锁定是常见的延迟初始化技术,但它是一个错误的用法。

public class DoubleCheckedLocking {                 //1
  private static Instance instance;                 //2
  public static Instance getInstance() {            //3
    if (instance == null) {                         //4:第一次检查
      synchronized (DoubleCheckedLocking.class) {   //5:加锁
        if (instance == null) {                     //6:第二次检查
          instance = new Instance();                //7:问题的根源出在这
        }                                           //8
      }                                             //9
      return instance;                              //10
    }                                               //11
  }
}

②问题的根源

第7行代码可以分解为如下伪代码:

memory = allocate();//1:分配对象的内存空间
ctorInstance(memory);//2:初始化对象
instance = memory;//3:设置instance指向刚分配的内存地址

上面伪代码中的2和3之间,可能会被重排序:

memory = allocate();//1:分配对象的内存空间
instance = memory;//3:设置instance指向刚分配的内存地址
                    //注意,此时对象还没有被初始化!
ctorInstance(memory);//2:初始化对象

解决方案:

③基于volatile的解决方案

需要JDK5或更高版本。

public class DoubleCheckedLocking {                 //1
  private volatile static Instance instance;                    //2
  public static Instance getInstance() {            //3
    if (instance == null) {                         //4:第一次检查
      synchronized (DoubleCheckedLocking.class) {   //5:加锁
        if (instance == null) {                     //6:第二次检查
          instance = new Instance();                //7:instance为volatile,现在没有问题了
        }                                           //8
      }                                             //9
      return instance;                              //10
    }                                               //11
  }
}

④基于类初始化的解决方案

public class InstanceFactory {
  private static class InstanceHolder {
    public static Instance instance = new Instance();
  }
  public static Instance getInstance() {
    return InstanceHolder.instance;//这里将导致InstanceHolder类被初始化
  }
}

初始化类的情况:

Java语言规范规定,对于每一个类或接口,都有一个唯一的初始化锁与之对应。

类初始化的处理过程:虚构出condition和state标记,方便理解。

线程B在第4阶段的B1获取同一个初始化锁,根据happens-before关系,保证:线程A执行类的初始化时的写入操作(执行类的静态初始化和初始化类中声明的静态字段),线程B一定能看到。

9.Java内存模型综述

①处理器的内存模型

内存模型类型:

②各种内存模型之间的关系

③JMM的内存可见性保证

④JSR-133对旧内存模型的修补

主要修补有两个:

上一篇下一篇

猜你喜欢

热点阅读