Java 并发编程艺术 #1 JVM 层面

何言 2022年02月27日 457次浏览

以下内容为 《Java 并发编程艺术》中 JVM 层面的并发笔记。
主要为 前三章内容

Java 对象头

image20220227165851498.png

其中 Mark Work 用于存储对象 HashCode、分代年龄和锁标记位,根据锁的不同状态,会存储不同的数据,以下是 32 位 虚拟机 在不同状态 Mark Work 存储的数据

image20220227170054904.png

全局安全点

线程执行到某些指令是,会更新当前线程的调用栈,持有关系等数据或者判断是否需要挂起,称为全局安全点,也就是说,当线程执行到全局安全点时,需要更新自己的执行数据,同时判断挂起标记。

全局安全点的作用:在 GC 等某些情况需要外界强制挂起线程(GC 需要 STW)的时候,为了避免一些状态的不确定性,外界需要将挂起标记设置为挂起,然后等线程执行到全局安全点的时候主动挂起,然后判断线程当前的执行情况进行一些判断,如果通过可达性分析进行 GC 或者判断锁冲突等情况。

全局安全点过多,会导致执行的时候需要多次判断挂起标记和记录信息,效率过低,全局安全点过少会导致当外界需要挂起线程时,等待过久。

对于 HotSpot 虚拟机来说,执行到以下代码时的一瞬间 称为全局安全点:(一般无特别说明可认为以下地方会有全局安全点)

  1. 方法调用
  2. 异常捕获
  3. 每次循环的末尾

锁升级与对比

这里指的是 syncchronized 的实现,如果用 JUC 中的包,则需要看 JUC 里的具体代码。

偏向锁

HotSpot 作者研究发现大多数情况下同一个锁会被同一个线程多次获取,为了优化效率,引入偏向锁。

当线程尝试获取锁时,如果锁为可偏向状态,则会直接在 Mark Word 中的 HashCode 部分记录当前线程 ID,同时线程也会在栈中加入锁记录。之后每次获取锁,如果当前锁已经偏向该线程,则不用使用 CAS 操作更新数据,直接获取锁(但是线程本身还是要更新锁记录的)。

当出现冲突的时候,例如锁已经偏向 A,而此时 B 尝试获取锁,则会 挂起 AB 线程(A 线程为外界挂起,需要等待 A 执行到全局安全点),然后根据 A 线程的锁记录判断当前 A 是否在临界区。

  • 如果当前 A 处于临界区,则偏向锁膨胀为轻量级锁,恢复 锁 的 MarkWord 数据,并且走获取轻量级锁的逻辑将锁分配给 A (将 A 中的栈中锁记录替换为 MarkWord 数据,同时使用 CAS 将锁对象的 MarkWord 更新为 A 线程 ID,根据 轻量级锁规则如果更新失败则膨胀为 重量级锁 ),修改好后恢复 AB 线程,正常走 轻量级锁的逻辑 。
  • 如果当前 A 不处于临界区,则偏向锁会重偏向,偏向 B,因为此时 A 已经不持有锁(锁记录中没有),因此 A 不需要进行任何处理,而偏向 B 会走偏向锁加锁逻辑,锁对象 Mark Word 中 HashCode 部分会记录 B 线程的 ID,同时更新 B线程的锁记录。修改好后恢复 AB 线程,正常走偏向锁逻辑。

偏向锁一旦膨胀为轻量级锁,则不会再回到偏向锁,同时如果锁对象计算过 HashCode,也会变得不可偏向。

简单地说,偏向锁的设置对于线程而言是无感知的,对于线程来说,获取锁和释放锁,都需要根据成功与否更新锁记录,而偏向锁只是相对于轻量级锁省去 cas 更新的操作,如果 A 获取 了偏向锁,执行完临界区后释放锁,A 线程的栈里就没有这个锁的记录,但是锁对象依然偏向 A,当 A 下一次获取锁时,如果偏向自己,则还是正常将锁记录入栈,省去 cas 更新对象头的操作。

如果偏向锁出现冲突,则会膨胀为 轻量级锁,在外界挂起线程 A 后偏向锁更新为轻量级锁对于线程 A 是无感知的(只是栈中的锁记录可能变为 Mark Work),挂起时 A 获取轻量级锁还是需要进行 CAS 操作,则此时偏向锁对于整体而言是负优化。

轻量级锁

获取锁时,会将锁对象中 Mark Word 数据加进线程栈中的锁记录,然后通过 CAS 操作将 Mark Word 数据更新为 线程 ID,如果失败则自旋获取 。

之后 线程 会执行临界区代码,在释放锁时,线程栈顶必须为锁记录,里面有锁原本的 MarkWord 数据,则使用 CAS 操作将锁对象头中的 线程 ID 更新为 原本的 的 Mark Word 数据,这一步如果失败,则会直接膨胀为 重量级锁,不会有自旋。

重量级锁

每次都需要获取锁,获取到则执行临界区代码,获取不到则挂起。当获取锁的的线程执行完临界区代码释放锁时,会唤醒所有等待的线程,这些现场会重新尝试获取锁,失败则重新挂起。(旧版 JDK 的模型,新版 JDK 加入了自旋或者阻塞队列等优化方案防止线程不断挂起和恢复)

原子操作

原子操作指不会被打断的操作,要么全做成功,要么失败放弃。

可以使用两种方式进行原子操作:

循环 CAS

CPU 会提供 CAS的原子指令,而使用 该指令可以做到原子操作。线程可以循环对共享变量进行 CAS 操作直到成功,此时这些 CAS 操作是原子操作。

这种实现方式有三大问题:

  1. ABA 问题,当 CAS 的共享变量由 A 变为 B,再从 B 变为 A,则有可能新的 CAS 操作会判断值没有更改过。例如常见的乐观锁,线程 A 的在进入临界区时使用 CAS 将共享变量由原值变为 A,在出临界区时需要使用 CAS 将共享变量由 A 变为原值,如果成功则判断没有冲突。如果线程 A 在临界区时线程 B 也走类似逻辑,将共享变量由 A 变为 B,在临界区结束后将 B 变为 A,则对于 线程 B来说没有出现冲突,如果 线程 B 临界区代码较少,执行较快,而线程 A 出临界区的 CAS 操作也会成功。此时两线程的临界区有一部分同时执行,已经出现冲突,按照乐观锁逻辑,线程 A 在出临界区时需要检查到期间出现了冲突。但是因为此时共享变量仍然是 A,因此会给出误判。该问题可以通过规定版本号解决。
  2. 循环开销大,如果竞争比较激烈,CAS 一直不成功,则循环会白白占用大量资源。
  3. 只能保证单个共享变量的原子操作,CAS 只能保证对 CAS 的目标变量进行的操作为原子操作,如果使用两个变量,则彼此之前可能会出现指令重排导致的不一致问题。(但可以在进入和走出临界区时通过 CAS 操作来更新共享变量的标记来达到代码块的原子操作,实际上就是方式二的加锁逻辑)

加锁

加锁最简单直接,同一个锁对象的所有临界区彼此之间都是原子操作(只是同个锁的临界区彼此之间,如果 A 获取 L 锁对变量 V 进行读写,而 B 获取 L1 锁对 变量 V 进行读写,此时这两个操作就彼此不是原子操作,可能会出现指令重排)

实际上,加锁在 JVM 层面也是通过循环 CAS 实现,除了偏向锁,其他锁在加锁时通过CAS 获取锁,执行完临界区代码后也使用 CAS 操作释放锁,因此实际上保证原子操作只有循环 CAS 一种方式,而锁是 jvm 为了方便开发者提供的一种模板循环 CAS 的方式,同时加入了挂起等操作解决问题 2 自旋开销大的问题。

Java 内存模型 JMM

并发编程的两个关键问题:线程通讯与线程同步

线程同步为控制线程彼此的执行顺序,线程通讯为线程对彼此的执行代码有感知。

线程之间的通信机制有两种:共享内存和消息传递

对于共享内存模型,线程通讯是隐式,线程同步是显式

对于消息传递模型则相反

消息传递模型中线程同步需要隐式执行,一般为 A 线程执行完毕后发消息给 B 线程,B 线程收到消息后在进行操作,这一步需要业务上的处理,无法直接在线程本身的角度实现同步。

Java 采用的是共享内存模型,线程通讯隐式代表两个线程彼此之间感知需要通过对共享内存变量的读写进行,而读写的顺序需要使用其他方式进行控制(加锁等),但线程同步则是直接显式指定,A 线程直接启动 B 线程,或者使用 join 等方法。

因为 共享内存模型的线程通讯为隐式指定,因此需要开发者对内存模型有一定的了解,控制好读和写的执行顺序,否则可能代码不会按预期进行。

共享内存和本地内存

image20220227182725428.png

每个线程的读写都是对自己线程的本地内存进行读写,而本地内存与主内存的读写则由 JMM 控制。

在此模型中, AB 线程通讯需要有以下操作,并且操作需要按顺序执行:

  1. 线程 A 更新本地内存 A 中的共享变量副本
  2. 将本地内存 A 中的共享变量副本写入主内存的共享变量
  3. 本地内存 B 中的共享变量副本需要从主内存中获取,刷新本地内存 B 中的副本
  4. 线程 B 从本地内存中共享变量副本中获取线程 A 写入的数据

实际上,如果指令按顺序执行,到是问题不大,JMM 会帮我们控制本地内存中的刷新与写回主内存,但是指令有可能不会顺序执行。

指令重排

从 Java 源代码到最终实际执行的指令顺序,会经过以下三种重排序:

image20220227183321941.png

为了为程序员提供一致的内存可见性,JMM 禁止某些指令的重排序。并在此基础上使用 happens-before 的概念来阐述内存可见性。

JMM 禁止重排序有以下方式:

内存屏障

内存屏障本身为一条指令,该指令会形成一道屏障,在该指令上下的某些指令不允许重排序:

image20220227183706961.png

as-if-serial 语义

as-if-serial 语义的含义为,单线程的程序的执行结果不能改变,则对于一段单线程程序指令,如果规则 A 规定的允许重排序的情况满足 as-if-serial 语义,则规则 A 的所有可能的重排序,运行结果都必须一致。

具体到 JMM 中,JMM 通过禁止具有 数据依赖性 的指令重排序来满足 as-if-serial 语义 要求。

数据依赖性的指令有以下三种情况:

image20220227184115798.png

happened-before 规则

image20220227184542698.png

JMM 通过禁止某种类型的重排序,实现了其规定的内存一致性,在阐述该内存一致性时,JMM 使用了 happened-before 规则。

happened-before 的定义如下:

如果 A happened-before B,则说明实际执行结果一定和 执行 A 然后执行 B 的运行结果一致。

有点拗口,这里举例子:

int a = 0; // 1
int b = 1; // 2
int c = a+b; // 3

按照 JMM 的规则,有如下规则:

  • 1 happened-before 2
  • 2 happened-before 3
  • 1 happened-before 3

说明实际执行的结果和 123 按顺序执行结果一致,也就是说执行完后 abc 的值分别为 0,1,1。

但实际执行顺序可能为 213,并且该顺序下执行完 abc 的值也为 0,1,1(因为 JMM 只需要满足 as-if-serial 语义 语义即可,这里面也没有内存屏障)。

因此 happened-before 并不是实际执行顺序。

以下为 JMM 的内存一致性设计图:

image20220227190630066.png

对于程序员来说,有以下六条重要的 happened-before 规则:

  • 一个线程中每个操作都 happened-before 于其之前的操作
  • 对于一个锁的解锁 happened-before 与之后对该锁的加锁
  • 对 volatile 变量的写 happened-before 于任何后续对该变量的读
  • 如果 A happened-before B,B happened-before C,则 A happened-before C
  • start 规则,如果线程 A 执行 线程 B 的 start() 方法启动线程,则 线程 A 之前的所有操作 happened-before 线程 B 的所有操作
  • join 规则,如果线程 A 执行 线程 B 的 join() 方法 并重新返回,则线程 B 的所有操作 happened-before 线程 A 之后的操作。
  • final 规则,对某 final 共享变量的读 happened-before 该对象的初始化(构造函数)

下面说明这些规则的实现方式

单线程的顺序执行规则

规则 1 通过 满足 as-if-serial 语义 实现,具体为禁止具有数据依赖性的指令重排实现,在没有其他规则限制下时,不具有数据依赖性的指令重排将被允许。

锁的执行顺序规则

首先,在获取锁时,JMM 会将本地内存的所有变量副本无效化,之后每次获取数据都需要从主内存拷贝。释放锁时,JMM 会将本地内存中所有副本写回主内存。并且锁本身保证了同时只有一个线程能获取锁并执行临界区代码,因此锁具有 happened-before 规则

volatile 的执行规则

volatile 的执行规则同个加入内存屏障解决,具体如下:

  • 在每个 volatile 写操作前面加入 StoreStore 屏障
  • 在每个 volatile 写操作后面加入 StoreLoad 屏障
  • 在每个 volatile 读操作后面加入 LoadLoad 屏障
  • 在每个 volatile 读操作后面加入 LoadStore 屏障(volatile 读操作后面有两个屏障)

start 和 join 规则

这两种规则都是通过刷新 主内存 和共享内存中的变量实现,start 时,新启动的线程的本地内存为空,因此所有变量都需要从主内存获取。join 结束时会将原线程中的所有共享变量无效,需要重新从主内存获取。

final 规则

首先 final 对象的写必须在构造函数之间进行,同时在该构造函数结束前加入一个 StoreStore 屏障。

懒加载单例模式的实现

直接加锁 synchronized

public class SingleInstance {
    private volatile static Instance instance;
    
    public static synchronized SingleInstance getInstance(){
        if(instance == null){
            instance = new SingleInstance();
        }
        return instance;
    }
}

效率较低,在第一次初始化后,每次 get 都已经不需要加锁,但还是会获取锁。

volatile 与 double check

public class SingleInstance {
    private volatile static Instance instance;
    
    public static SingleInstance getInstance(){
        if (instance == null){
            synchronized (SingleInstance.class){
                if(instance == null){
                    instance = new SingleInstance();
                }
            }
        }
        return instance;
    }
}

在第一个初始化后,每次 get 都不会再加锁,通过第一条 if (instance == null) 避免。

这里主要分析使用 volatile 的原因。

首先看 instance = new SingleInstance(); 这句代码,这句代码需要至少三条指令:

  1. 为新对象分配空间
  2. 初始化对象(调用构造函数)
  3. 将对象地址赋给 instance

而如果此时另外一个线程调用 get 方法,则会出现以下操作:

  1. 获取 instance 对象

因为第一次判断还未获取锁,因此 4 可能在 123 所有操作中的任意位置,如果 这里只讨论 4 在 3 之后的情况,之前的情况因为会走下一步获取锁,不会发生冲突。

按照 单线程的顺序执行规则 的 as-if-serial 语义,以下执行顺序会被允许:(在没有其他规则限制时允许不具有数据依赖性的指令重排)

1,3,4,2

因为按照该顺序的执行结果与 1,2,3,4 的结果一致(各变量当前的值为结果),而又没有其他规则限制。

此时 4 获取到的是还没调用构造函数的对象(或者正在构造中)

而加入了 volatile,则按照 volatile 的规则,3 happend-before 4,而原本 具有 2 happend-before 3 的规则(因为没有其他限制并且结不具有数据依赖性因此可以重排),根据传递性,因此 具有 2 happend-before 4 规则,因此以上顺序将不被允许,但是以下顺序依然被允许:

1,2,3,4

1,3,2,4

这里只是根据传递性,新加入了对操作 4 的限制。

实际上,在 JMM 的实现中,2 和 3 的指令重排将不被允许,即 执行顺序必须为 1,2,3,4 。只不过 happend-before 本身的规则就可以作出结果判断。因此这里没必要过于深究。

基于类初始化

public class SingleInstance {
    public static class InstanceHolder {
        public static SingleInstance instance = new SingleInstance();
    }
    
    public static SingleInstance getInstance(){
        return InstanceHolder.instance();
    }
}

Java 中类的初始化在第一次使用的时候进行,同时初始化时使用 锁 来防止冲突。以上代码 getInstance 调用时,如果 InstanceHolder 还未初始化,则会获取 LC 锁进行初始化。初始化时其他线程的操作也需要获取锁。在锁被第一次释放之后的操作,因为类已经加载完成,因此不需要在加锁。

这里使用了 JVM 本身的类加载是并发安全和懒加载的特性来实现单例模式的初始化。