Java并发编程艺术系列-三、Java内存模型

三、Java内存模型

本章大致分四个部分:

  • Java内存模型的基础:主要介绍内存模型相关的基本概念;

  • Java内存模型中的顺序一致性:主要介绍重排序与顺序一致性内存模型;

  • 同步原语:主要介绍3个同步原语(synchronized、volatile和final)的内存语义及重排序规则在处理器中的实现;

  • Java内存模型的设计:主要介绍Java内存模型的设计原理,及其与处理器内存模型和顺序一致性内存模型的关系。

3.1 内存模型基础

3.1.1 并发编程的两个关键问题

在并发编程中,需要处理两个关键问题:线程之间如何通信及线程之间如何同步

3.1.1.1 通信机制

  • 共享内存

    线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。

  • 消息传递

    线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。

  • Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明

3.1.1.2 同步

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

  • 在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行

  • 在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

3.1.2 Java内存模型的抽象结构

image-20210522143226904

如果线程A与线程B之间要通信的话,必须要经历下面2个步骤。

1)线程A把本地内存A中更新过的共享变量刷新到主内存中去。

2)线程B到主内存中去读取线程A之前已更新过的共享变量。

image-20210522143553847

3.1.3 重排序分类

重排序分3种类型。

3.1.3.1 编译器优化的重排序

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

3.1.3.2 指令级并行的重排序

现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应

机器指令的执行顺序。

3.1.3.3 内存系统的重排序

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

image-20210522143946335

3.1.4 并发编程模型的分类

3.1.4.1 内存屏障类型表

image-20210522144438675

StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。

执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。

3.1.5 happensbefore

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

3.2 重排序

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

3.2.1 数据依赖性

image-20210522145219585

3.2.2 as-if-serial语义

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

但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

编译器和处理器遵从:在不改变程序执行结果的前提下,尽可能提高并行度,JMM也遵从这一目标

3.2.3 重排序对程序的影响

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

设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。

由于1和2,3和4操作没有数据依赖关系,可能发生重排序,线程B在执行操作4时, 不一定能看到线程A在操作1对共享变量a的写入。

3.4 volatile内存语义

3.4.1 特性

  • 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。

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

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

从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果:

volatile写和锁的释放有相同的内存语义;volatile读与锁的获取有相同的内存语义。

class ReorderExample {
    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)根据程序次序规则,1 happens-before 2;3 happens-before 4。

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

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

3.4.3 volatile写-读的内存语义

3.4.3.1 volatile写的内存语义

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

3.4.3.2 volatile读的内存语义

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

3.4.4 总结

如果我们把volatile写和volatile读两个步骤综合起来看的话,在读线程B读一个volatile变量后,写线程A在写这个volatile变量之前所有可见的共享变量的值都将立即变得对读线程B可见。

  • 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。

  • 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。

  • 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

3.5 锁的内存语义

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

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

3.5.3 锁内存语义的实现

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)来维护同步状态,马上我们会看到,这个volatile变量是ReentrantLock内存语义实现的关键

image-20210522155600038

3.5.3.1 ReentrantLock公平锁

  • 加锁方法lock()调用轨迹如下:

    1)ReentrantLock:lock()。

    2)FairSync:lock()。

    3)AbstractQueuedSynchronizer:acquire(int arg)。

    4)ReentrantLock:tryAcquire(int acquires)。

    第4步真正开始加锁,源码如下:可以看出,加锁方法首先读volatile变量state

    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)Sync:tryRelease(int releases)。

    在第3步真正开始释放锁,源码如下:可以看出,在释放锁的最后写volatile变量state。

    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变量后将立即变得对获取锁的线程可见。

3.5.3.2 ReentrantLock非公平锁

  • 加锁方法lock()调用轨迹如下:

    1)ReentrantLock:lock()。

    2)NonfairSync:lock()。

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

    在第3步真正开始加锁,源码如下:

    protected final boolean compareAndSetState(int expect, int update) { 
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update); 
    }
    

    编译器不会对volatile读与volatile读后面的任意内存操作重排序;

    编译器不会对volatile写与volatile写前面的任意内存操作重排序

    组合这两个条件,意味着为了同时实现volatile读和volatile写的内存语义,编译器不能对CAS与CAS前面和后面的任意内存操作重排序

  • 处理器CAS的实现

    LOCK

    1)确保对内存的读-改-写操作原子执行:锁总线、锁缓存

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

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

    上面的第2点和第3点所具有的内存屏障效果,足以同时实现volatile读和volatile写的内存语义。

    经过上面的分析,现在我们终于能明白为什么JDK文档说CAS同时具有volatile读和volatile写的内存语义了。

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

  • 公平锁和非公平锁释放时,最后都要写一个volatile变量state。

  • 公平锁获取时,首先会去读volatile变量。

  • 非公平锁获取时,首先会用CAS更新volatile变量,这个操作同时具有volatile读和volatile写的内存语义。

  • 从本文对ReentrantLock的分析可以看出,锁释放-获取的内存语义的实现至少有下面两种方式:

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

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

3.5.4 concurrent包的实现

volatile变量的读/写和CAS可以实现线程之间的通信。把这些特性整合在一起,就形成了整个concurrent包得以实现的基石

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

1)首先,声明共享变量为volatile。

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

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

AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic包中的类),这些concurrent包中的基础类都是使用这种模式来实现的,而concurrent包中的高层类又是依赖于这些基础类来实现的。从整体来看,concurrent包的实现示意图

image-20210522210955638

3.6 final域的内存语义

3.6.1 final域的重排序规则

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

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

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

image-20210522211704563

"j = 2 // 写final域" 不能与 "obj = new FinalExample()“ 重排序。

"FinalExample object = obj //读对象引用" 不能与 "int b = object.j" 重排序。

3.6.2 写final域的重排序规则

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

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

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

3.6.3 读final域的重排序规则

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

3.6.4 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 writerOne() { // 写线程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 
        }
    }
}

对于以上程序,1是对final域的写入,2是对这个final域引用的对象的成员域的写入,3是把被构造的对象的引用赋值给某个引用变量。这里除了前面提到的1不能和3重排序外,2和3也不能重排序

3.6.5 为什么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 
        } 
    } 
}

A线程执行write(), B线程调用reader(),由于1和2可能重排序,导致4读到未初始化的 final域 i

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

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

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

3.7 happens-before

happens-before是JMM最核心的概念。对应Java程序员来说,理解happens-before是理解JMM的关键。

3.7.1 JMM设计

首先,让我们来看JMM的设计意图。从JMM设计者的角度,在设计JMM时,需要考虑两个

关键因素。

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

  • 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。

由于这两个因素互相矛盾,所以JSR-133专家组在设计JMM时的核心目标就是找到一个好的平衡点:一方面,要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能地放松。下面让我们来看JSR-133是如何实现这一目标的

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

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

2)对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种重排序)。

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

1 A happens-before B;

2 B happens-before C;

3 A happens-before C。

2和3会改变程序结果,必要,JMM禁止重排序;1不会改变程序结果,不必要,JMM不做要求;

image-20210522215715581

3.7.2 happens-before的定义

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

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

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

3.7.2.1 JMM这么做的原因

程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before关系本质上和as-if-serial语义是一回事。

as-if-serial语义保证单线程内程序的执行结果不被改变,

happens-before关系保证正确同步的多线程程序的执行结果不被改变

as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提 下,尽可能地提高程序执行的并行度

3.7.3 happens-before规则

《JSR-133:Java Memory Model and Thread Specification》定义了如下happens-before规则。

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

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

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

4)传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

5)start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。

6)join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

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

3.8.1 双重检查锁定的由来

在Java程序中,有时候可能需要推迟一些高开销的对象初始化操作,并且只有在使用这些对象时才进行初始化。

此时,程序员可能会采用延迟初始化。但要正确实现线程安全的延迟初始化需要一些技巧,否则很容易出现问题。

  • 非线程安全的延迟初始化对象的示例代码

    public class UnsafeLazyInitialization { 
        private static Instance instance; 
        public static Instance getInstance() { 
            if (instance == null) // 1:A线程执行 
                instance = new Instance(); // 2:B线程执行 
            return instance; 
        } 
    }
    
  • 加同步处理

    public class SafeLazyInitialization { 
        private static Instance instance; 
        public synchronized static Instance getInstance() { 
            if (instance == null) 
                instance = new Instance(); 
            return instance; 
        } 
    }
    
  • DCL

    在早期的JVM中,synchronized(甚至是无竞争的synchronized)存在巨大的性能开销。因此,人们想出了一个“聪明”的技巧:

    双重检查锁定(Double-Checked Locking)。人们想通过双重检查锁定来降低同步的开销。

    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 
    }
    

    如上面代码所示,如果第一次检查instance不为null,那么就不需要执行下面的加锁和初始化操作。因此,可以大幅降低synchronized带来的性能开销。上面代码表面上看起来,似乎两全其美。

3.8.2 问题根源

第7行(instance=new Singleton();)编译成的字节码伪代码如下三行,其中2和3可能重排序,会导致instance不为null但未初始化

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

3.8.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,现在没问题了 
            }
        }
        return instance;
    }
}

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

JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。

基于这个特性,可以实现另一种线程安全的延迟初始化方案

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

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

posted @ 2021-06-30 16:57  杨海星  阅读(198)  评论(0编辑  收藏  举报