《Java并发编程的艺术》第三章 Java 内存模型

第三章 Java 内存模型

主要包含4个部分:

  1. Java 内存模型的基础
  2. Java 内存模型中的顺序一致性,即重排序和顺序一致性内存模型
  3. 同步原语,3个同步原语的内存语义和重排序规则在处理器中的实现
  4. Java 内存模型的设计

3.1 Java 内存模型的基础

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

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

通信,是指线程间以何种机制交换信息,如 共享内存消息传递

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

消息传递:在此模型中,线程间没有公共状态,只能通过发送消息来显式进行通信。

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

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

消息传递:在此模型中,由于消息发送必须在消息接收之前,因此同步是隐式的。

3.1.2 Java 内存模型的抽象结构

在 Java 中,所有实例域、静态域、数组元素都存储在堆内存中,堆内存在线程之间共享。局部变量、方法定义参数、异常处理参数不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。

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

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

本地内存涵盖了缓存、写缓冲区、寄存器以及其他硬件和编译器优化。

Java 内存模型的抽象结构示意图

线程A与B之间通信,需要经历如下两个步骤:

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

总体来看,这两步的实质是线程A向线程B通信,且这个通信过程必须要经过主内存。

3.1.3 指令重排序

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。

指令重排的顺序:源代码 –> 1.编译器优化重排序 –> 2. 指令集并行重排序 –> 3. 内存系统重排序 –> 最终执行的指令序列

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

3.1.4 Happens-Before

happens-before 用来阐述操作之间的内存可见性,在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系。这两个操作可以在不同的线程中。

3.2 重排序

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

3.2.1 数据依赖性

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

编译器和处理器在重排序时,会遵守数据依赖性,不会改变存在数据依赖性关系的两个操作的执行顺序。

3.2.2 as-if-serial

as-if-serial:不管怎么重排序(提高并行度),程序的执行结果不能改变。

3.2.3 结论

在不改变程序结果的前提下,尽可能提高并行度。

3.3 顺序一致性

顺序一致性内存模型是一个理论参考模型,在设计时,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。

顺序一致性:即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。

3.3.1 顺序一致性内存模型

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

  1. 一个线程中的所有操作必须按照程序的顺序来执行
  2. (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

3.4 volatile 的内存语义

当声明共享变量为 volatile 后,可以看成是使用同一个锁对这些单个读写操作做了同步。

volatile 具有以下特性:

  1. 可见性。对一个volatile 变量的读,总是能看到对这个volatile 变量最后的写入。
  2. 原子性。对任意单个 volatile 变量的读写具有原子性,但类似于 volatile++ 这种复合操作不具有原子性。

3.4.1 volatile 的读写

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

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

3.4.2 volatile 内存语义

基于保守策略的 JMM 内存屏障插入策略:

  1. 在每个 volatile 写操作的前面插入一个 StoreStore 屏障
    1. StoreStore 屏障,将保障上面所有的普通写,在volatile 写之前刷新到主内存
  2. 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障
    1. StoreLoad 屏障,避免volatile写与后面可能有的 volatile 读写操作重排序
  3. 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障
    1. LoadLoad 屏障,用来禁止处理器把上面的 volatile 读与下面的普通读重排序
  4. 在每个 volatile 读操作的后面插入一个 LoadStore 屏障
    1. LoadStore 屏障,用来禁止处理器把上面的 volatile 读与下面的普通写重排序

volatile 仅能保证对单个 volatile 变量的读写具有原子性,而锁的互斥执行能确保整个临界区代码的执行具有原子性。在功能上,锁比 volatile 更强大;在可伸缩性和执行性能上,volatile 更有优势。

3.5 锁的内存语义

锁是 Java 并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。

3.5.1 锁的获取与释放

class MonitorExample{
    int a = 0;

    // 根据程序次序规则,1 happens-before 2, 2 happens-before 3 ...
    public synchronized void writer(){  // 1
        a++;                            // 2
    }                                    // 3

    // 根据监视器锁规则,3 happens-before 4
    public synchronized void reader(){    // 4
        int i = a;                        // 5  根据happens-before 的传递性,2 happens-before 5
    }                                    // 6

    public static void main(String[] args){
        MonitorExample me = new MonitorExample();

        new Thread(() => {
            me.writer();
        }).run(); // 当线程释放锁时,JMM 会把线程对应的本地内存中的共享变量刷新到主内存中

        new Thread(() => { // 当线程获取锁时,JMM 会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量
            me.reader();
        }).run();
    }
}

3.5.2 锁的内存语义

以 ReentrantLock 为例,分为公平锁和非公平锁。

公平锁

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

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

在获取锁时,会首先读 volatile 变量 state。

解锁方法 unlock()调用轨迹:

  1. ReentrantLock:unlock()
  2. AbstractQueuedSynchronizer:release(int arg)
  3. Sync: tryRelease(int releases)

在释放锁的最后,会写 volatile 变量 state

非公平锁

加锁:

  1. ReentrantLock: lock()
  2. NonfairSync: lock()
  3. AbstractQueuedSynchronizer: compareAndSetState(int expect,int update)

第三步时,会以原子操作的方式更新 state 变量。而 CAS 同时具有 volatile 读和 volatile 写的内存语义。即编译器不会对 CAS 与 CAS 前面和后面的任意内存操作重排序。

解锁同加锁,以CAS 操作 state。

公平锁与非公平锁的区别

公平锁:加锁前先检查是否有排队等待的线程,优先排队等待的线程,FIFO(先入先出)

非公平锁:加锁时不考虑排队等待问题,直接尝试获取锁,获取不到自动到队尾等待

3.5.4 concurrent 包的实现

一个通用化的实现方式:

  1. 首先,声明共享变量为 volatile
  2. 然后,使用 CAS 的原子条件更新来实现线程之间的同步
  3. 同时,配合以 volatile 的读写和 CAS 具有的 volatile 读写的内存语义来实现线程之间的通信。

AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic 包中的类),这些类都是使用这种模式来实现的。

concurrent包的实现示意图

3.6 happens-before 的定义

  1. 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,且第一个操作的执行顺序在第二个操作之前
  2. 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须按照 happens-before 关系指定的顺序来执行。但是JMM 会保证执行的结果一致性

3.6.1 happens-before 规则

  1. 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作
  2. 监视器锁规则:对一个锁的解锁,happens-before 于任意后续对这个 volatile 域的读
  3. volatile 变量规则,对一个 volatile域的写,happens-before 于任意后续对这个volatile 域的读
  4. 传递性:A happens-before B,B happens-before C => A happens-before C
  5. start() 规则:如果线程A执行 ThreadB.start() ,那么A线程的 ThreadB.start() 操作 happens-before于线程B中的任意操作
  6. join() 规则:如果线程A执行ThreadB.join() 并成功返回,那么线程B中的任意操作 happens-before 于线程A 从ThreadB.join() 操作成功返回

3.7 双重检查锁定

第一种的同步实例化方法,当多个线程同时调用该方法时,会导致加锁和解锁的操作频发,带来性能的问题。

public class SafeLazyInit{
    private static Instance instance;

    // 由于 getInstance() 方法做了同步处理,synchronized 将导致性能开销。
    public synchronized static Instance getInstance(){
        if(instance == null)
            instance = new Instance();
        return instance;
    }
}

第二种使用双重检查机制,在第一次检查通过后,再进行加锁。但是由于 JMM 的重排序存在,执行结果可能并不如预期一致。

// 使用双重锁机制,来降低同步的开销。
public class DoubleCheckLocking{
    private static Instance instance;

    public static Instance getInstance(){
        // 如果第一次检查instance 不为 null,那么就不需要执行下面的加锁和解锁操作,可以大幅降低synchronized 带来的性能开销
        if(instance == null){
            synchronized (DoubleCheckLocking.class){
                if(instance == null)
                    instance = new Instance(); // 但是,这里存在问题
            }
        }
        return instance;
    }
}

instance = new Instance() 应该分为具体的三步:1. 分配内存空间 2. 初始化对象 3. 将 instance 指向该内存空间 ,第二步和第三步之间可能出现指令重排。即存在instance尚未初始化,但是分配了内存空间的多线程问题。

第三种,基于 volatile 解决方案,将声明对象的引用设置为 volatile 后,代码中的指令重排在多线程环境中将被禁止。

public class DoubleCheckLocking{
    private volatile static Instance instance;

    public static Instance getInstance(){
        if(instance == null){
            synchronized (DoubleCheckLocking.class){
                if(instance == null)
                    instance = new Instance(); 
            }
        }
        return instance;
    }
}
文章作者: koral
文章链接: http://luokaiii.github.io/2019/06/12/读书笔记/《Java并发编程的艺术》/3.Java内存模型/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自