synchronized锁升级过程:偏向锁→轻量级锁→重量级锁

引言

想象一下,你正在开发一个高并发的秒杀系统。上线第一天,QPS 冲到 5000,一切正常;第二天,运营搞了个大促,QPS 冲到 20000,系统突然卡死。你排查发现,罪魁祸首竟然是 synchronized——这个你用了无数次的"老朋友"。

有意思的是,synchronized 在 JDK 8 之后已经做了大量优化,它不再是一开始就上重量级锁,而是根据竞争情况动态升级。但很多同学对这套升级机制的理解还停留在"重量级锁"的层面,导致在多线程编程中要么过度设计、要么用错场景。

今天,我们就从 HotSpot 源码层面,彻底讲清楚 synchronized 的锁升级全过程,并给出可落地的实战建议。

核心概念:先来点生活化的铺垫

在讲技术之前,我们先建一个生活模型。把锁竞争比作图书馆的一个自习座位

  • 无锁(无竞争):自习室空无一人,你来了直接坐,不需要任何登记。
  • 偏向锁:你在座位上放了一本书,表示"这个位置是我的"。下次你再来,看到自己的书,直接坐下,不需要任何额外操作。但如果别人想把你的书挪开,你就得"收回"这本书(撤销偏向)。
  • 轻量级锁:有人想抢你的座位,你把书拿走,改成"用笔在纸上写下自己的名字"。每次坐下前,先看纸上有没有名字;如果只有自己的名字,直接坐;如果发现别人的名字,说明有竞争。
  • 重量级锁:抢座位的人越来越多,纸上的名字写不下了。你干脆请了一个管理员(OS Mutex),所有人来之前必须先找管理员登记排队,管理员统一协调。

这个类比映射到 JVM 里就是:

| 概念 | 生活类比 | 技术含义 |

|------|----------|----------|

| 偏向锁 | 座位上放书 | 同一线程重入时,无需任何 CAS 操作 |

| 轻量级锁 | 纸上签名 | 通过 CAS 自旋获取锁,避免线程阻塞 |

| 重量级锁 | 管理员登记 | 通过 OS Mutex 挂起/唤醒线程,涉及用户态和内核态切换 |

源码/原理深度分析

对象头的秘密

锁的状态信息存放在 Java 对象的 Mark Word 中(64 位 JVM 下占据 8 字节)。为了让你有个直观感受,下面是 64 位 HotSpot 中 Mark Word 的布局:

|--------------------------------------------------------------------|--------------------|
|                  Mark Word (64 bits)                               | State             |
|--------------------------------------------------------------------|--------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | 01                |
|--------------------------------------------------------------------|--------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | 01         | 可偏向            |
|--------------------------------------------------------------------|--------------------|
| ptr_to_lock_record:62 | 00                                        | 轻量级锁          |
|--------------------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:62 | 10                                | 重量级锁          |
|--------------------------------------------------------------------|--------------------|
|                                                                     | 11                |
|--------------------------------------------------------------------|--------------------|

关键点:

  • biased_lock=1 且 state=01:表示可偏向状态,此时 Mark Word 里存的是线程 ID。
  • state=00:轻量级锁,Mark Word 里存的是栈中锁记录的指针。
  • state=10:重量级锁,Mark Word 里存的是 Monitor 对象的指针。

HotSpot 源码中的锁升级路径

在 HotSpot 的 synchronizer.cpp 中,ObjectSynchronizer::enter() 方法定义了获取锁的完整路径。我把核心逻辑简化如下:

// HotSpot 源码简化逻辑 (synchronizer.cpp)
void ObjectSynchronizer::enter(Handle obj, BasicLock* lock, TRAPS) {
  if (UseBiasedLocking) {
    // 1. 尝试获取偏向锁
    if (!SafepointSynchronize::is_at_safepoint()) {
      BiasedLocking::revoke_and_rebias(obj, false, THREAD);
    }
  }
  
  // 2. 如果偏向锁获取失败,膨胀为轻量级锁
  if (!Atomic::cmpxchg_ptr(lock, obj()->mark_addr(), mark) == success) {
    // 3. 如果 CAS 失败,说明有竞争,膨胀为重量级锁
    ObjectMonitor* monitor = inflate(THREAD, obj(), inflate_cause_monitor_enter);
    monitor->enter(THREAD);
  }
}

偏向锁的撤销与重偏向

偏向锁的核心优化思路是:假设锁始终被同一个线程获取。但一旦发生竞争,偏向锁需要被撤销(revoke)。这个操作在 HotSpot 中叫 safe point 处的批量重偏向(bulk rebias)。

// biasedLocking.cpp 中的核心逻辑
void BiasedLocking::revoke_at_safepoint(Handle obj) {
  // 1. 找到持有偏向锁的线程
  JavaThread* biased_thread = ...;
  
  // 2. 如果该线程存活
  if (biased_thread->is_alive()) {
    // 3. 在该线程的栈中找到对应的锁记录
    // 4. 将 Mark Word 更新为轻量级锁状态或不可偏向状态
  }
}

注意:偏向锁的撤销必须等到 安全点(SafePoint),此时所有线程都暂停在安全位置。这也是为什么偏向锁在竞争激烈时反而会成为性能瓶颈——频繁的 SafePoint 会 STW(Stop The World)。

轻量级锁的自旋

轻量级锁的获取是通过 CAS + 自旋实现的。在 JDK 8 中,自旋次数可以通过 -XX:PreBlockSpin 调整,默认是 10 次。但从 JDK 9 开始,默认改成了自适应自旋(Adaptive Spinning),JVM 会根据之前的自旋成功率动态调整。

// 自适应自旋在 rotate_while_biased 中的体现
static int SpinPause() {
  // pause 指令,告诉 CPU 当前处于自旋状态
  __asm__ volatile ("pause");
}

重量级锁的 Monitor

当竞争过于激烈时,锁会膨胀为重量级锁。重量级锁基于 ObjectMonitor 实现,它内部维护了一个 EntryList(等待队列)和 WaitSet(条件队列)。

class ObjectMonitor {
  volatile markOop   _mark;       // Mark Word
  ObjectWaiter*      _WaitSet;    // 调用 wait() 的线程
  ObjectWaiter*      _EntryList;  // 等待获取锁的线程
  Thread*            _owner;      // 当前持有锁的线程
};

当一个线程获取重量级锁失败时,它会被封装成 ObjectWaiter 放入 _EntryList,然后通过 park() 挂起。当持有锁的线程释放锁时,会从 _EntryList 中唤醒一个线程。

实战代码:从理论到落地

示例 1:观察偏向锁的获取与撤销

import org.openjdk.jol.info.ClassLayout;

/**
 * 使用 JOL (Java Object Layout) 观察对象头中的锁状态
 * 运行参数:-XX:BiasedLockingStartupDelay=0
 */
public class BiasedLockDemo {

    private static class LockObject {
        // 空对象,仅用于观察锁状态
    }

    public static void main(String[] args) throws InterruptedException {
        LockObject obj = new LockObject();

        // 打印初始状态(应该处于无锁可偏向状态)
        System.out.println("初始状态(无锁可偏向):");
        System.out.println(ClassLayout.parseInstance(obj).toPrintable());

        // 模拟线程 A 获取锁
        Thread threadA = new Thread(() -> {
            synchronized (obj) {
                // 偏向锁:Mark Word 中会记录 Thread A 的 ID
                System.out.println("线程 A 持有锁(偏向锁):");
                System.out.println(ClassLayout.parseInstance(obj).toPrintable());
            }
        });
        threadA.start();
        threadA.join();

        // 主线程尝试获取锁,此时会触发偏向锁撤销
        synchronized (obj) {
            System.out.println("主线程获取锁(偏向锁被撤销,升级为轻量级锁):");
            System.out.println(ClassLayout.parseInstance(obj).toPrintable());
        }
    }
}

运行结果分析

  • 第一次打印:biased_lock=1,状态为可偏向。
  • 线程 A 持有锁后:Mark Word 中记录了线程 A 的 ID。
  • 主线程获取锁后:偏向锁被撤销,升级为轻量级锁(state=00)。

注意:JOL 需要额外依赖,Maven 中引入:

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.17</version>
</dependency>

示例 2:轻量级锁的竞争与自旋

import org.openjdk.jol.info.ClassLayout;

/**
 * 验证轻量级锁的自旋行为
 * 运行参数:-XX:+UseSpinning -XX:PreBlockSpin=10
 */
public class LightweightLockDemo {

    private static final Object LOCK = new Object();

    public static void main(String[] args) throws InterruptedException {
        // 预热阶段:让 JIT 完成编译
        for (int i = 0; i < 10000; i++) {
            synchronized (LOCK) {
                // 空操作
            }
        }

        // 模拟两个线程竞争,但竞争时间很短(自旋能成功)
        Thread t1 = new Thread(() -> {
            synchronized (LOCK) {
                System.out.println("线程1 持有锁(轻量级锁):");
                System.out.println(ClassLayout.parseInstance(LOCK).toPrintable());
                try {
                    // 持有锁 100ms,模拟短时间占用
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        Thread t2 = new Thread(() -> {
            try {
                // 稍微延迟,确保 t1 先拿到锁
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (LOCK) {
                System.out.println("线程2 通过自旋获取到锁(未膨胀为重量级锁):");
                System.out.println(ClassLayout.parseInstance(LOCK).toPrintable());
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }
}

关键点

  • 当 t2 尝试获取锁时,如果 t1 还在持有锁,t2 会先自旋等待。
  • 如果 t1 在自旋次数内释放锁,t2 通过 CAS 成功获取锁,锁不会膨胀。
  • 如果 t1 持有锁时间过长,自旋失败,锁会膨胀为重量级锁。

示例 3:重量级锁的线程阻塞与唤醒

/**
 * 验证重量级锁的线程阻塞行为
 * 运行参数:-XX:-UseSpinning 禁用自旋,强制膨胀
 */
public class HeavyweightLockDemo {

    private static final Object LOCK = new Object();

    public static void main(String[] args) {
        // 线程1:持有锁很长时间
        Thread t1 = new Thread(() -> {
            synchronized (LOCK) {
                System.out.println("线程1 持有锁,模拟长时间占用(3秒)...");
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        // 线程2:尝试获取锁,会阻塞
        Thread t2 = new Thread(() -> {
            long start = System.currentTimeMillis();
            synchronized (LOCK) {
                long cost = System.currentTimeMillis() - start;
                System.out.println("线程2 获取锁成功,等待耗时: " + cost + "ms (重量级锁)");
            }
        });

        t1.start();
        
        // 确保 t1 先拿到锁
        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        
        t2.start();
        
        // 观察线程状态
        try {
            Thread.sleep(100);
            System.out.println("线程2 状态: " + t2.getState()); // 应该是 BLOCKED
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

运行结果

线程1 持有锁,模拟长时间占用(3秒)...
线程2 状态: BLOCKED
线程2 获取锁成功,等待耗时: 2999ms (重量级锁)

线程2 的状态是 BLOCKED,说明它已经进入重量级锁的 EntryList,被 OS 挂起,等待线程1释放锁后被唤醒。

方案对比:synchronized vs ReentrantLock vs StampedLock

理解了 synchronized 的锁升级,我们把它和 Java 中的其他锁方案做个对比:

graph TD A[锁方案选择] --> B[竞争不激烈] A --> C[竞争激烈] A --> D[读写比例相差大] B --> E[synchronized 偏向锁/轻量级锁] B --> F[ReentrantLock 无锁竞争时性能略差于 synchronized] C --> G[synchronized 重量级锁] C --> H[ReentrantLock 可中断/超时/公平性] D --> I[ReentrantReadWriteLock] D --> J[StampedLock 乐观读]

| 维度 | synchronized | ReentrantLock | StampedLock |

|------|--------------|---------------|-------------|

| 锁升级 | 支持(偏向→轻量→重量) | 不支持(直接是 AQS 队列) | 不支持(乐观读/写锁切换) |

| 可中断性 | 不支持 | 支持 | 不支持 |

| 公平性 | 非公平 | 可配置公平/非公平 | 非公平 |

| 性能(低竞争) | 最好(偏向锁) | 较好(CAS) | 好(乐观读) |

| 性能(高竞争) | 差(OS 阻塞) | 较好(AQS 队列) | 读多写少时最好 |

| 适用场景 | 简单同步块、低竞争 | 复杂并发控制、需要超时中断 | 读多写少、读写比例悬殊 |

最佳实践建议

  • 如果只是简单的同步方法/块,直接用 synchronized,JVM 会帮你优化。
  • 如果需要可中断、超时、公平锁等特性,选 ReentrantLock
  • 如果是读多写少的场景(如缓存),优先考虑 StampedLock 的乐观读。

最佳实践与避坑指南

四个常见坑

坑 1:偏向锁延迟启动

// JVM 默认启动后 4 秒才启用偏向锁
// 如果系统启动前 4 秒就有大量锁竞争,偏向锁优化失效
// 可以通过 -XX:BiasedLockingStartupDelay=0 关闭延迟

坑 2:偏向锁撤销的 STW 风险

// 大量线程交替获取锁时,偏向锁会反复撤销和重偏向
// 每次撤销都需要 SafePoint(STW)
// 解决方案:-XX:-UseBiasedLocking 直接关闭偏向锁

坑 3:轻量级锁自旋的 CPU 浪费

// 如果锁持有时间较长,自旋会浪费 CPU
// 自适应自旋会根据历史调整,但仍有风险
// 方案:合理设计临界区大小,避免在锁内做耗时操作

坑 4:重量级锁的线程阻塞恢复

// 重量级锁的唤醒需要 OS 在用户态和内核态之间切换
// 频繁的阻塞/唤醒会导致系统调用开销巨大
// 方案:使用 LockSupport.park()/unpark() 时,注意减少切换频率

三个核心实践建议

  1. 锁粒度要小:缩小临界区范围,减少锁持有时间。比如用局部变量代替成员变量,避免在锁内执行 I/O 操作。
  1. 锁粒度要准:如果多个线程操作的是不同对象,用不同锁实例,避免伪共享。
  1. 善用 JVM 参数调优
   # 关闭偏向锁(适合高竞争场景)
   -XX:-UseBiasedLocking
   
   # 调整自旋次数(JDK8 及以前)
   -XX:PreBlockSpin=20
   
   # 开启偏向锁批量重偏向日志
   -XX:+TraceBiasedLocking

总结

回到开头秒杀系统的场景。如果你发现 synchronized 变成了瓶颈,先别急着换锁,用 JOL 看一下当前锁状态:

  • 如果锁一直处于偏向锁状态,说明竞争极低,synchronized 的性能是最好的。
  • 如果锁在轻量级锁重量级锁之间反复横跳,说明临界区代码太重,优先优化代码而不是换锁。
  • 如果确实需要更细粒度的控制(超时、中断、公平性),再考虑 ReentrantLock

锁升级是 JVM 为了平衡"性能"和"公平性"做的巧妙折中。理解这套机制,能帮你在多线程编程中做出更明智的选择。

最后留个思考题:JDK 15 之后,JEP 374 默认禁用了偏向锁(JDK 15 中 UseBiasedLocking 默认关闭)。你觉得为什么官方会做出这个决定?在什么场景下偏向锁反而成了累赘?欢迎在评论区讨论。