开篇:被面试官问懵的那一刻

面试官问我:“synchronized 有几种状态?什么时候会升级?”

我心想这题我背过啊,张口就来:“偏向锁、轻量级锁、重量级锁。”

面试官面无表情:“说说升级过程。”

我开始背:对象头、Mark Word、CAS…

面试官打断:“为什么要升级?直接用重量级锁不行吗?”

我卡壳了。

回来怒学一周,终于把这里面的门道摸透了。这篇博客,写给曾经和我一样只会背答案的你。

一、先搞懂对象头:锁的"身份证"

在说锁之前,必须先搞懂 Java 对象的内存布局。

一个 Java 对象在内存里分三块:

  • 对象头:包含 Mark Word + Klass Pointer
  • 实例数据:字段内容
  • 对齐填充:补齐到 8 字节

锁的秘密全在 Mark Word 里。

Mark Word 是个 64 位(或者 32 位)的"万能槽",不同状态下存的东西不一样:

状态Mark Word 存储内容
无锁对象 hashCode + 分代年龄 + 偏向标志(0) + 锁标志(01)
偏向锁偏向线程 ID + epoch + 分代年龄 + 偏向标志(1) + 锁标志(01)
轻量级锁指向栈中锁记录的指针
重量级锁指向互斥量(管程)的指针

一句话记住:Mark Word 就是锁的"身份证",记录了对象当前被谁持有、处于什么状态。

二、偏向锁:第一个吃螃蟹的人独享

2.1 什么是偏向锁

偏向锁是 JDK 1.6 引入的优化。核心思想就一句话:“这个锁老子拿了,谁也别来抢。”

当一个线程第一次访问同步代码块时,JVM 会把 Mark Word 的偏向标志设为 1,记录下当前线程 ID。之后这个线程再来,直接对比 ID,秒过。

// 偏向锁场景:一个线程反复进入同一个锁
synchronized (lock) {
    // 第一次:CAS 设置偏向
    // 第二次:直接比对线程 ID,零成本
}

省掉了每次加锁的 CAS 开销。

2.2 什么时候撤销偏向锁

偏向锁虽好,但不是永久的。遇到其他线程来抢,就凉了:

// 线程 A 正在偏向中
synchronized (lock) {
    // 线程 B 来了
}

// 此时 JVM 发现有竞争,撤销偏向锁
// 升级为轻量级锁

撤销偏向锁的场景:

  1. 其他线程竞争:线程 B 来获取锁,偏向锁被撤销
  2. 调用 wait():wait() 是重量级锁专属,偏向锁直接凉凉
  3. hashCode():对象调用 hashCode() 会导致偏向锁撤销

三、轻量级锁:CAS 自旋的战场

3.1 轻量级锁的加锁过程

当偏向锁被撤销,或者一开始就是多线程竞争,锁就升级为轻量级锁。

我第一次看这个流程时绕了好久,用大白话讲:

  1. 线程抢锁:线程在栈里创建一个"锁记录"(Lock Record),复制对象头的 Mark Word
  2. CAS 抢椅子的游戏:用 CAS 把对象头的 Mark Word 改成指向自己的锁记录
  3. 抢到就是赚到:CAS 成功,加锁成功
  4. 没抢到就自旋:CAS 失败,自旋重试,不放弃
// 简化模拟
public void lock() {
    // 第一步:在栈里创建锁记录,复制 Mark Word
    // 第二步:CAS 修改对象头
    if (cas(markWord, copiedMarkWord, threadLockRecord)) {
        // 成功!拿到了轻量级锁
    } else {
        // 没抢到,自旋重试
        while (true) {
            if (cas(...)) break;
        }
    }
}

3.2 轻量级锁的解锁过程

解锁也是 CAS:

// 线程执行完同步块,CAS 把栈里的锁记录写回对象头
if (cas(对象头, 当前锁记录, 之前的MarkWord)) {
    // 解锁成功
} else {
    // 解锁失败,说明有竞争,进入重量级锁流程
}

3.3 什么情况下轻量级锁会"膨胀"

这是面试常问的:“轻量级锁什么时候变成重量级锁?”

两种情况:

第一种:自旋次数超限。

// JDK 1.6 默认自旋 10 次
// 超过 10 次还没拿到锁,直接升级
for (int i = 0; i < 10; i++) {
    if (cas(...)) return;  // 拿到锁
}
// 升级为重量级锁

第二种:自旋过程中有第三个线程来抢。

// 线程 A 和 B 在轻量级锁层面自旋
// 线程 C 也来抢这把锁
// JVM 判断:竞争激烈,别自旋了,升级!

记住一个公式:

轻量级锁 ≈ “我赌你很快就能释放锁”

如果赌输了(自旋太久 / 竞争加剧),就升级为重量级锁。

四、重量级锁:最传统也最稳定

4.1 重量级锁的原理

重量级锁依赖操作系统的 Mutex(互斥量)实现。

synchronized (lock) {
    // 调用操作系统底层
    // 线程没拿到锁,进入等待队列
    // 操作系统把线程挂起
}

特点:

  • 线程进入等待状态,不占 CPU
  • 需要用户态和内核态切换,开销大(大约是轻量级锁的 100 倍)
  • 不自旋,不浪费 CPU

4.2 我踩过的坑:锁竞争导致的线上故障

实习期间遇到过一次 OOM 故障,根因就是 synchronized + 重量级锁。

// 伪代码
public synchronized void processOrder(Order order) {
    // 这个方法被高频调用
    // 每次进来都要竞争重量级锁
    // 大量线程阻塞在 wait queue
    // 导致内存占用飙升
}

教训:

  • 高并发场景下,别用 synchronized 锁太久的代码块
  • 优先考虑分段锁、读写锁、ConcurrentHashMap

五、锁升级流程图(面试必备)

无锁状态
   │
   │ 第一个线程进入
   ▼
偏向锁(偏向线程 ID = A)
   │
   │ 线程 B 来竞争
   ▼
偏向锁撤销
   │
   │ CAS 抢锁
   ▼
轻量级锁(A 在栈中持有锁记录)
   │
   │ 自旋超过 10 次 / 竞争加剧
   ▼
重量级锁(OS Mutex,线程阻塞)

面试能说出这个流程,加分。

六、面试高频 Q&A

Q1:为什么要有锁升级?不能直接用重量级锁吗?

答: 因为性能。重量级锁需要操作系统介入,用户态切换内核态,开销巨大。偏向锁和轻量级锁都是在"大多数情况只有一个线程"的假设下做的优化,避免无谓的系统调用。

一句话: 能用轻量级解决就别用重量级,底层优化就是省那 100 倍的开销。

Q2:synchronized 和 ReentrantLock 区别?

对比项synchronizedReentrantLock
底层Monitor (OS)AQS
锁升级自动升级可重入,不可升级
公平锁不支持支持
等待可中断不支持支持
条件变量无Condition

我的理解: synchronized 是"傻瓜相机",自动优化;ReentrantLock 是"单反相机",手动调参。

Q3:什么是锁粗化?为什么会有?

答: JIT 编译优化。如果一段代码反复加锁解锁,JIT 会把锁范围扩大,减少加锁次数。

// JIT 优化前
for (int i = 0; i < 1000; i++) {
    synchronized (lock) {
        list.add(i);
    }
}

// JIT 优化后(锁粗化)
synchronized (lock) {
    for (int i = 0; i < 1000; i++) {
        list.add(i);
    }
}

Q4:什么是锁消除?和逃逸分析什么关系?

答: 锁消除是 JIT 编译器分析发现一个锁对象不可能被其他线程访问,直接把锁去掉。

// JIT 逃逸分析发现 sb 是局部变量,不会逃逸
// 自动消除 synchronized
StringBuffer sb = new StringBuffer();
synchronized (sb) {
    sb.append("hello");
}
// 编译后:sb.append("hello");  // 锁没了

七、记忆口诀

无锁 → 偏向 → 轻量 → 重量

一个线程用偏向,多个线程轻量来

自旋失败或竞争激,重量锁住线程歇

逐句解释:

  • 无锁 → 偏向:第一个线程独享
  • 偏向 → 轻量:其他线程来竞争
  • 轻量 → 重量:自旋超时或竞争激烈

总结

写这篇文章的收获:

  1. 锁的本质是对象头:Mark Word 的状态决定了锁的形态
  2. 升级是单向的:只能无锁→偏向→轻量→重量,不能降级(JDK 15 后偏向锁已废弃)
  3. 性能优化的哲学:假设"大多数情况只有一个线程",用轻量手段解决;假设失败再升级

面试被问到锁升级,别再只会背三个名字了。把 Mark Word、升级条件、性能差异说出来,面试官会眼前一亮。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐