Java内存模型知识点小结---《深入理解Java内存模型》(程晓明)读书总结

 2019-12-10 15:45  阅读(611)
文章分类:Java Core

一、Java内存模型介绍

内存模型的作用范围:

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

重排序与可见性:

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

CPU在执行指令时一般都会使用缓存技术来提高效率,如果不同线程使用不同的缓存空间则会造成一个线程对一个共享变量的更新不能及时反映给其他线程,也就是多线程对共享变量更新的可见性问题,这个问题是非常复杂的。

Java内存模型的抽象:

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

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

20191210001261\_1.png

如果A、B两个线程要通信要经过以下两步:首先线程A将本地内存中更新过的共享变量刷新到主内存中,然后线程B到主内存中读取A之前更新过的变量。

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

重排序:

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

JMM把内存屏障分为以下四类:

屏障类型 指令示例 说明
屏障类型 指令示例 说明
LoadLoadBarriers Load1;LoadLoad;Load2 确保Load1数据的装载之前于在Load2及其所有后续装载指令
StoreStoreBarriers Store1;StoreStore;Store2 确保Store1刷新数据到内存之前与Store2及其后续存储指令
LoadStoreBarriers Load1;LoadStore;Store2 确保Load1数据装载之前于Store2及其后续存储指令
StoreLoadBarriers Store1;StoreLoad;Load2 确保Store1刷新数据到内存之前于Load2及其后续装载指令。StoreLoadBarriers会使该屏障之前的所有内存访问指令完成后才执行屏障后的指令。

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

20191210001261\_2.png

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

写后读 a=1;b=a;
写后读 a=1;b=a;
写后写 a=1;a=2;
读后写 a=b;b=a;

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

as-if-serial语义:

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

示例:

  a=1;//Ab=2;//Bc=a+b;//C
  a=1;//Ab=2;//Bc=a+b;//C

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

上述A happens-before B,B happens-before C,但并不保证A在B之前执行,只需要保证操作A对B可见(这里A操作不需要对B可见,因此可以重排)

重排序对多线程的影响:

示例:

  classDemo{   booleanflag=false;   inta=0;      public voidfun1(){       a=1;//A       flag=true;//B   }      public voidfun2(){       if(flag){//C           a=a+a;//D       }   }}
  classDemo{   booleanflag=false;   inta=0;      public voidfun1(){       a=1;//A       flag=true;//B   }      public voidfun2(){       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开始执行。那么他们在顺序一致性模型中执行效果如下:

20191210001261\_3.png

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

20191210001261\_4.png

可以看到,未同步的程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只看到一个一致的整体执行顺序。如上图,线程A和B看到的执行顺序都是B1->A1->A2->B2-A3->B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任何线程可见。

但是JMM中没有这个保证。比如当前线程写数据到本地内存中,在还没有刷新到主内存之前,这个写操作只对当前线程可见,从其他线程角度观察,可以认为这个写操作根本还没有被当前线程执行过。这种情况下,当前线程和其他线程看到的操作执行顺序将不一致。

同步程序的一致性效果:

示例:

  classSynchronizedDemo{   inta=0;   booleanflag=false;   public synchronized voidwrite(){       a=1;       flag=true;   }   public synchronized voidread(){       if(flag){           inti=a;       }   }}
  classSynchronizedDemo{   inta=0;   booleanflag=false;   public synchronized voidwrite(){       a=1;       flag=true;   }   public synchronized voidread(){       if(flag){           inti=a;       }   }}

上述代码使用同步方法,线程A先执行write()方法,释放锁后线程B获取锁并执行read()方法,执行流程如下:

20191210001261\_5.png

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

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

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

二、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读写操作前后插入内存屏障实现的。

三、锁的特性

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

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

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

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

20191210001261\_6.png

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线程之间通信现在有了以下四种方式:

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

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

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

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

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

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

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

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

四、Final 的特性

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

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

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

写final域的重排序规则

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

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

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

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

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

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

读final域的重排序规则

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

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

点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> Java内存模型知识点小结---《深入理解Java内存模型》(程晓明)读书总结

相关推荐