深刻理解Java内存模型

1、Java内存模型介绍 

内存模型的做用范围:

在Java中,全部实例域、静态域和数组元素存放在堆内存中,线程之间共享,下文称之为“共享变量”。局部变量、方法参数、异常处理器等不会在线程之间共享,不存在内存可见性问题,也不受内存模型的影响。java

重排序与可见性:

现代编译器在编译源码时会作一些优化处理,对代码指令进行重排序;现代流水线结构的处理器为了提升并行度,在执行时也可能对指令作一些顺序上的调整。重排序包括编译器重排序、指令级并行重排序和内存系统重排序等。通常来讲,编译器和处理器在作重排序的时候都会作一些保证,保证程序的执行结果与重排序以前指令的执行结果相同。即as-if-serial,无论怎样重排序,都不能改变程序的执行结果。程序员

CPU在执行指令时通常都会使用缓存技术来提升效率,若是不一样线程使用不一样的缓存空间则会形成一个线程对一个共享变量的更新不能及时反映给其余线程,也就是多线程对共享变量更新的可见性问题,这个问题是很是复杂的。数组

Java内存模型的抽象:

对于上述问题,Java内存模型(JMM)为程序员提供了一个抽象层面的描述,咱们不用去关心编译器、处理器对指令作了怎样的重排序,也不用关心复杂的系统缓存机制,只要遵循JMM的规则,JMM就能为咱们提供代码顺序性、共享变量可见性的保证,从而获得预期的执行结果。缓存

JMM决定了一个线程对共享变量的写入什么时候对另外一个线程可见。从抽象来说,线程共享变量存放在主内存(main memory),每一个线程持有一个本地内存(local memory),本地内存中存储了该线程读写共享变量的副本(本地内存是JMM的一个抽象概念,并非真实存在的)。以下图:安全

image

若是A、B两个线程要通讯要通过如下两步:首先线程A将本地内存中更新过的共享变量刷新到主内存中,而后线程B到主内存中读取A以前更新过的变量。多线程

JMM经过控制主内存与每一个线程的本地内存之间的交互来为Java程序员提供可见性保证。并发

重排序:

现代编译器和处理器会对指令执行的顺序进行重排序,以此提升程序的性能。这些重排序可能会致使多线程程序出现内存可见性问题。为了避免改变程序的执行结果,对于编译器,JMM会禁止特定类型的编译器重排序;对于处理器重排序,JMM要求在Java编译生产指令序列时,插入特定类型的内存屏障(memory barriers)来禁止特定类型的重排序。app

JMM把内存屏障分为如下四类:函数

屏障类型 指令示例 说明
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是一个全能型屏障,同时具备其余三个屏障的效果。

Happens-before:

从JDK1.5开始,Java使用新的JSR-133内存模型(如下全部都是针对该内存模型讲的),使用happens-before的概念来阐述操做之间的内存可见性。

若是一个操做要对另外一个操做可见,那这两个操做之间必须存在happens-before关系。这两个操做能够在一个线程内,也能够在不一样线程之间。ps.(两个操做存在happens-before关系并不意味着前一个操做必须在后一个操做以前执行,仅仅要求前一个操做对后一个操做可见。)

常见的与程序员相关的happens-before规则以下:

①程序顺序规则:一个线程中的每一个操做happens-before于其后的任意操做;

②监视器锁规则:对一个监视器的解锁happens-before于随后对这个监视器的加锁;

③volatile规则:对一个volatile域的写happens-before于任意后续对该域的读操做(该规则多个线程之间也成立);

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

image

数据依赖性:若是两个操做访问同一个变量,且这两个操做其中一个为写操做时,这两个操做就存在数据依赖性。以下示例:

写后读

a=1;

b=a;

写后写

a=1;

a=2;

读后写

a=b;

b=a;

上述三类状况存在数据依赖性,此时不容许重排序,不然程序的结果可能会改变。

as-if-serial语义:

as-if-serial语义的意思是:在单线程内,无论怎么重排序,程序的执行结果不变,在程序员看来,就像顺序执行的同样。

示例:

 

a = 1; //A
b = 2; //B
c = a + b; //C

前两条语句就能够进行重排序,而第三条语句与前两条存在依赖关系,不能重排序。

上述A happens-before B,B happens-before C,但并不保证A在B以前执行,只须要保证操做A对B可见(这里A操做不须要对B可见,所以能够重排)

重排序对多线程的影响:

示例:

 

class Demo {
    boolean flag = false;
    int a = 0;
    
    public void fun1() {
        a = 1; //A
        flag = true; //B
    }
    
    public void fun2() {
        if (flag) { //C
            a = a + a; //D
        }
    }
}

假设上述类中fun1()和fun2()在不一样线程中执行,操做A、B没有依赖关系,可能被重排序;操做C、D虽然存在控制依赖关系,现代编译器和处理器为了提升并行度,可能采起激进的方法(即先求出if语句块中的值存于临时变量中,若是if条件为真则使用该值,不然丢弃)对其进行重排序,这均可能改变程序的执行结果。

顺序一致性内存模型:

计算机科学家们提出了一个理想化的理论参考模型--顺序一致性模型,它为程序员提供了极强的内存可见性,具备以下两大特性:

①一个线程中的全部操做必须按照程序顺序来执行;

②全部线程(不管同步与否)都只能看到一个单一的操做执行顺序。每一个操做都必须是原子的且马上对全部线程可见。

示例:

假设有A和B两个线程并发执行,A线程中有三个操做,顺序是A1->A2->A3,线程B中也有三个操做,顺序是B1->B2->B3。 先假设这两个线程使用监视器同步,A线程先得到监视器,执行完毕释放监视器后线程B开始执行。那么他们在顺序一致性模型中执行效果以下:

image

如今咱们再假设这两个线程未进行同步,其在顺序一致性模型中执行效果以下:

image

能够看到,未同步的程序在顺序一致性模型中虽然总体执行顺序是无序的,但全部线程都只看到一个一致的总体执行顺序。如上图,线程A和B看到的执行顺序都是B1->A1->A2->B2-A3->B3。之因此能获得这个保证是由于顺序一致性内存模型中的每一个操做必须当即对任何线程可见。

可是JMM中没有这个保证。好比当前线程写数据到本地内存中,在尚未刷新到主内存以前,这个写操做只对当前线程可见,从其余线程角度观察,能够认为这个写操做根本尚未被当前线程执行过。这种状况下,当前线程和其余线程看到的操做执行顺序将不一致。

同步程序的一致性效果:

示例:

 

class SynchronizedDemo {
    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获取锁并执行read()方法,执行流程以下:

clip_image002[15]

在顺序一致性模型中,全部操做按顺序执行。在JMM中,临界区内的代码能够重排序(JMM不容许临界区内的代码“逸出”到临界区以外),JMM会在进入和退出临界区的关键点上作一些限定,使得现场在这两个关键点处具备和顺序一致性模型具备相同的内存视图。虽然现场A在临界区内作了重排序,但因为监视器的互斥性,这里线程B根本没法“观察”到线程A在临界区内的重排序,这样既提升了效率又不改变程序的执行结果。

对于未同步的多线程程序,JMM只提供最小安全性:线程执行读操做取得的值,要么是以前线程写入的,要么是默认值(0,null,false),JMM保证线程读取的数据不是无中生有冒出来的。为了实现最小安全,JVM在堆上分配对象时首先会清空内存空间,而后才分配对象(所以对象分配时,域的默认初始化已经完成)。

此外,JMM的最小安全不保证对64位的long和double型变量的读写具备原子性,而顺序一致性模型保证对全部内存读写操做具备原子性。

2、Volatile特性

volatile变量的单次读写,至关于使用了一个锁对这些单个读/写作了同步。

原子性:对volatile变量的单次读写操做具备原子性(ps.这里存在争议,暂且这么写,保留意见);

可见性:锁的happens-before规则保证释放锁和获取锁的两个线程之间的可见性,这意味着对一个volatile变量的读操做总能看到以前任意线程对这个volatile变量最后的写入,即对volatile变量的写操做对其余线程当即可见。

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

       当读一个volatile变量时,JMM会把改下昵称对应的本地内存置为无效,接下来从主内存中读取共享变量的值。

 

从内存语义的角度来讲,volatile的写-读于锁的释放-获取具备相同的内存效果。所以若是线程A对volatile变量的写操做在线程B对volatile变量的读操做以前,则其存在happens-before关系。

示例:

class VolatileDemo {
     volatile boolean flag = false; 
     int a=0;
public void fun1() {
      a=1; //A
      flag = true; //B 
    } 

    public void fun2() { 
        if (flag) { //C 
            a=a+a; //D 
        } 
    } 
}

上述操做A happens-before 操做B,操做C happens-before 操做D,若是线程1调用fun1()方法以后线程2调用fun2()方法,则操做B happens-before 操做C,根据happens-before的传递性,则有A happens-before D,所以能够保证操做D能够正确读取到操做A的赋值。

Volatile的内存语义是JMM经过在volatile读写操做先后插入内存屏障实现的。

3、锁的特性

锁的释放与获取遵循happens-before规则,释放锁线程临界区的操做结果对获取锁的线程可见。

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

        当线程获取锁时,JMM会把改下昵称对应的本地内存置为无效,接下来从主内存中读取共享变量的值。

ReentrantLock是java.util.concurrent.locks包下的一个锁的实现,依赖对volatile变量的读写和compareAndSet(CAS)操做实现锁机制。其中CAS操做使用不一样的CPU指令实现单次操做的原子性,具备volatile读写操做相同的内存语义。 类图以下:

ReentrantLock根据对抢占锁的线程的处理方式不一样,分为公平锁和非公平锁,首先看公平锁,使用公平锁加锁时,加锁方法lock()的方法调用主要有如下四步:

1. ReentrantLock : lock() 
2. FairSync : lock() 
3. AbstractQueuedSynchronizer : acquire(int arg) 
4. ReentrantLock : tryAcquire(int acquires)

在第四步才开始真正加锁,该方法的源码以下:

protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();();//获取锁的开始,state是volatile类型变量 
            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;
        }

从上面方法能够看出,加锁方法首先读取volatile变量state。

 

使用公平锁的unlock()方法调用轨迹以下:

1. ReentrantLock : unlock() 
2. AbstractQueuedSynchronizer : release(int arg) 
3. 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);//释放锁后,写volatile变量state
    return free;
}

从上面代码能够看出,在释放锁的最后写volatile变量state。

公平锁在释放锁的最后写volatile变量state,在获取锁的时候首先读这个volatile变量。根据volatile的happens-before规则,释放锁的线程在写volatile变量以后该变量对获取锁的线程可见。

 

Java中的CAS操做同时具备volatile读和volatile写的内存语义,所以Java线程之间通讯如今有了如下四种方式:

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

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

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

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

 

Java的CAS会使用现代处理器上提供的高效机器级别原子指令,这些原子指令以原子方式对内存执行读-改-写操做,同时,volatile变量的读/写和CAS能够实现线程之间的通讯。把这些特性整合在一块儿,就造成了整个concurrent包得以实现的基石。若是咱们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式:

一、首先,声明共享变量为volatile;

二、而后,使用CAS的原子条件更新来实现线程之间的同步;

三、同时,配合以volatile的读/写和CAS所具备的volatile读和写的内存语义来实现线程之间的通讯。

4、Final 的特性

与前面介绍的锁和volatile相比较,对final域的读和写更像是普通的变量访问。对于final域,编译器和处理器要遵照两个(分别对应读写)重排序规则:

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

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

写final域的重排序规则

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

一、JMM禁止编译器把final域的写重排序到构造函数以外。

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

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

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

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

读final域的重排序规则

在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操做(注意,这个规则仅仅针对处理器)。编译器会在读final域操做的前面插入一个LoadLoad屏障。初次读对象引用与初次读该对象包含的final域,这两个操做之间存在间接依赖关系。因为编译器遵照间接依赖关系,所以编译器不会重排序这两个操做。

读final域的重排序规则能够确保:在读一个对象的final域以前,必定会先读包含这个final域的对象的引用。在这个示例程序中,若是该引用不为null,那么引用对象的final域必定已经被A线程初始化过了。

相关文章
相关标签/搜索