Java并发编程:Java内存模型

Posted KiteRunner24

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java并发编程:Java内存模型相关的知识,希望对你有一定的参考价值。

一、Java内存模型基础

1. 两个关键问题

  • 线程之间如何通信;
  • 线程之间如何同步。

线程之间的通信机制:共享内存+消息传递

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

Java并发采用的是共享内存模型,Java线程之间的通信总是隐式进行的。

2. Java内存模型的抽象结构

Java线程之间的通信由Java内存模型控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。

JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读写共享变量的副本。

从上图可以看出,如果线程A和线程B之间要通信的话,必须要经历两个步骤:

  1. 线程A把本地内存A中更新过的共享变量刷新到主内存中去;
  2. 线程B到主内存中去读取线程A之前已经更新过的共享变量。

从整体上看,上述两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。

3. 指令序列的重排序

为了提高性能,编译器和处理器常常会对指令进行重排序。

重排序包括以下三种类型:

  1. 编译器优化的重排序
  2. 指令级并行的重排序——指令级并行技术。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序

对于编译器重排序,JMM的编译器重排序规则会禁止特定类型的编译器重排序。

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

4. 并发编程模型的分类

处理器使用写缓冲区临时保存向内存写入的数据。

示例:

其内部执行过程如下所示:

这里,处理器A和处理器B可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓冲区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到x=y=0的结果。

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

5. happens-before

Java JSR-133使用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与JMM的关系:

二、指令重排序

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

1. 数据依赖性

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

对于数据依赖性,主要分为三种类型:

对于上述三种情况,只要重排序两个操作的操作顺序,程序的执行结果就会被改变。

2. as-if-serial语义

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

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

例如:

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

对于上述操作的数据依赖关系,如下所示:

其存在两种执行顺序,如下所示:

as-if-serial语义使单线程程序员无需担心重排序是否会干扰到其正常运行,也无需担心内存可见性问题。

3. 重排序对多线程的影响

示例代码:

class RecordExample 
    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、数据竞争与顺序一致性

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

  • 在一个线程写一个变量;
  • 在另一个线程读同一个变量;
  • 读和写没有通过同步来排序。

当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。

顺序一致性(Sequentially Consistent)——程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。

2、顺序一致性内存模型

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

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

  • 所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

顺序一致性内存模型为程序员提供的视图:

当多个线程并发执行时,上图的开关装置能把所有线程的所有内存读写操作串行化,即在顺序一致性模型中,所有操作之间具有全序关系。

举例说明:A和B两个线程。

一种执行过程(同步):

另一种执行过程(非同步):

3. 同步程序的顺序一致性效果

示例程序:

class SynchronizeExample 
    int a = 0;
    boolean flag = false;
    public synchronized void writer()     // 获取锁
        a = 1;
        flag = true;
                                           // 释放锁
    public synchronized void reader()      // 获取锁
        if (flag) 
            int i = a;
            //...
        
                                            // 释放锁

两个内存模型中的执行顺序:

JMM在具体实现上的基本方针:在不改变(正确同步的)程序执行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门。

未同步程序在两个模型中的执行特性存在如下几个差异:

  1. 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行(重排序)。
  2. 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。
  3. JMM不保证对64位的long型和double型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读写操作具有原子性。

总线仲裁 –> 总线事务!!!

四、volatile的内存语义

1、volatile的特性

volatile变量具有以下特性:

  • 可见性。

  • 原子性。对任意单个volatile变量的读写具有原子性,但类似volatile++这种复合操作不具有原子性。

2、volatile读写建立的happens-before关系

volatile对于线程的内存可见性的影响比volatile自身的特性更为重要。

从内存语义上来说,volatile的写读与锁的释放获取有相同的内存效果。

请看下面使用volatile变量的示例代码:

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
            ...
        
    

假设线程A执行writer()方法之后,线程B执行reader()方法。根据happens-before规则,该过程建立的happens-before关系可以分为3类:

  • 根据程序次序规则:1 happens-before 2; 3 happens-before 4。
  • 根据volatile规则:2 happens-before 3。
  • 根据happens-before的传递性规则:1 happens-before 4。

因此,上述关系的图形化表现形式如下:

在上图中,每一个箭头链接的两个节点,代表了一个happens-before关系。黑色箭头表示程序顺序规则;橙色箭头表示volatile规则;蓝色箭头表示组合这些规则后提供的happens-before保证。

3、volatile写读的内存语义

volatile写的内存语义如下:

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

volatile读的内存语义如下:

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

总结一下:

  • 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了其对共享变量所做修改的消息。
  • 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的在写这个volatile变量之前对共享变量所做修改的消息。
  • 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

4. volatile内存语义的实现

为了实现volatile内存语义,JMM会限制编译器重排序和处理器重排序。

规则表:

从上表,我们可以看出:

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。其确保了volatile写之前的操作不会被编译器重排序到volatile写之后

  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。其确保了volatile读之后的操作不会被编译器重排序到volatile读之前

  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

实现方式:内存屏障。

具体内存屏障插入策略如下:

  • 在每个volatile写操作前插入一个StoreStore屏障。
  • 在每个volatile写操作后插入一个StoreLoad屏障。
  • 在每个volatile读操作后插入一个LoadLoad屏障。
  • 在每个volatile读操作后插入一个LoadStore屏障。

    保守策略下,volatile写插入内存屏障后生成的指令序列示意图:

StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。volatile写之后的StoreLoad屏障的作用是避免volatile写与后面可能有的volatile读写操作重排序(保守策略)。

保守策略下,volatile读插入内存屏障后生成的指令序列示意图:

LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的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写
    
    ... //写

对应的指令序列示意图如下:

5. 为什么增强volatile的内存语义

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
            ...
        
    

在旧的内存模型中,可以使用指令重排序,因此,时序图如下所示:

结果:读线程B执行4时,不一定能看到线程A在执行1时对共享变量的修改。

volatile内存语义增强

volatile严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写读和锁的释放获取具有相同的内存语义。

不过,要很好地利用volatile来完成锁机制下的并发过程,是十分困难的,一定要谨慎。

五、锁的内存语义

1、锁的释放获取所建立的happens-before关系

锁是Java并发编程中最重要的同步机制。

示例:

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规则,该过程包含三类关系:

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

  • 监视器锁规则:3 happens-before 4。

  • happens-before的传递性:2 happens-before 5。

示意图如下:

在上图中,每一个箭头链接的两个节点,代表了一个happens-before关系。黑色箭头表示程序顺序规则;橙色箭头表示监视器锁规则;蓝色箭头表示组合这些规则后提供的happens-before保证。

2、锁的释放和获取的内存语义

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

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

总结一下:

  • 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了线程A对共享变量所做修改的消息。
  • 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的在释放这个锁之前对共享变量所做修改的消息。
  • 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程b发送消息。

3、锁内存语义的实现

在这里,我们借助ReentrantLock的源码,来分析锁内存语义的具体实现机制。

示例代码:

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同步器框架AbstractQueuedSychronizer(AQS)

基本类图:

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

公平锁的加锁过程

Step1: ReentrantLock.lock();
Step2: FairSync.lock();
Step3: AbstractQueuedSynchronizer.acquire(int arg);
Step4: ReentrantLock.tryAcquire(int acquires);

第四步为核心,如下:

protected final boolean tryAcquire(int acquires) 
    final Thread current = Thread.currentThread();
    int c = getState();          //获取锁的开始,首先读取volatile变量state
    if (c == 0) 
        if (isFirst(current) && 
            compareAndSetState(0, acquires)) 
             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。

公平锁的解锁过程

Step1: ReentrantLock.unlock();
Step2: AbstractQueuedSynchronizer.release(int arg);
Step3: 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变量。根据volatile的happens-before规则,释放锁的线程在写volatile变量之前可见的共享变量,在获取锁的线程读取同一个volatile变量后将立即变得对获取锁的线程可见。

4、concurrent包的实现

Java线程之间的通信方式:

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

六、final域的内存语义

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

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

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

七、happens-before

happens-before是JMM最核心的概念。

1、JMM的设计

在设计JMM时,需要考虑两个关键因素:

  • 程序员对内存模型的使用。程序员希望基于一个强内存模型来编写代码。

  • 编译器和处理器对内存模型的实现。编译器和处理器希望实现一个弱内存模型。

由于上述两个因素的互相矛盾,因此需要找到一个好的平衡点:一方面,要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能地放松。

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

  • 会改变程序执行结果的重排序;

  • 不会改变程序执行结果的重排序。

JMM对于这两种不同性质的重排序,采取了不同的策略,如下:

  • 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。

  • 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求。

如下所示:

如上所示,可以得出以下两点:

  • JMM向程序员提供的happens-before规则能满足程序员的需求。

  • JMM对编译器和处理器的束缚已经尽可能少。

基本原则:只要不改变程序的执行结果,编译器和处理器怎么优化都行。

2、happens-before的定义

定义如下:

  • 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。 —— JMM对程序员的承诺

  • 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。只要结果一致,重排序就不非法。 —— JMM对编译器和处理器重排序的约束原则

3、happens-before规则

  • 程序次序规则:一段代码在单线程中执行的结果是有序的。注意是执行结果,因为虚拟机、处理器会对指令进行重排序。虽然重排序了,但是并不会影响程序的执行结果,所以程序最终执行的结果与顺序执行的结果是一致的。故而这个规则只对单线程有效,在多线程环境下无法保证正确性。

  • 锁定规则:这个规则比较好理解,无论是在单线程环境还是多线程环境,一个锁处于被锁定状态,那么必须先执行unlock操作后面才能进行lock操作。

  • volatile变量规则:这是一条比较重要的规则,它标志着volatile保证了线程可见性。通俗点讲就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作一定是happens-before读操作的。

  • 传递规则:提现了happens-before原则具有传递性,即A happens-before B , B happens-before C,那么A happens-before C。

  • 线程启动规则:假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行后确保对线程B可见。

  • 线程终结规则:假定线程A在执行的过程中,通过制定ThreadB.join()等待线程B终止,那么线程B在终止之前对共享变量的修改在线程A等待返回后可见。

线程启动规则示例:

线程终结规则示例:

八、双重检查锁定与延迟初始化

1、双重检查锁定

写一个线程安全的单例模式:

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
                                                      //10
        return instance;
    

问题:

在线程执行到第四行,代码读取到instance不为null时,instance引用的对象有可能没有完成初始化。

2、问题的根源

示例代码第7行instance = new Instance();创建对象,其可分解为:

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

上面2和3之间可能会被重排序。2和3之间重排序之后的执行时序(并不违反JMM规则)如下:

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

上述过程多线程下并发执行的情况:

单线程的执行时序图:

多线程的执行时序图:

所以对于上述多线程情况,可以知道,线程B访问目标对象时,目标对象并未进行初始化。此处就会出现问题。

如何解决?

两种方法:

  • 不允许2和3重排序;
  • 允许2和3重排序,但不允许其他线程看到这个重排序。

3、基于volatile的解决方案

public class SafeDoubleCheckedLocking 
    private volatile static Instance instance;
    public static Instance getInstance() 
        if (instance == null) 
            synchronized (SafeDoubleCheckedLocking.class) 
                if (instance == null) 
                    instance = new Instance();   //instance为volatile,现在没有问题了
                
            
        
    

当声明对象的引用为volatile时,2和3之间的重排序在多线程环境中将会被禁止。

4、基于类初始化的解决方案

JVM在类的初始化阶段,会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁,这个锁可以同步多个线程多同一个类的初始化。

public class InstanceFactory 
    private static class InstanceHolder 
        public static Instance instance = new Instance();
    
    public static Instance getInstance() 
        return InstanceHolder.instance;    //这里将触发InstancHolder类被初始化
    

上述过程如下:

该方案的实质是:允许2和3重排序,但不允许非构造线程看到这个重排序。

附加:

一个类或接口被初始化的5种情况:
1. T是一个类,而且一个T类型的实例被创建;
2. T是一个类,且T中声明的一个静态方法被调用;
3. T中声明的一个静态字段被赋值;
4. T中声明的一个静态字段被使用,而且这个字段不是一个常量字段;
5. T是一个顶级类,而且一个断言语句嵌套在T内部被执行。

类初始化的处理过程的五个阶段:

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

  • 第二阶段:线程A执行类的初始化,同时线程B在初始化锁对应的condition上等待。

  • 第三阶段:线程A设置state=initialized,然后唤醒在condition中等待的所有线程。

  • 第四阶段:线程B结束类的初始化处理。

  • 第五阶段:线程C执行类的初始化的处理。

静态内部类的加载过程:静态内部类的加载不需要依附外部类,在使用时才加载

九、Java内存模型综述

十、小结

本文对Java内存模型做了比较全面的解读。

以上是关于Java并发编程:Java内存模型的主要内容,如果未能解决你的问题,请参考以下文章

Java高并发编程实战3,Java内存模型与Java对象结构

小白也能看懂的Java内存模型

从根源上解析 Java volatile 关键字的实现

Java并发编程实战基础概要

深入理解 Java 内存模型

Java并发编程实战基础概要