并发编程④共享:Monitor、锁优化


1、Monitor

Monitor 是操作系统提供的,负责处理 synchronized 的组件。

在学习 Monitor 之前,要了解 Java 对象头 的概念。

1.1、对象组成

以 32 位虚拟机为例

Java 对象由三部分组成:对象头、实例数据、对齐补充。

image-20220328180944313

1.1.1、对象头

对象头的结构如下

  • Mark Word:存储对象运行时记录(hashcode、GC 分代年龄、锁状态标志、线程 ID 等)

  • Klass Word:元数据指针,指向方法区的 instanceKlass 实例(稍后讲解)

  • array length:数组对象特有,表示数组长度

    image-20220328181316137

1.1.2、Mark Word(!)

对象处于不同状态时,Mark Word 有所不同。

末尾 2 位表示锁标记(不加锁、偏向锁、轻量级锁、重量级锁、垃圾回收标志)

  • 32 位虚拟机

    |---------------------------------------------------|--------------------|
    |                 Mark Word (32 bits)               |       State        |
    |---------------------------------------------------|--------------------|
    |      hashcode:25     | age:4 | biased_lock:0 | 01 |       Normal       |
    |---------------------------------------------------|--------------------|
    |  thread:23 | epoch:2 | age:4 | biased_lock:1 | 01 |       Biased       |
    |---------------------------------------------------|--------------------|
    |            ptr_to_lock_record:30             | 00 | Lightweight Locked |
    |---------------------------------------------------|--------------------|
    |          ptr_to_heavyweight_monitor:30       | 10 | Heavyweight Locked |
    |---------------------------------------------------|--------------------|
    |                                              | 11 |   Marked for GC    |
    |---------------------------------------------------|--------------------|
    
  • 64 位虚拟机

    |-------------------------------------------------------|--------------------|
    |                      Mark Word (64 bits)              |       State        |
    |-------------------------------------------------------|--------------------|
    |unused:25|hashcode:31|unused:1|age:4|biased_lock:0| 01 |       Normal       |
    |-------------------------------------------------------|--------------------|
    | thread:54 | epoch:2 |unused:1|age:4|biased_lock:1| 01 |       Biased       |
    |-------------------------------------------------------|--------------------|
    |                 ptr_to_lock_record:62            | 00 | Lightweight Locked |
    |-------------------------------------------------------|--------------------|
    |              ptr_to_heavyweight_monitor:62       | 10 | Heavyweight Locked |
    |-------------------------------------------------------|--------------------|
    |                                                  | 11 |    Marked for GC   |
    |-------------------------------------------------------|--------------------|
    

1.1.3、oop-klass 模型(*)

Hotspot 使用 oop-klass 模型 表示 Java 类和对象

instanceKlass instanceOopDesc
创建时期 类加载阶段,JVM 将字节码文件(.class)加载到方法区 new 创建对象时,JVM 会创建 instanceOopDesc
说明 表示类的元数据,包括常量池、成员变量、方法等结构 表示对象实例,实例存放在区,对象引用存放在
指针维护 属性 _java_mirror 存储 java.lang.Class 实例的地址 对象头的 Klass Word 存储 instanceKlass 的地址

代表 java.lang.Class 的对象实例

JVM 不把 instanceKlass 暴露给 Java,而是创建一个单独的 instanceOopDesc 实例(代表 java.lang.Class 对象)

  • 该对象实例称为 instanceKlass 的 Java 镜像

  • instanceKlass 维护了一个指向镜像的指针(即 _java_mirror

  • 指针关系

    image-20220328205818924

1.2、Monitor 原理

1.2.1、说明

Monitor(管程、监视器)

每个 Java 对象都可以关联一个 Monitor。

  • 当使用 synchronized 给对象加锁时(重量级),对象头中的 Mark Word 就被设置为指向 Monitor 对象的指针。
  • ptr_to_heavyweight_monitor,且锁标志为 10
  • 不加 synchronized 的对象不会关联 Monitor,不同对象关联独有的 Monitor。

Monitor 结构

含义 对应线程状态
owner 锁的持有者,正在执行同步代码块 RUNNABLE
EntryList 由于锁已被其它线程获取,尝试获取锁失败的阻塞线程 BLOCKED
WaitSet 锁的持有者由于条件不满足主动释放锁,进入等待状态 WAITING

1.2.1、图解 Monitor

  • 普通对象为例(非数组类型,区别在于对象头)
  • 注:以下案例针对同一个对象实例的 Monitor。

线程 t1 执行到 synchronized(obj){} 代码块

  1. 根据对象头的 Mark Word 找到关联的 Monitor

  2. 判断 Monitor 的 Owner 为空,将其设置为当前线程 t1,进入临界区执行同步代码块RUNNABLE

    image-20220328210244324

线程 t2 执行到 synchronized(obj){} 代码块

(同一时刻,Monitor 只能有一个 Owner)

  1. 根据对象头的 Mark Word 找到关联的 Monitor,判断 Monitor 的 Owner 不为空

  2. t2 进入 EntryList,变成 BLOCKED 状态。

  3. 此时如果有 t3、t4 线程也执行到 synchronized(obj){},也会经历 t2 的过程。

    image-20220328210424107

若 Monitor 的 Owner(线程 t1)的执行条件不满足(涉及 wait/notify

  1. t1 主动释放锁,进入 WaitSet,变成 WAITING 状态

    image-20220328210813929

  2. 此时其它线程可以成为竞争锁,成为 Owner。

  3. 等待某个获取同一个对象锁的线程执行了 notify()(表示条件满足)

  4. 线程 t1 退出 WaitSet,进入 EntryList 竞争锁(而不是直接成为 Owner)。

线程 t1 执行完同步代码块内容,将 Owner 置为 null,唤醒 EntryList 中的阻塞线程

  1. 若 EntryList 中只有 t2,则 t2 获得锁。

  2. 若 EntryList 中有多个线程,则 t1 会唤醒所有阻塞线程来竞争锁(RUNNABLE)。

    • 竞争是非公平的(先阻塞的未必先获得)

    • 竞争到锁的线程成为新的 Owner,竞争失败的锁再次进入 BLOCKED 状态。

      image-20220328211100831

1.3、synchronized 字节码

1.3.1、示例代码

static final Object lock = new Object();
static int counter = 0;
public static void main(String[] args) {
    synchronized (lock) {
        counter++;
    }
}

1.3.2、字节码

  • 仅展示字节码指令、异常表

  • 常用字节码指令:

    Stack=2, locals=3, args_size=1
        0	getstatic #2
        3	dup				# 复制
        4	astore_1		# 将复制的引用至存储到slot1
        5	monitorenter	# 将lock对象的Mark Word置为指向Monitor指针
        6	getstatic #3
        9	iconst_1
        10	iadd
        11	putstatic #3
        14	aload_1
        15	monitorexit		# 将lock对象的Mark Word重置,唤醒EntryList
        16	goto 24
        19	astore_2		# 将异常存储到slot2
        20	aload_1			# 加载slot1的对象引用
        21	monitorexit
        22	aload_2
        23	athrow			# 抛出异常
        24	return
    Exception table:
    	from	to	target	type
           6	16		19	 any
          19	22		19	 any
    

正常流程(关键步骤)

  1. dup复制 lock 对象引用,存放到局部变量表(用于解锁)。
  2. monitorenter:将对象关联 Monitor(Mark Word 设为 Monitor 对象地址),进入临界区执行同步代码块。
  3. monitorexit:重置 Mark Word(Owner 设为 null),唤醒 EntryList 的阻塞线程
  4. 没有异常,跳到第 24 行字节码指令,return 结束。

发生异常(参考异常表)

JVM 会监视 6-16、19-22 行的字节码指令。

任意一行指令发生 any 异常(任意类型),跳转到第 19 行。

  1. 将异常存储到 slot2
  2. 加载局部变量表 slot1 的 lock 引用,进行解锁
  3. 抛出异常并结束。

1.3.3、说明

结构

  • JVM 为每个线程分配一个独有的栈区,为每个方法分配一个栈帧
  • 栈帧主要结构:操作数栈(stacks)、局部变量表(locals)、锁记录(lock record)

加锁过程

  • 加锁前会复制一份被加锁对象的引用,保证出现异常时也能正常解锁。
  • monitorentermonitorexit 成对出现,对应加锁和解锁操作。

2、JVM 中的锁优化

synchronized 是重量级锁,并发性能低。

为了提高并发时的系统吞吐量,JVM 提供了锁优化策略。

  • 加锁策略:不加锁 → 偏向锁 → 轻量级锁 → 重量级锁
  • 其它策略:自旋、锁粗化、锁消除等

2.1、偏向锁

2.1.1、Biased locking

思想:当一个线程获取锁时进入偏向模式,同一个线程再次请求锁时,无需做同步操作

  • 优点:节省大量有关锁申请(如 CAS)的操作,提高性能。

  • 适用场合没有锁竞争,只有一个线程对该对象加锁。

    • 锁竞争:有其它线程也对该对象加锁。
    • 发生不同时刻的锁竞争时升级为轻量级锁,发生同一时刻的锁竞争时膨胀为重量级锁。
  • 虚拟机参数

    # 开启偏向锁(默认开启)
    -XX:+UserBiasedLocking
    # 偏向锁开启延时(JDK10前默认4000ms,之后默认0)
    -XX:BiasedLockingStartupDelay=毫秒
    
  • 锁标志:Mark Word 末尾三位为 101(不加锁是 001,区别在于倒数第三位)

    image-20220329004147155

2.1.2、Mark Word 情况

没开启偏向锁

对象实例化时

  • 末尾三位001
  • age 为 0:垃圾回收时才会设置。
  • Hashcode 为 0:第一次用到 hashCode 才会设置。

若有开启偏向锁(默认开启)

  1. 对象实例化

    • 末尾三位101
    • Thread、epoch、age 为 0:加锁 / 垃圾回收时才设置。
  2. 偏向锁延迟开启

    • 在 JDK 10 之前默认 4000ms,即程序启动 4s 后才会开启。
    • 从 JDK 10 开启调整为 0,可使用虚拟机参数手动设置。

2.1.3、锁撤销

偏向锁在没有发生锁竞争时生效。

  • 发生撤销的情况

    说明 原因
    获取对象 hashcode Mark Word 中需要存储 hashcode 偏向锁状态的对象的 Mark Word 中存储的是 thread 和 epoch
    发生不同时刻的锁竞争 升级为轻量级锁
    发生同一时刻的锁竞争 膨胀为重量级锁
    调用 wait/notify 膨胀为重量级锁 涉及到 Monitor 结构
  • 对撤销的优化:撤销次数达到阈值时,会触发批量重偏向或批量撤销。

    • 默认阈值:批量重偏向 20,批量撤销 40。
    • 撤销次数 = 阈值 - 1,就认为达到了阈值。

相关说明

关于偏向锁的 JVM 结构

  1. revocation_count:撤销计数器
  2. BiasdLockingDecayTime:即重新开启偏向锁的时间(默认 25000ms)

其它说明

  1. 撤销次数是针对类计数,而不是对象实例。
  2. 重偏向不增加撤销次数
  3. 批量重偏向的对象,在规定时间(即 BiasLockingDecayTime)内无法再次批量重偏向至新的线程

2.1.4、批量重偏向

思想

场合同一个类的对象实例发生不同时刻的锁竞争。

  • 撤销次数达到阈值后(默认 20)不再进行撤销操作。
  • 而是将该类剩余的需要被加锁的实例批量重偏向至另一个线程。

示例理解

用 Vector 存储 User 对象,模拟多线程访问相同的对象实例。

Vector userVector = new Vector<>();

Thread t1 = new Thread(() -> {
    for (int i = 0; i < 100; i++) {
        User user = new User();
        userVector.add(user);
        synchronized (user) {
        }
    }
}, "t1");
t1.start();
// 保证t1和t2在不同时刻加锁(否则膨胀为重量级锁)
t1.join();
new Thread(() -> {
    for (int i = 0; i < 39; i++) {
        User user = userVector.get(i);
        synchronized (user) {
        }
    }
}, "t2").start();

线程 t1对 100 个 user 对象实例加锁

  • 100 个 User 都从无锁变成偏向锁
  • Mark Word 后三位为 101,thread 为 t1。

线程 t2对前 39 个 user 对象加锁,分析如下

  • user1-19:撤销偏向锁,加锁为轻量级锁(00),解锁后设为禁用偏向锁001
  • user20-39:达到默认阈值 20 不再撤销,将之后此线程需加锁的所有实例都重偏向至 t2(101,thread 置为 t2)

全程的对象状态

# t1
user1-100
加锁前:	001	无锁
加锁中:	101 偏向锁,thread为t1
解锁后:	101 偏向锁,thread为t1
# t2
# user1-19:撤销,升级为轻量级锁,禁用偏向锁
加锁前:	101 偏向锁,thread为t1
加锁中:	 00 轻量级锁
解锁后:	001	禁用偏向锁
# user20-39:批量重偏向至 t2
加锁前:	101 偏向锁,thread为t1
加锁中:	101 偏向锁,thread为t2
解锁后:	101 偏向锁,thread为t2
# user40-100
101 偏向锁,thread为t1

2.1.5、批量撤销

思想

场合同一个类的对象实例发生不同时刻的锁竞争。

  • 撤销次数达到阈值后(默认 40)不再进行撤销或重偏向操作。
  • 而是撤销该类所有实例的偏向锁,并禁用该类的偏向锁。

示例理解

在批量重偏向的例子中,增加 t3 线程。

Vector userVector = new Vector<>();
// 保证t1和t2在不同时刻加锁(否则膨胀为重量级锁)
Thread t1 = new Thread(() -> {
    for (int i = 0; i < 100; i++) {
        User user = new User();
        userVector.add(user);
        synchronized (user) {
        }
    }
}, "t1");
t1.join();
Thread t2 = new Thread(() -> {
    for (int i = 0; i < 39; i++) {
        User user = userVector.get(i);
        synchronized (user) {
        }
    }
}, "t2");
// 保证t2和t3在不同时刻加锁(否则膨胀为重量级锁)
t2.join();
new Thread(() -> {
    for (int i = 0; i < 39; i++) {
        User user = userVector.get(i);
        synchronized (user) {
        }
    }
}, "t3").start();

线程 t1:对 100 个 User 对象实例加偏向锁101,thread 为 t1)

线程 t2:对 UserVector 前 39 个对象加锁(前 19 个禁用偏向锁,20-39 是 t2 偏向锁)

线程 t3对前 40 个 user 对象加锁,分析如下

  • user1-19:t2 加锁时已设为禁用偏向锁,加锁时为轻量级锁,解锁时仍为禁用偏向锁。
  • user20-38:(共 19 个)撤销偏向锁,加锁为轻量级锁(00),解锁后设为禁用偏向锁001
  • user39:达到批量重偏向阈值,但未超过 BiasedLockingDecayTime
    • 无法二次重偏向(操作同上,加轻量级锁,禁用偏向锁)。

达到阈值 40

  • 撤销次数共 39 次,达到阈值。
  • 撤销该类所有实例的偏向锁,并禁用该类的偏向锁
  • 此后创建该类的实例对象,也是处于禁用偏向锁的状态(001)。

全程的对象状态

# t1
# user1-100
加锁前:	001	无锁
加锁中:	101 偏向锁,thread为t1
解锁后:	101 偏向锁,thread为t1
# t2
# user1-19:撤销,升级为轻量级锁,禁用偏向锁
加锁前:	101 偏向锁,thread为t1
加锁中:	 00 轻量级锁
解锁后:	001	禁用偏向锁
# user20-39:批量重偏向至 t2
加锁前:	101 偏向锁,thread为t1
加锁中:	101 偏向锁,thread为t2
解锁后:	101 偏向锁,thread为t2
# user40-100
101 偏向锁,thread为t1

# t3
# user1-19:保持禁用偏向锁
加锁前:	001 禁用偏向锁
加锁中:	 00 轻量级锁
解锁后:	001	禁用偏向锁
# user20-38:撤销,升级为轻量级锁,禁用偏向锁
加锁前:	101 偏向锁,thread为t2
加锁中:	 00 轻量级锁
解锁后:	001 禁用偏向锁
# user39:达到批量重偏向阈值,但已被批量重偏向过
加锁前:	101 偏向锁,3的thread为t2
加锁中:	 00 偏向锁,轻量级锁
解锁后:	001 禁用偏向锁
# user40-100
001 禁用偏向锁

2.2、轻量级锁

当没有发生锁竞争时,偏向锁生效。

思想:当发生不同时刻的锁竞争时,使用轻量级锁(而不是直接膨胀为重量级锁)

  • 适用场合:发生不同时刻的锁竞争

    • 多线程在不同时间对同一个对象加锁
    • 若多线程在同一时刻尝试加锁,则膨胀为重量级锁。
  • 锁标志:Mark Word 保存锁记录对象地址,末尾两位为 00

    image-20220329174326658

2.2.1、锁记录

锁记录:每个线程的栈帧中的空间结构

锁记录对象的结构

  • 锁记录地址、锁标志

    image-20220329182822941

  • 对象引用:存储当前线程正加锁的对象

2.2.2、加锁过程

  • 创建 Lock Record → CAS → 成功
  • 创建 Lock Record → CAS → 失败 → 自旋 → 成功或膨胀

创建 Lock Record

  1. 线程执行到 synchronized(user){} 代码块,在当前栈帧(Frame)创建锁记录对象(Lock Record)。

  2. Object Reference 保存 user 地址。

    image-20220329183416122

CAS

  1. 尝试用锁记录地址,交换 userMark Word

    image-20220329183509988

  2. CAS 替换成功则加轻量级锁成功,此时

    • Lock Record 中存储了 user 的原 Mark Word

    • user 的 Mark Word 存储了锁记录地址和锁标志(即 ptr_to_lock_record 00

      image-20220329183935372

CAS 失败的情况

假设线程 t1 已对 user 加轻量级锁,且未释放

User user = new User();
new Thread(()->{
    synchronized(user){
        // 轻量级锁
    }
},"t1").start;

分析以下情况

  • case1:线程 t2 尝试加轻量级锁

    new Thread(()->{
        synchronized(user){ // 锁膨胀(先自旋)
        }
    },"t2").start;
    
    1. 创建 Lock Record
    2. 尝试 CAS,但 user 的 Mark Word 已存储锁记录地址,CAS 失败。
    3. 查看锁记录地址,发现不属于当前线程(即产生同一时刻的锁竞争
    4. 进入锁膨胀过程(稍后讲解)。
  • case2:t1 再次该对象加锁(锁重入)

    1. 创建 Lock Record

    2. 尝试 CAS,但 user 的 Mark Word 已存储锁记录地址,CAS 失败。

    3. 查看锁记录地址,发现属于当前线程 (锁重入)

    4. 重置 Lock Record 的锁记录地址(置为 null),作为重入计数。

      image-20220329185457727

2.2.3、解锁过程

线程 t1 退出 synchronized 代码块时,查看当前栈帧中 Lock Record 的头部。

  • null:说明是锁重入。
  • 非 null:存储的是对象实例的原 Mark Word,通过 CAS 恢复给对象实例。
    • CAS 成功:轻量级锁解锁成功。
    • CAS 失败:说明轻量级锁已膨胀,需进入重量级锁解锁流程。

2.2.4、锁膨胀 & 锁自旋

锁膨胀

锁膨胀:将轻量级锁变成重量级锁。

场景:线程尝试加轻量级锁时 CAS 失败,其它线程已获取该对象的轻量级锁。

线程 t2 尝试加轻量级锁失败,进入锁膨胀过程

  1. t2 为 user 申请 Monitor,让 user 的 Mark Word 指向 Monitor 对象地址。
  2. t2 进入 EntryListBLOCKED)。
  3. t1 执行完临界区代码,退出 synchronized 代码块时 CAS 失败。
  4. t1 进入重量级锁解锁流程
    • 通过 Lock Record 的对象引用,根据 user 的 Mark Word 找到 Monitor 对象。
    • 将 Owner 置为 null,唤醒 EntryList 中的 t2(即 Monitor 原理)。

自旋

发生同一时刻的锁竞争时,轻量级锁会膨胀为重量级锁

  • 导致当前线程进入 EntryList,变成阻塞状态(BLOCKED
  • 导致线程上下文切换,影响性能。

因此在锁膨胀之前,JVM 会尝试自旋优化

  • 让当前线程做若干次空循环(即自旋),而不是直接阻塞。
  • 若在此期间,轻量级锁被持有者释放,则当前线程成功获得锁。

说明

  1. 自旋会消耗 CPU:自旋的做法是使线程执行空循环,在此期间消耗一定的 CPU 资源。
  2. 多核 CPU 下自旋才有效:至少有一个 CPU 用于执行 owner 的线程,另一个 CPU 用于当前线程的自旋。
  3. Java 6 后自旋锁自适应(自动调整自旋次数),Java 7 后默认开启自旋功能。

2.3、锁消除 & 锁粗化

锁消除是 JVM 的 技术。

JVM 执行引擎

  • 当程序启动时,解释器首先发挥作用,直接将代码解释执行(省去编译的时间)。
  • 随着代码执行次数越来越多,JIT 编译器开始发挥作用,将热点代码编译成本地机器码,提高执行效率。

锁消除(Lock Elimination)

以线程安全的 StringBuffer 类为例。

  • 变量 result 是局部变量,只会在方法内部被调用,不存在线程安全问题

  • JVM 在运行时自动将 StringBuffer 对象内部的锁消除

    public void add(String str1, String str2) {
        StringBuffer result = new StringBuffer();
        result.append(str1).append(str2);
    }
    

锁粗化(Lock Coarsening)

仍以 StringBuffer 类为例。

  • 假如没有锁粗化,以下代码会对同一个对象进行 10000 次加锁和解锁操作,浪费资源

  • JVM 在运行时会将加锁的范围粗化(比如对整个循环体加锁),使得整个操作只进行 1 次加锁和解锁操作。

    StringBuffer result = new StringBuffer();
    public String add(String str) {
    	for (int i = 0; i < 10000; i++){
            result.append(str)
        }
        return result.toString();
    }