Java内存模型

  在命令式编程中,线程之间通信机制有两种:共享内存和消息传递。

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

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

  Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。在Java中,所有实例域,静态域和数组元素都存储在堆内存中,堆内存在线程之间共享。局部变量,方法定义参数和异常处理器参数不会在线程之间共享,他们不会有内存可见性问题,也不受内存模型的影响。线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另个一个线程可见。从抽象的角度来看,JMM定义了线程的主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。若线程A想和线程B通信,则线程A把本地内存A中更新过的共享变量刷新到主内存中后线程B到主内存中去读取线程A之前已经更新过的共享变量。

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

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

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

    内存系统的重排序。由于处理器使用缓存和读/写缓冲区。

  编译器优化的重排序始于编译器重排序,指令级并行的重排序和内存系统的重排序术语处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会进制特定类型的编译器重排序。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台上,通过禁止特定类型的编译器重排序和处理器重排序。

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

  处理器的重排序规则

  Load-Load Load-Store Store-Store Store-Load 数据依赖
SPARC-TSO N N N Y N
x86 N N N Y N
IA64 Y Y Y Y N
PowerPC Y Y Y Y N

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

屏障类型 指令实例 说明
LoadLoad Barriers Load1; LoadLoad; Load2 确保Load1数据的装载先于Load2及所有后续装载指令的装载
StoreStore Barriers

Store1; StoreStore; Store2

确保Store1数据对其他处理器可见先于Store2及所有后续存储指令的存储
LoadStore Barriers Load1; LoadStore; Store2 确保Load1数据装载先于Store2及所有后续的存储指令刷新到内存
StoreLoad Barriers Store1; StoreLoad; Load2 确保Store1数据对其他处理器变得可见先于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令完成之后,才执行该屏障之后的内存访问指令

  

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

  

  从JDK5开始,Java使用新的JSR-133内存模型。JSR-133使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,若一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。happens-before规则如下:

    程序顺序规则:一个线程中的每个操作,happens-before与该线程中的任意后续操作

    监视器锁规则:对一个锁的解锁,happens-before与随后对这个锁加锁

    violatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读

    传递性:如A happens-before B, 且B happens-before C,那么A happens-before C

  两个操作之间具有happens-before关系并不意味着前一个操作必须要在后一个操作之前执行。happens-before仅仅要求前一个操作对后一个操作可见,且前一个操作桉顺序排在第二个操作之前。一个happens-before规则对应于一个或多个编译器和处理器重排序规则。

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

名称 代码示例 说明
写后读 a=1; b=a; 写一个变量后再读这个变量
写后写 a=1; a=2; 写一个变量后再写这个变量
读后写 a=b; b=1; 读一个变量后再写这个变量

  as-if-serial的含义是:不管怎么重排序,程序的执行结果都不能被改变。编译器,runtime和处理器都必须遵守as-if-serial语义。因此编译器和处理器不会对存在数据依赖关系的操作做重排序。编译器和处理器会采用猜测执行来控制相关性对并行度的影响。在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果。但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

  

  Java内存模型规范对数据竞争的定义如下:在一个线程中写一个变量,在另一个线程读统一变量,而且读和写没有通过同步来排序。若程序是正确同步的,程序的执行将具有顺序一致性,即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。顺序一致性内存模型有两大特性:一个线程中的所有操作必须按照程序的顺序来执行;所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作。在任意时间最多只能有一个线程可以连接到内存。当多个线程并发执行时,开关装置能把所有线程的所有内存读/写操作串行化。未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。在JMM中就没有这个保证。未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要那么是之前某个线程写入的值,要么是默认值,JMM保证线程读操作读取到的值不会无中生有的冒出来。为了实现最小安全性,JVM在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象。未同步程序在两个模型中的执行特性有如下几个差异: 

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

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

    JMM不保证对64位的long和double型变量的写操作具有原子性,而顺序一致性保证对所有内存读/写操作都具有原子性。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(Bus Transaction)。总线事务包括读事务(Read Transaction)和写事务(Write Transaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字。总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其他的处理器和I/O设备执行内存的读/写。总线的工作机制可以把所有处理器对内存的访问以串行化的方式来执行。在任意时间点最多只能有一个处理器可以访问内存。这个特性确保了单个总线事务之中的内存读/写操作具有原子性。Java鼓励但不强制JVM对64位long/double型变量的写操作拆分为两个32位的写操作来执行。这两个32位的写操作可能会被分配到不同的总线事务中执行,此时对这个64位变量的写操作将不具有原子性。

  volatile变量自身具备可见性和原子性。对一个volatile变量的读,总是能看到对这个volatile变量最后的写入;对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具备原子性。从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同搞得内存效果:volatile写和锁的释放有相同的内存语义;volatile读与所得获取有相同的内存语义。当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从内存中读取共享变量。即线程A写一个volatile变量,实际上是线程A向接下来将要读这个volatile变量的某个线程发出了消息;线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的消息;线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实际上是线程A通过主内存向线程B发送消息。

volatile重排序规则表

是否能重排序 第二个操作
第一个操作 普通读/写 volatile读 volatile写
普通读/写     NO
volatile读 NO NO NO
volatile写   NO NO

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

    在每个volatile写操作前插入一个StoreStore屏障

    在每个volatile写操作后插入一个StoreLoad屏障

    在每个volatile读操作后插入一个LoadLoad屏障

    在每个volatile读操作后插入一个LoadStore屏障

  在实际执行时,只要不改变volatile写-读的内容语义,编译器可以根据具体情况省略不必要的屏障。由于不同的处理器有不同的“松紧度”的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。

  锁让临界区互斥执行外,可以让锁释放的线程向获取同一个锁的线程发送消息。

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

  线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的的某个线程发出了消息。线程B获取一个锁,实质上是线程B接收了之前某个线程发出的消息。线程A释放锁,随后线程B获取这个锁,这个过程实际上是线程A通过主内存向线程B发送消息。

  ReentrantLock的实现依赖于Java同步框架AbstractQueuedSynchronizer(AQS)。AQS使用了一个整型的volatile变量来维护同步状态。

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

    使用公平锁时,加锁方法lock()调用轨迹如下:

      ReentrantLock:lock()

      FairSync:lock();

      AbstractQueuedSynchronizer:acquire(int arg)

      ReentrantLock:tryAcquire(int acquires)

protected final boolean tryAcquire(int acquires){
  final Thread current = Thread.currentThread();
  int c = getState();
  if(c == 0){
    if(isFirst(current) && compareAndSetState(0, acquires)){
      setExclusiveOwnerThread(current);
      return true;
    }
  }else if(current == getExclusiveOwnerThread()){
    int nextc = c+ acquires;
    if(next < 0){
      throw new Error("Maximum lock count exceeded');
    }
    setState(nextc);
    return true;
  }
  return false
}

   使用公平锁时,解锁方法unlock()调用轨迹如下:

    ReentrantLock:unlock()

    AbstractQueuedSynchronizer:release(int arg)

    Sync:tryRelease(int releases)

protected final boolean tryRelease(int releases){
  int c = getState() - releases;
  if(Thread.currentThread() != getExclusiveOwnerThread()){
    throw new IllegalMonitorStateException;
  }
  boolean free = false;
  if(c == 0){
    free = true;
    setExclusiveOwnerThread(null);
  }
   setState(c);
   return free;
}

  公平锁在释放锁的最后写volatile变量state,在获取锁时首先读volatile变量。根据volatile的happens-before规则,释放锁的线程写在volatile变量之前可见的共享变量,在获取锁的线程读取同一个volatile变量后将立刻变得对获取锁的线程可见。

  非公平锁的释放和公平锁完全一样,使用非公平锁时加锁方法lock()调用轨迹如下:

    ReentrantLock:lock()

    NofairSync:lock()

    AbstractQueuedSynchronizer:compareAndSetState(int expect, int update)

// 若当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值
protected
final boolean compareAndSetState(int expect, int update){ return unsafe.compareAndSwapInt(this, stateOffset, expect, update); }

  公平锁和非公平锁释放锁时,最后都要写一个volatile变量state。公平锁获取时,会先读取volatile变量。非公平锁获取时,首先会用CAS更新volatile变量,这个操作同时具有volatile读和volatile写的内存语义。

  intel手册对lock前缀的说明如下:

    确保对内存的读-写操作原子执行。在Pentium即Pentium之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。从Pentium4,Intel Xeon即P6处理器开始,Intel使用缓存锁定(Cache Locking)来保证指令执行的原子性。缓存锁定将大大降低lock前缀指令的执行开销。

    禁止该指令,与之前和之后的读和写指令重排序

    把写缓冲区中的所有数据刷新到内存中

  

  由于Java的CAS同时具有volatile读和volatile写的内存语义,因此Java线程之间的通信现在有了4种方式:

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

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

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

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

  Java的CAS会使用现代处理器上提供的高效机器级别的原子指令,这些原子指令以原子方式对内存执行读-改-写操作。同时,volatile变量的读/写和CAS可以实现线程之间的通信。concurrent包会有一个通用化的实现模式:先声明共享变量为volatile,然后使用CAS的原子条件更新来实现线程之间的同步。同时配以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。

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

    1) 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用付给一个引用变量,这两个操作之间不能重排序

    2) 初次读一个包含final域的对象引用,与随后初次读这个final域,这两个操作之间不能重排序

  

  写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包含下面两个方面:

    1) JMM禁止编译器把final域的写重排序到构造函数之外

    2) 编译器会在final域的写之后,构造函数return之前,插入一个StroreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外

  读final域的重排序规则是在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作。编译器会在读final域操作的前面加个LoadLoad屏障。reader()方法包含3个操作:初次读引用变量obj;初次读引用变量obj指向对象的普通域j;初次读引用变量obj指向对象的final域i。

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

  由于X86处理器不会对写-写操作做重排序,所以在X86处理器中,写final域需要的StoreStore屏障会被省略掉。同样,由于X86处理器不会对存间接依赖关系的操作做重排序,因此读final域需要的LoadLoad屏障也会被省略掉。即在X86处理器中,final域的读/写不会插入任何内存屏障。

  在设计JMM时,需要考虑两个关键因素:程序员对内存模型的使用。程序员希望内存模型易于理解和编程;编译器和处理器对于内存模型的实现。编译器和处理器希望内存模型对他们的约束越少越好。JMM把happens-before要求禁止的重排序分为了两类:会改变程序执行结果的重排序以及不会改变程序执行结果的重排序。JMM对这两种不同性质的重排序,采取了不同的策略。对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序;对于不会改变程序执行结果的重排序,JMM会对编译器和处理器不做要求。

  JSR-133:Java Memory Model and Thread Specification对happens-before关系定义如下:

    若一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前

    两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。若重排序之后的执行结果与按happens-before关系来执行的结果一致,那么这种重排序并不非法。

  JSR-133:Java Memory Model and Thread Specification定义了happens-before规则;

    程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作

    监视器锁规则:对一个锁的解锁,happens-before与随后对这个锁的加锁

    传递性:若A happens-before B,且B happens-before C,那么A happens-before C

    start()规则:若线程A执行操作ThreadB.start(),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作

    join()规则:若线程A执行操作ThreadB.join()并成功返回,那么线程B的任一操作happens-before于线程A从ThreadB.join()操作成功返回

  在早期JVM中,synchronized存在巨大的性能开销。人们想通过双重检查锁定(Double-Checked Locking)来降低同步的开销。

// 此方法并不是线程安全的。因为ctorInstance(memory)和instance = memory可能会被重排序,导致B线程查看instance不为空但是A线程还并未初始化instance。
public
class DoubleCheckedLocking(){ private static Instance instance; ==》 可以将instance变成volatile变量 public static Instance getInstance(){ if(instance == null){ synchronized (DoubleCheckLocking.class){ if(instance == null){ instance = new Instance();
      // instance = new Instance(); -> 伪代码
      // memory = allocate(); 分配对象的内存空间
// ctorInstance(memory); 初始化对象
// instance = memory; 设置instance指向刚分配的内存地址 } }
return instance; } }

  多个线程视图在同一时间创建对象时,会通过加锁来保证只有一个线程能创建对象。在对象创建好之后,执行getInstance()方法将不需要获取锁。直接返回已创建好的对象。

  根据The Java Language Specification, JAVA SE 7Edition,所有线程在执行Java程序时必须要遵守intra-thread semantics 。intra-thread semantics保证重排序不会改变单线程内的程序执行结果。

  JVM在类的初始化阶段会执行类的初始化,在执行类的初始化期间,JVM会获取一个锁。这个锁可以同步多个线程对同一个类的初始化。基于这个特性,可以实现Initialization On Demand Holder idiom。

public class InstanceFatory{
  private static class InstanceHolder{
    public static Instance instance = new Instance();
  }

  public static Instance getInstance(){
    return InstanceHolder.instance;
  }
}

  初始化一个类,包括执行这个类的静态初始化和初始化在这个类中声明的静态字段。根据Java语言规范,在首次发生下列情况之一时,一个类或接口类型T将被立即初始化:

    T是一个类,而且一个T类型的实例被创建

    T是一个类,且T中声明的一个静态方法被调用

    T中声明的一个静态字段被赋值

    T中声明的一个静态字段被使用,而且这个字段不是一个常量字段

    T是一个顶级类,而且一个短语语句嵌套在T内部被执行

  Java语言规范规定,对于每一个类或接口C,都有一个唯一的初始化锁LC与之对应。从C到LC的映射,由JVM的具体实现去自由实现。JVM会在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了。Java初始化一个类或接口的处理过程如下:

    通过在Class对象上同步即获取Class对象的初始化锁,来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁

      线程A尝试获取Class对象的初始化锁,线程B尝试获取Class对象的初始化锁,由于线程A获得到了锁,线程B将一直等待初始化资源。线程A看到线程的state=noInitialization后将线程设置为state = initializating。线程A释放初始化锁

    线程A执行类的初始化,同时线程B在初始化锁对应的condition上等待

    线程A设置state=initialized,然后唤醒在condition中等待的所有线程

    线程B结束类的初始化处理

    线程C执行类的初始化的处理

  顺序一致性内存模型是一个理论参考模型,JMM和处理器内存模型在设计时通常会以顺序一致性内存模型为参考。根据对不同类型的读/写操作组合的顺序执行的放松,可以把常见处理器的内存模型分为以下三种:

    放松程序中写-读操作的顺序,由此产生了Total Store Ordering内存模型(TSO)

    在TSO基础上,继续放松程序中写-些操作的顺序,由此产生了Partial Store Order内存模型(PSO)

    在PSO基础上,继续放松程序中读-写和读-读操作的顺序,产生了Related Memory Order内存模型(RMO)和PowerPC内存模型。

  处理器对读/写操作的放松是以两个操作之间不存在数据依赖性为前提的。

内存模型名称 对应的处理器 Store-Load重排序 Store-Store重排序 Load-Load和Load-Store重排序 可以更早的读取到其他处理的写 可以更早读取到当前处理器的写
TSO sparc-TSO X64 Y       Y
PSM sparc-PSO Y Y     Y
RMO ia64 Y Y Y   Y
PowerPC PowerPC Y Y Y Y Y

   JMM屏蔽了不同处理器内存模型的差异。它通过在不同的处理器中插入内存屏障来为程序员提供了一个一致的内存模型

  按程序类型, Java程序的内存可见性保证可以分为3类:

    单线程程序:单线程程序不会出现内存可见性问题。编译器,runtime和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同

    正确同步的多线程程序:正确同步的多线程程序的执行将具有顺序一致性结果与该程序在顺序一致性内存模型中的执行结果相同

    未同步/未正确同步的多线程程序:JMM为他们提供最小安全性保障,线程执行时读取到的值,要么是之前某个线程写入值,要么是默认值

  最小安全性保障与64位数据的费原子性写并不矛盾。他们是两个不同的概念,他们发生的时间点也不同。最小安全性保证对象默认初始化之后。才会被任意线程使用。最小安全性“发生”在对象被任意线程使用之前。64位数据的非原子性写发生在对象被多个线程使用的过程中。

  JSR-133对JDK5之前的就内存模型的修补主要有两个:

    增强volatile的内存语义。就内存模型允许volatile变量与普通变量重排序。JSR-133严格限制volatile变量与普通变量的重排序,使volatile的写-读和锁的释放-获取具有相同的内存语义。

    增强final的内存语义。在旧内存模型中,多次读取同一个final变量的值可能会不同。为此,JSR-133为final增加了两个重排序规则。在保证final引用该不会从构造函数内逸出的情况下,final具有了初始化安全性

原文地址:https://www.cnblogs.com/forerver-elf/p/7562986.html