并发4️⃣管程④synchronized 锁优化

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

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

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

1、偏向锁

1.1、Biased locking

当一个线程获取锁时进入偏向模式(设置锁标志位、线程 ID)

同一个线程再次请求锁时,检查 Mark Word 为当前线程 ID,则无需做同步操作

  • 优点:优化同一线程多次获取同一个锁的情况,节省大量有关锁申请(如 CAS)的操作,提高性能。

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

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

    # 开启偏向锁(默认开启)
    -XX:+UserBiasedLocking
    # 偏向锁开启延时(JDK10前默认4000ms,之后默认0)
    -XX:BiasedLockingStartupDelay=毫秒
    
  • Mark Word 说明

    • Thread、epoch、age:对象实例化时为 0,加锁 / 垃圾回收时才设置。

    • 锁标志101(不加锁是 001,区别在于倒数第三位)

      image-20220329004147155

1.2、锁撤销

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

  • 发生撤销的情况

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

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

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

1.3、批量重偏向

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

撤销次数达到阈值后不再撤销,而是将该类剩余的需要被加锁的实例批量重偏向至另一个线程。

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

示例理解

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

Vector<User> 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

1.4、批量撤销

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

撤销次数达到阈值后,不再撤销或批量重偏向,而是撤销该类所有实例的偏向锁,并禁用该类的偏向锁。

示例理解

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

Vector<User> 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在不同时刻加锁(否则膨胀为重量级锁)
t1.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、轻量级锁

线程 t1 已获取对象锁,某一时刻 t1 没有加锁,t2 尝试获取该对象锁时。

t2 检查 Mark Word 发现线程 ID 不是 t2,则升级为轻量级锁。

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

  • 锁标志:Mark Word 保存锁记录对象地址,末尾两位为 00

    image-20220329174326658

2.1、锁记录

Lock Record:每个线程的栈帧中的空间结构

两部分组成

  • 锁记录地址、锁标志

    image-20220329182822941.png

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

2.2、加锁过程

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

① 创建 Lock Record

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

  2. Object Reference 保存 user 地址。

    image-20220416170152438

② CAS

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

image-20220416170210817

③ CAS 成功

加轻量级锁成功

  • Lock Record 中存储 user 的原 Mark Word

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

    image-20220416170304448

④ CAS 失败

假设线程 t1 已对 user 加轻量级锁且未释放,分析以下情况。

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

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

case2:线程 t1 再次该对象加锁(锁重入)

  1. 创建 Lock Record

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

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

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

    image-20220329185457727

2.3、解锁过程

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

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

2.4、锁膨胀 & 锁自旋

① 锁膨胀

锁膨胀:不使用轻量级锁,而是改用重量级锁。

场景:发生同一时刻的锁竞争

示例线程 t1 持有 user 的轻量级锁,线程 t2 尝试加轻量级锁时 CAS 失败

进入锁膨胀过程

  1. t2 为 user 申请 Monitor,让 userMark Word 指向 Monitor 对象地址。
  2. t2 进入 EntryList,变成 BLOCKED 状态。
  3. t1 执行完临界区代码,退出 synchronized 代码块时 CAS 失败。
  4. t1 进入重量级锁解锁流程
    • 通过 Lock Record 的对象引用,根据 userMark Word 找到 Monitor 对象。
    • Owner 置为 null,唤醒 EntryList 中的阻塞队列。
    • 后续操作,回顾 Monitor 原理(1.2)

② 自旋

锁膨胀的缺点

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

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

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

说明

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

3、锁消除 & 锁粗化

锁消除和锁粗化是 JVM 的运行期优化 技术。

3.1、锁消除

Lock Elimination

以线程安全的 StringBuffer 类为例。

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

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

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

3.2、锁粗化

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();
    }
    
posted @ 2022-04-16 18:00  Jaywee  阅读(49)  评论(0编辑  收藏  举报

👇