# 基础

# 并发编程模型的分类

并发编程中,关注的两大问题:

  1. 线程之间如何通信。
  2. 线程之间如何同步。

# 如何通信

命令式编程中,线程通信:共享内存与消息传递:

  1. 共享内存并发模型:

    1. 线程之间共享程序的公共状态。
    2. 线程之间通过写 - 读内存中的公共状态来隐式进行通信。
  2. 消息传递并发模型:

    1. 线程之间没有公共状态。
    2. 线程之间必须通过明确的发送消息来显式进行通信。

# 如何同步

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

  1. 共享内存并发模型:
    1. 同步是显式进行的。
    2. 必须显式指定某个方法或某段代码需要在线程之间互斥执行。
  2. 消息传递并发模型:
    1. 同步是隐式进行的。
    2. 消息的发送必须在消息的接收之前。

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

# Java 内存模型的抽象

Java内存模型

# 重排序

  1. 编译器优化的重排序:不改变单线程程序语义;
  2. 指令级并行的重排序:不存在数据依赖性,现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP);
  3. 内存系统的重排序:处理器使用缓存和读 / 写缓冲区,这使得加载和储存操作顺序改变。

重排序

这些重排序都有可能导致多线程程序内存可见性问题。

# 处理器重排序与内存屏障指令

Processor AProcessor B

a = 1; //A1
x = b; //A2

b = 2; //B1
y = a; //B2

初始状态:a = b = 0
处理器允许执行后得到结果:x = y = 0

处理器重排序与内存屏障指令

下面是常见处理器允许的重排序类型的列表:

ArchitectureLoad-LoadLoad-StoreStore-StoreStore-Load数据依赖
sparc-TSONNNYN
x86NNNYN
ia64YYYYN
PowerPCYYYYN

为了保证内存可见性,java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。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 会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。

# happens-before

在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 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。

happens-before 与 JMM 的关系如下图所示:

happens-before与JMM的关系

# 重排序

# 数据依赖性

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

名称代码示例说明

写后读

a = 1;
b = a;

一个变量之后,再读这个位置。

写后写

a = 1;
a = 2;

写一个变量之后,再写这个变量。

读后写

a = b;
b = 1;

读一个变量之后,再写这个变量。

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

注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间不同线程之间数据依赖性不被编译器和处理器考虑。

# as-if-serial 语义

as-if-serial 语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守 as-if-serial 语义。

as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial 语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题

# 程序顺序规则

计算圆的面积的示例代码:

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

上面的示例代码存在三个 happens- before 关系:

  1. A happens- before B;
  2. B happens- before C;
  3. A happens- before C;

这里 A happens- before B,但实际执行时 B 却可以排在 A 之前执行。

因为,JMM 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前;

,这里操作 A 的执行结果不需要对操作 B 可见;而且重排序操作 A 和操作 B 后的执行结果,与操作 A 和操作 B 按 happens- before 顺序执行的结果一致。

所以,JMM 允许这种重排序。

A 与 B 操作只是赋值操作,并未有数据依赖问题。

在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能的开发并行度。

# 重排序对多线程的影响

示例代码:

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 () 方法。线程 B 在执行操作 4 时,能否看到线程 A 在操作 1 对共享变量 a 的写入?

答案是:不一定能看到。

由于,1 和 2,3 和 4 没有数据依赖性,所以编译器和处理器可以对 1 和 2,3 和 4 进行重排序,以下是这两种情况的重排序导致的结果。

  • 1 和 2 重排序,即执行顺序为:
    A:2 ---> 1 (线程 A 执行顺序,重排序)
    B:3 ---> 4 (线程 B 执行顺序)
    t:2 ---> 3 ----> 4 ---> 1 (时间线上的执行顺序)

这就导致操作 2 先执行 flag = true,线程 B 的条件为 true,然后执行了操作 4 操作,而操作 1 还没执行,a 没有初始化,这就导致多线程的语义被重排序破坏了。

  • 3 和 4 重排序,即执行顺序为:
    A:1 ---> 2 (线程 A 执行顺序)
    B:3 ---> 4 (线程 B 执行顺序,重排序)
    t:1 ---> 4 ----> 3 ---> 2 (时间线上的执行顺序)

在程序中,操作 3 和操作 4 存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。

以处理器的猜测执行为例,执行线程 B 的处理器可以提前读取并计算 a * a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作 3 的条件判断为真时,就把该计算结果写入变量 i 中。

这也破坏了多线程的语义。

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是 as-if-serial 语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

# 数据竞争与顺序一致性保证

当程序未正确同步时,就会存在数据竞争。java 内存模型规范对数据竞争的定义如下:

  1. 在一个线程中写一个变量;
  2. 在另一个线程读一个变量;
  3. 而且写和读没有通过同步来排序。

如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。

JMM 对正确同步的多线程程序的内存一致性做了如下保证:

如果程序是正确同步的,程序的执行将具有顺序一致性(sequentially consistent)–即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同

# 顺序一致性内存模型

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

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

sequentially_consistent_memory_model

假设有两个线程 A 和 B 并发执行。其中 A 线程有三个操作,它们在程序中的顺序是:A1->A2->A3。B 线程也有三个操作,它们在程序中的顺序是:B1->B2->B3。

  • 假设这两个线程使用监视器锁来正确同步:A 线程的三个操作执行后释放监视器锁,随后 B 线程获取同一个监视器锁。

sync

  • 假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型中的执行示意图:

un_sync

# 同步程序的顺序一致性效果

class SynchronizedExample {
    int a = 0;
    boolean flag = false;
    
    public synchronized void writer() {
        a = 1;
        flag = true;
    }
    public synchronized void reader() {
        if (flag) {
            int i = a;
            // ……
        }
    }
}

synchronized_example

# 未同步程序的执行特性

  1. JVM 在堆上分配对象时,首先会清零内存空间,然后才会在上面分配对象(JVM 内部会同步这两个操作)。因此,在以清零的内存空间(pre-zeroed memory)分配对象时,域的默认初始化已经完成了。

  2. 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而 JMM 不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)。

  3. 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。

  4. JMM 不保证对 64 位的 long 型和 double 型变量的读 / 写操作具有原子性,而顺序一致性模型保证对所有的内存读 / 写操作都具有原子性。

在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务 (bus transaction)。总线事务包括读事务 (read transaction) 和写事务 (write transaction)。

bus_transaction

在一些 32 位的处理器上,如果要求对 64 位数据的写操作具有原子性,会有比较大的开销。为了照顾这种处理器,java 语言规范鼓励但不强求 JVM 对 64 位的 long 型变量和 double 型变量的写具有原子性。

当 JVM 在这种处理器上运行时,会把一个 64 位 long/double 型变量的写操作拆分为两个 32 位的写操作来执行。这两个 32 位的写操作可能会被分配到不同的总线事务中执行,此时对这个 64 位变量的写将不具有原子性。

without_atomic

注意,在 JSR -133 之前的旧内存模型中,一个 64 位 long/double 型变量的读 / 写操作可以被拆分为两个 32 位的读 / 写操作来执行。

从 JSR -133 内存模型开始 (即从 JDK5 开始), 仅仅只允许把一个 64 位 long/double 型变量的写操作拆分为两个 32 位的写操作来执行,任意的读操作在 JSR-133 中都必须具有原子性 (即任意读操作必须要在单个读事务中执行)。

# volatile

# volatile 的特性

理解 volatile 特性的一个好方法是把对 volatile 变量的单个读 / 写,看成是使用同一个锁对这些单个读 / 写操作做了同步。

class VolatileFeaturesExample {
    // 使用 volatile 声明 64 位的 long 型变量
    volatile long vl = 0L;
    
    public void set(long l) {
        // 单个 volatile 变量的写
        vl = l;
    }
    public void getAndIncrement () {
        // 复合 (多个) volatile 变量的读 / 写
        vl++;
    }
    public long get() {
        // 单个 volatile 变量的读
        return vl;
    }
}

等价于下面 synchronized 同步方式:

class VolatileFeaturesExample {
    long vl = 0L;
    public synchronized void set(long l) {
        // 64 位的 long 型普通变量
        // 对单个的普通变量的写用同一个锁同步
        vl = l;
    }
    public void getAndIncrement () {
        long temp = get();
        // 普通方法调用
        // 调用已同步的读方法
        temp += 1L; // 普通写操作
        set(temp); // 调用已同步的写方法
    }
    public synchronized long get() {
        // 对单个的普通变量的读用同一个锁同步
        return vl;
    }
}

简而言之,volatile 变量自身具有下列特性:

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

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

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 关系可以分为两类:

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

volatile happens before

# volatile 写 - 读的内存语义

volatile 写的内存语义如下:

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

volatile write

volatile 读的内存语义如下:

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

volatile read

下面对 volatile 写和 volatile 读的内存语义做个总结:

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

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

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

# volatile 内存语义的实现

下面是 JMM 针对编译器制定的 volatile 重排序规则表:

是否能重排序第二个操作
第一个操作普通读 / 写volatile 读volatile 写
普通读 / 写NO
volatile 读NONONO
volatile 写NONO

从上表我们可以看出:

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

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

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

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

volatile_write's_memory_barriers

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

volatile_read's_memory_barriers

编译器可以根据具体情况省略不必要的屏障。
下面我们通过具体的示例代码来说明:

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 写
    }
    // ...
    // 其他方法
}

编译器在生成字节码时保守的优化

memory_barriers_optimize

在 x86 处理器平台可以优化成:

memory_barriers_x86_optimize

# JSR-133 为什么要增强 volatile 的内存语义

在 JSR-133 之前的旧 Java 内存模型中,虽然不允许 volatile 变量之间重排序,但旧的 Java 内存模型允许 volatile 变量与普通变量重排序。

由于 volatile 仅仅保证对单个 volatile 变量的读 / 写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。

在功能上,锁比 volatile 更强大;在可伸缩性和执行性能上,volatile 更有优势。

#

# 锁的释放 - 获取建立的 happens before 关系

锁释放 - 获取的示例代码:

class Monitor Example {
    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 规则,这个过程包含的 happens before 关系可以分为两类:

  1. 根据程序次序规则,1 happens before 2, 2 happens before 3; 4 happens
    before 5, 5 happens before 6。
  2. 根据监视器锁规则,3 happens before 4。
  3. 根据 happens before 的传递性,2 happens before 5。

上述 happens before 关系的图形化表现形式如下:

lock_happens_before

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

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

release_lock

当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须要从主内存中去读取共享变量。下面是锁获取的状态示意图:

get_lock

可以看出:锁释放与 volatile 写有相同的内存语义;锁获取与 volatile 读有相同的内存语义

  1. 线程 A 释放一个锁,实质上是线程 A 向接下来将要获取这个锁的某个线程发出
    了 (线程 A 对共享变量所做修改的) 消息。

  2. 线程 B 获取一个锁,实质上是线程 B 接收了之前某个线程发出的 (在释放这个
    锁之前对共享变量所做修改的) 消息。

  3. 线程 A 释放锁,随后线程 B 获取这个锁,这个过程实质上是线程 A 通过主内存
    向线程 B 发送消息。

# 锁内存语义的实现

# final

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

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