首页 / Java 学习笔记 / 12

JAVA · Vol.II · DAY 12 · 并发编程

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

高级必问#并发#核心#JVM
第 1 站

从一次线上事故说起

先讲一个真实发生过的线上事故。某天晚上大促压测,一个订单服务的接口 P99 从 80ms 一路飙到 3 秒,线程池被打满,新请求全部排队。运维 jstack 一抓,发现几百个线程齐刷刷卡在同一个状态:

jstack 输出 · 事故现场
"http-nio-8080-exec-42" #42 prio=5 os_prio=31 ...
   java.lang.Thread.State: BLOCKED (on object monitor)
        at com.demo.order.OrderService.createOrder(OrderService.java:58)
        - waiting to lock <0x00000007ff8f4d10> (a java.lang.Object)
        at com.demo.order.OrderController.createOrder(...)

一个对象的锁被某个线程长期持有,后面几百个线程都在排队等它——这就是最典型的锁竞争热点。等你学完这一篇,再看到这种日志,你一眼就能说出:这些线程正堵在 EntryList 上等锁,而锁大概率已经膨胀成了重量级锁

随后面试官微微一笑,抛出了那个经典问题:

"synchronized 和 ReentrantLock 有什么区别?"

90% 的候选人会脱口而出:"synchronized 是重量级锁,ReentrantLock 是轻量级锁"。面试官心里已经给你打了标签——停留在 JDK 5 的认知。

事实上,从 JDK 6 开始,JVM 团队对 synchronized 做了翻天覆地的优化,引入了偏向锁、轻量级锁、锁膨胀等机制,让 synchronized 在大多数场景下的性能已经不输甚至优于 ReentrantLock。HotSpot 虚拟机甚至为此重写了大半个 monitor 模块。这篇文章,我们就来拆解 synchronized 背后的锁升级机制——从一把"笨锁"进化成"智能锁"的全过程。先给你一个 30 秒版答案,后面每一站都是在给这句话"上细节":

一句话点破

synchronized 是 JVM 层面的内置锁,锁的信息存在对象头 Mark Word 里,会经历无锁 → 偏向锁 → 轻量级锁 → 重量级锁的升级,升级单向不可逆;JDK 6 之后经过大量优化,性能已不输 ReentrantLock,默认优先用它;只有当需要可中断、超时获取、公平锁、多条件队列这些能力时,才轮到 ReentrantLock 上场。

发车之前,先问自己三个问题:

  • Mark Word 有多少位?偏向锁时里面存的是什么?
  • 锁能升级,能降级吗?
  • ObjectMonitor 的 EntryList 和 WaitSet 有什么区别?

带着这些问题,我们从最底层开始,一步步拆解。

第 2 站

三种用法:synchronized 到底锁的是谁?

很多人在面试第一关就翻车,不是不知道 synchronized,而是说不清"它到底锁在哪个对象上"。先背下这张表,它是整篇文章的地基:

用法锁的对象等价写法
修饰实例方法this(当前实例对象)synchronized (this) { … }
修饰静态方法类名.class(Class 对象)synchronized (X.class) { … }
修饰代码块任意指定的对象synchronized (obj) { … }
SyncUsageDemo.java · 三种用法(可直接运行)
public class SyncUsageDemo {

    private int count = 0;
    private final Object lock = new Object();

    // 用法一:修饰实例方法 —— 锁的是 this(当前实例)
    public synchronized void instanceMethod() {
        count++;
    }

    // 用法二:修饰静态方法 —— 锁的是 SyncUsageDemo.class
    public static synchronized void staticMethod() {
        System.out.println("静态方法锁的是 Class 对象");
    }

    // 用法三:修饰代码块 —— 锁的是你指定的对象
    public void blockMethod() {
        synchronized (lock) {
            count++;
        }
    }

    public static void main(String[] args) {
        SyncUsageDemo demo = new SyncUsageDemo();
        demo.instanceMethod();        // 锁 this
        SyncUsageDemo.staticMethod(); // 锁 Class 对象
        demo.blockMethod();           // 锁自定义 lock 对象
    }
}

背下用法只是第一步,真正考的是你知不知道它们之间互不互斥。这里有三个高频坑,每一个都值得单独记住:

三个高频坑
  • 坑一:两个实例的同步实例方法互不干扰。因为锁的 this 不同——new 出 10 个对象,就有 10 把独立的锁。所以"给实例方法加 synchronized 就能全局互斥"是错的。
  • 坑二:静态同步方法对"所有实例"都互斥。因为 Class 对象在 JVM 里全局唯一,一个类的所有实例共享这一把锁。
  • 坑三:静态同步方法和实例同步方法之间不互斥。一个锁 Class、一个锁 this,两者井水不犯河水。这也是面试最爱的追问点。
追问:"synchronized 修饰静态方法和修饰实例方法,锁的是同一个东西吗?"
点破

不是。静态方法锁的是 X.class(Class 对象),实例方法锁的是 this。想在代码块里锁 Class 对象,可以写 synchronized (X.class)synchronized (this.getClass())——同一运行时类,getClass() 返回的是同一个 Class 对象,等价于静态方法。另外记住:锁的对象必须是引用类型,基本类型(int、boolean)不能加锁,编译直接报错。

落地建议

生产代码里,代码块 + 专用锁对象(第 14 站细讲)是主流写法,因为它能精确控制锁的粒度——只锁真正需要保护的几行,而不是整个方法。方法级 synchronized 适合"整个方法就是临界区"的简单场景。

第 3 站

对象头结构:锁信息藏在哪?

在 HotSpot 虚拟机中,每一个 Java 对象在内存中都由三部分组成:对象头(Object Header)、实例数据(Instance Data)和对齐填充(Padding)。锁的所有状态信息,都存储在对象头的 Mark Word 里——这就是"synchronized 是对象级锁"这句话的物理基础。

对象头组成 · 64 位 JVM
// 64 位 JVM 下的对象头结构
┌─────────────────────────────────────┐
 Mark Word(8 字节 = 64 位)          ← 锁状态、哈希码、GC 分代年龄
├─────────────────────────────────────┤
 Klass Pointer(4/8 字节)             ← 指向类元数据(开启指针压缩后 4 字节)
├─────────────────────────────────────┤
 数组长度(仅数组对象,4 字节)        ← 如果是普通对象则没有这部分
└─────────────────────────────────────┘

重点在 Mark Word。它是一个 64 位的位图,不同锁状态下存储的内容完全不同——同一个位置,无锁时放 hashCode,偏向锁时放线程 ID,轻量级锁时放指向 Lock Record 的指针,重量级锁时放指向 ObjectMonitor 的指针:

64 位 JVM Mark Word 结构(8 字节 = 64 bits) 无锁态: unused+cms_free (26 bits) + hashCode (31 bits) age(4) 0 01 ← 锁标志位 偏向锁: Thread ID (54 bits) + epoch (2 bits) age(4) 1 01 轻量级锁: 指向 Lock Record 的指针 (62 bits) 00 重量级锁: 指向 ObjectMonitor 的指针 (62 bits) 10 GC 标记: forwarding pointer (62 bits) 11 面试关键点 最低 2 位是「锁标志位」:01 = 无锁/偏向、00 = 轻量级、10 = 重量级、11 = GC 标记 偏向锁与无锁共用标志位 01,通过第 3 低位(biased_lock = 1/0)区分
图 164 位 JVM 的 Mark Word 在不同锁状态下存储不同内容,最低 2 位是锁标志位

把位段分配整理成一张速查表,面试画图就靠它:

锁状态Mark Word 位段(高位 → 低位)锁标志位
无锁unused 25 + hashCode 31 + cms_free 1 + age 4 + biased_lock 101
偏向锁thread 54 + epoch 2 + age 4 + biased_lock 1(另 1 位保留)01
轻量级锁指向 Lock Record 的指针 6200
重量级锁指向 ObjectMonitor 的指针 6210
GC 标记转发指针(forwarding pointer)6211

几个容易被追问的细节,一次讲透:

  • age(4 bit):GC 分代年龄,最大 15。对象每熬过一次 Minor GC 年龄 +1,达到阈值(默认 15)晋升老年代。这就是为什么 JVM 调优时晋升阈值最大只能设 15——位段只有 4 bit。
  • hashCode(31 bit):存的是 identity hash code(System.identityHashCode / 未重写的 hashCode()),而且是懒计算的——第一次调用才写入 Mark Word。
  • biased_lock + lock(1 + 2 bit):偏向锁和无锁的标志位都是 01,靠第 3 低位区分——这个设计省了标志位空间。
为什么"调用过 hashCode() 的对象进不了偏向锁"?

无锁态的 Mark Word 里放的是 hashCode(31 bit),偏向锁态放的是线程 ID(54 bit)——一个对象既想存 hashCode 又想存线程 ID,64 位根本放不下。所以一旦某个对象调用过 identity hash code(hashCode() 被写入),它就不能进入偏向锁;反过来,偏向锁期间调用 hashCode(),必须先撤销偏向,把 Mark Word 恢复成能放 hashCode 的形态。JDK 15 废弃偏向锁(JDK 18 彻底移除)后,这个限制也随之消失。这个点答出来,面试官会眼前一亮。

顺带一提:Mark Word 的大小随 JVM 位数变化——32 位 JVM 是 32 bit(8 字节对象头),64 位 JVM 是 64 bit;而 Klass Pointer 在开启指针压缩(-XX:+UseCompressedOops,JDK 8 默认)时只占 4 字节。面试默认按 64 位、未压缩或压缩场景说清楚即可。

第 4 站

管程 Monitor:Owner / EntryList / WaitSet

先补一个操作系统课上的概念:管程(Monitor)。它是并发编程里最经典的一等公民——把「互斥」和「条件等待」打包在一起的管理机制。Java 里每个对象都天生带一个 monitor,synchronized 加锁,本质就是去抢这个对象的 monitor;而重量级锁的底层实现,就是 HotSpot 的 ObjectMonitor 对象。

餐厅叫号的类比来理解 ObjectMonitor 的内部结构,三组"人"各司其职:

  • Owner:当前正在"用餐"的线程——同一时刻只能有一个,也就是持锁者;
  • EntryList:门口排队等位的线程——想吃饭(抢锁)没抢到,先排队等着叫号;
  • WaitSet:已经坐下、但点的菜还没上、暂时"让出座位"的线程——调用了 wait(),等 notify() 上菜才能继续。
ObjectMonitor 内部结构 Mark Word → 指向 ObjectMonitor ObjectMonitor _owner: Thread* (当前持有锁的线程) _count: int (重入计数器) _cxq: ObjectWaiter* (竞争链表,新来线程先入此) _EntryList: ObjectWaiter* (阻塞队列,等待获取锁) _WaitSet: ObjectWaiter* (已获取锁但调用了 wait() 的线程) _recursions: int (递归/重入次数) Thread-B Thread-C (已 wait()) 三个队列的区别 cxq: 刚被 park 的线程 (无锁链表入队,性能优化) EntryList: 从 cxq 转移 等待被 unpark 竞争锁 WaitSet: 持有锁后调了 wait(),等 notify() 唤醒
图 2ObjectMonitor 管理三组队列:Owner 持锁、EntryList 排队等锁、WaitSet 等待条件

围绕这三组结构,synchronized 的每个动作都有了明确落点:

操作行为涉及队列
加锁(monitorenter)如果 _owner 为空则获取;否则 park 线程cxq → EntryList
解锁(monitorexit)释放 _owner,从 EntryList 唤醒一个线程EntryList 出队
wait()释放 _owner,当前线程进入 WaitSet→ WaitSet
notify()从 WaitSet 移一个线程到 EntryListWaitSet → EntryList
notifyAll()将 WaitSet 所有线程移到 EntryListWaitSet → EntryList

顺便解释一下图里的 _cxq:新来的竞争线程会先进入这个无锁链表,避免大家同时往 EntryList 上加锁造成二次竞争;解锁时再批量转移到 EntryList。这是 HotSpot 的性能细节,面试时主动提一句是明显加分项。

核心要点

一句话记牢三者的分工:Owner 是"正在持锁"、EntryList 是"排队等锁"、WaitSet 是"等条件"。后面第 11 站讲 wait/notify 时,你会反复用到这张图。

第 5 站

偏向锁 (Biased Locking):一个人的独角戏

HotSpot 团队在大量真实应用中发现了一个关键观察:大多数锁在整个生命周期中只被同一个线程访问。比如一个 StringBuffer 对象几乎总是在同一个线程里被 append,一个连接池的内部状态通常由单个管理线程操作。

基于这个观察,偏向锁的设计思路非常直接:既然只有一个线程来,那就把锁"偏向"给它,后续访问连 CAS 都不需要做。

生活类比:专属停车位

想象一个小区物业,给每辆车安排了专属停车位:第一次停车时,物业在车位上写上你的车牌号(CAS 把线程 ID 写入 Mark Word);之后你每次来,直接停进去就行,连登记都不需要(零开销放行)。只有别的车来停,物业才需要处理——撤销你的专属权(偏向撤销),然后按普通车位规则来。

偏向锁的核心:第一次加锁用 CAS 把线程 ID 写入 Mark Word,后续同一线程再次进入同步块时,只需要比较 Mark Word 中的线程 ID 是否是自己——如果是,直接放行,零开销。

偏向锁的获取流程:

偏向锁获取 · 伪代码
// 第一次进入 synchronized 块
if (markWord.bias_pattern == 1 && markWord.threadId == 0) {
    // 对象可偏向,且还没有偏向任何线程
    CAS(markWord.threadId, 0, currentThreadId);  // CAS 写入当前线程 ID
    return; // 加锁成功,几乎无开销
}

// 后续进入 synchronized 块
if (markWord.threadId == currentThreadId) {
    return; // 直接放行!不需要任何原子操作
}

// 其他线程来竞争 → 偏向锁撤销 → 升级为轻量级锁
revokeBias();

关键优势在于 零 CAS 开销。传统加锁每次进入同步块都需要 CAS 操作,偏向锁只需要第一次 CAS,之后就是普通的内存读比较——这比 CAS 快一个数量级。

偏向锁撤销(revocation)有个重要细节:撤销必须等到安全点(SafePoint)。JVM 要停住所有线程,检查持有偏向的线程状态:

  • 偏向线程已结束(或不再使用该对象)→ 恢复为无锁、可重新偏向;
  • 偏向线程还活着且在临界区内 → 撤销偏向,升级为轻量级锁。

正因为撤销要走安全点、可能引发短暂的 Stop-The-World 暂停,偏向锁在高竞争场景下反而是负优化——这也是它最终被废弃的核心原因之一。

JDK 15 废弃、JDK 18 移除偏向锁(准确版本事实)

偏向锁的设计初衷是好的,但它带来了巨大的代码复杂度。HotSpot 中与偏向锁相关的代码超过 2 万行,涉及 GC、类加载、锁撤销等多个子系统的协调。

JEP 374(JDK 15)正式废弃偏向锁并默认关闭-XX:-UseBiasedLocking);JDK 18 起彻底移除,连开关都不再支持。原因:

  • 现代应用更多是多线程并发,"只有一个线程访问"的场景变少;
  • 偏向锁撤销涉及 SafePoint,可能导致 STW 暂停;
  • 维护成本高,收益递减——轻量级锁本身已经很快了。

面试时要说:偏向锁在 JDK 15+ 默认关闭并废弃,JDK 18 彻底移除;现代 JVM 直接从轻量级锁起步。千万别再说"synchronized 永远很慢"这种过时结论。

第 6 站

轻量级锁 (Lightweight Lock):短暂竞争的最优解

当偏向锁遇到竞争(另一个线程也来加锁),或者 JDK 15+ 直接跳过偏向锁阶段,锁就会进入轻量级锁状态。

轻量级锁的设计假设是:锁的竞争是短暂的——线程 A 马上就会释放,线程 B 只需要稍微"等"一下就拿到了。这种情况下,不值得动用操作系统级别的线程挂起(那涉及用户态到内核态的切换,代价高昂)。

生活类比:等电梯 vs 叫保安

竞争很短暂,就像等电梯:A 马上从 5 楼下来,你在门口等一两秒就行,没必要呼叫物业(OS 内核)派人来处理。等电梯就是自旋(spin)——用户态空转等待;叫保安处理就是挂起(park)——线程进入内核态阻塞,等系统调度唤醒。

核心机制是 Lock Record(锁记录),它位于线程栈帧中:

BasicLock · 栈帧中的锁记录
// 每个线程栈帧中都有若干 Lock Record
// 用于在轻量级锁阶段保存 Mark Word 的备份
struct BasicLock {
    volatile markOop displaced_header;  // 保存对象原始的 Mark Word
};

// 加锁流程:
// 1. 在当前线程栈帧中创建一个 Lock Record
// 2. 把对象的 Mark Word 复制到 Lock Record 的 displaced_header
// 3. CAS 尝试将 Mark Word 替换为指向 Lock Record 的指针
// 4. CAS 成功 → 拿到锁(锁标志位变为 00)
// 5. CAS 失败 → 存在竞争 → 可能膨胀为重量级锁

轻量级锁的完整获取与释放序列:

线程栈帧 方法调用帧 Lock Record displaced_header 局部变量 ① 复制 Mark Word 对象(堆上) Mark Word (64 bits) 原始值: hashCode|age|01 Klass Pointer 实例数据 ② CAS 替换 Mark Word → 锁记录指针 加锁序列 ① 复制 Mark Word 到 Lock Record ② CAS(MarkWord, 原始值, LockRecordPtr) CAS 成功 → 标志位变 00,拿到锁 CAS 失败 → 竞争,膨胀为重量级锁 解锁序列 ③ CAS(MarkWord, LockRecordPtr, 原始值) CAS 成功 → 释放锁,恢复 Mark Word CAS 失败 → 有线程在等,唤醒它们
图 3轻量级锁通过 CAS 在线程栈帧和对象头之间交换 Mark Word,避免操作系统级别的线程挂起

这里要澄清一个常见的模糊表述:轻量级锁加锁用的是 CAS,竞争失败后 JVM 并不会立刻挂起线程,而是先短暂自旋(spin)等待持锁者释放,这就是自适应自旋(Adaptive Spinning)——JVM 根据历史上该锁的竞争情况动态调整自旋次数:上次自旋成功,这次就多转几圈;上次失败,这次就少转。自旋还是拿不到锁,才走锁膨胀。记住这条分界线:

轻量级锁 = CAS + 自旋(用户态忙等)
重量级锁 = 线程挂起/唤醒(内核态阻塞,park/unpark)

竞争不激烈时,轻量级锁通常比重量级锁快一个数量级
(省掉了用户态 ↔ 内核态的切换)
第 7 站

锁膨胀 (Lock Inflation):从轻到重的临界点

当轻量级锁的 CAS 操作失败、自旋也等不到锁——意味着有另一个线程也在持续竞争同一把锁,竞争已经从"短暂"变成了"持续"。此时 JVM 会执行锁膨胀,将轻量级锁升级为重量级锁

膨胀过程不可逆(在绝大多数 JDK 版本中),这就是"锁升级"中"升级"的含义——一旦变重,就回不去了

膨胀的核心步骤:

inflate() · 锁膨胀伪代码
ObjectMonitor* inflate(Thread* self, oop obj) {
    // 1. 为对象创建一个 ObjectMonitor(如果不存在)
    ObjectMonitor* monitor = new ObjectMonitor();

    // 2. 将对象的 Mark Word 从"指向 Lock Record"改为"指向 ObjectMonitor"
    //    锁标志位从 00 变为 10
    obj->set_mark(markOop::encode(monitor));

    // 3. 将之前持有轻量级锁的线程放入 ObjectMonitor 的 Owner 字段
    monitor->set_owner(lockRecordOwner);

    // 4. 将当前竞争失败的线程放入 EntryList(阻塞队列)
    monitor->enter(self);  // 线程被 park(),进入内核态阻塞

    return monitor;
}
为什么锁不能降级?

理论上,当竞争消失后,重量级锁可以"降级"回轻量级锁以恢复性能。但实际上,HotSpot 的实现中锁降级非常有限。原因:

  • 降级逻辑复杂——需要确认 EntryList 为空、没有线程在 wait() 等;
  • 降级本身有开销——CAS 修改 Mark Word、清理 ObjectMonitor;
  • 经验数据表明:竞争激烈过的锁,未来大概率还会竞争。

JDK 8u20 之后引入了有限的锁降级,但只在安全点(SafePoint)批量进行,不是实时降级。面试答"锁升级单向不可逆,仅有安全点批量降级"就到位了。

第 8 站

重量级锁 (Heavyweight Lock):为什么它最慢

重量级锁的底层是 ObjectMonitor(第 4 站的结构图),它通过操作系统的 Mutex 和 Condition Variable 实现线程的阻塞与唤醒。synchronized 编译后,每个同步块对应两条字节码指令 monitorentermonitorexit

字节码 · synchronized 编译结果
// Java 源码
synchronized (obj) {
    count++;
}

// 编译后的字节码(javap -c 输出)
  0: aload_1           // 加载 obj 到操作数栈
  1: dup               // 复制一份引用(用于 finally 中的 monitorexit)
  2: monitorenter     // 尝试获取 monitor 锁
  3: aload_0
  4: dup
  5: getfield #2      // 读取 count
  8: iconst_1
  9: iadd
 10: putfield #2      // 写回 count++
 13: aload_1
 14: monitorexit      // 正常退出,释放 monitor 锁
 15: goto 23
 18: astore_2          // 异常处理入口
 19: aload_1
 20: monitorexit      // 异常退出,也要释放锁!
 21: athrow            // 重新抛出异常
 23: return

注意字节码里有两个 monitorexit:一个是正常路径(第 14 行),一个是异常路径(第 20 行,由编译器自动生成的 finally 块)。这就是为什么 synchronized 能保证即使抛出异常,锁也一定会被释放——语法层面的保证,不需要你写 finally。

这也是 synchronized 与 ReentrantLock 最本质的差异:一个在 JVM 层(字节码指令),一个在 JDK 层(java.util.concurrent 的类)。前者由 JVM 解释执行加解锁语义,后者需要你手动 lock()/unlock() 并自己保证 finally 释放。

为什么重量级锁最慢?

慢不在"锁"本身,而在线程的挂起与唤醒

  • 用户态 ↔ 内核态切换park/unpark 要陷入内核,一次切换代价在微秒级,频繁切换直接吃掉吞吐;
  • 线程上下文切换:被唤醒的线程要重新进入运行队列、竞争 CPU,涉及寄存器保存恢复、缓存失效;
  • 调度延迟:从"锁释放"到"等待线程真正拿到 CPU 继续执行",中间隔了好几步系统调度,延迟远高于自旋。

所以重量级锁只有在临界区长、竞争激烈(持锁时间远大于切换成本)时才是合理选择;临界区极短时,宁可让竞争者自旋。

核心要点

整条链路的性能逻辑一句话:能用内存操作(CAS)解决就不用自旋,能用自旋解决就不用挂起线程——每一级优化都是在"少进一次内核"。第 1 站事故里几百个线程 BLOCKED,说明锁已经膨胀到重量级、线程全在挂起等锁,这就是性能雪崩的典型现场。

第 9 站

可重入性 (Reentrancy):同一线程能反复进

什么叫可重入(Reentrant)?就是同一个线程可以重复进入自己已经持有的锁。写业务代码时这几乎是必然发生的:一个 synchronized 方法调另一个 synchronized 方法,或者递归调用,都会"重复进入"。如果锁不可重入,那么方法一调用方法二,自己就把自己锁死了——这叫自死锁(self-deadlock)

ReentrantDemo.java · 可重入演示(可直接运行)
public class ReentrantDemo {

    // 场景一:synchronized 方法嵌套调用
    public synchronized void a() {
        System.out.println("进入 a(),持有锁");
        b();  // 同一个线程再次进入 synchronized —— 可重入,不会死锁
    }

    public synchronized void b() {
        System.out.println("进入 b(),锁可重入");
    }

    // 场景二:递归调用
    public synchronized int factorial(int n) {
        return n <= 1 ? 1 : n * factorial(n - 1);
    }

    public static void main(String[] args) {
        ReentrantDemo demo = new ReentrantDemo();
        demo.a();                          // 嵌套调用,正常运行不卡死
        System.out.println("10! = " + demo.factorial(10)); // 递归,正常运行
    }
}

原理一句话:JVM 在 monitor 里记录了持有者线程(_owner)重入计数(_recursions)。同一线程再次进入,只做一次判断——_owner == 当前线程,成立就计数 +1 直接放行;每退出一层计数 -1,减到 0 才真正释放锁。所以在轻量级锁阶段,重入时 Mark Word 指向的 Lock Record 不需要变——这就是为什么同一个线程可以在同一个对象上加锁多次而不乱套。

追问:"synchronized 和 ReentrantLock 都可重入吗?实现上有什么区别?"

都可重入。synchronized 靠的是 JVM 内部的 _owner + _recursions;ReentrantLock 靠的是 AQS 的 state 字段——同一线程再次 lock() 时 state +1,释放时逐步减,减到 0 才算真正释放(详见 AQS 那一篇)。共同点:都通过"持有者身份 + 计数"实现可重入。反过来,不可重入的锁(如早期的自旋锁实现)遇到递归或嵌套调用就会自死锁——这也是面试官爱出的陷阱题。

第 10 站

锁升级全景图:状态机总览

现在把前几站的内容串联起来,看完整的锁升级状态机:

无锁 标志位: 01 偏向锁 标志位: 01 biased=1 轻量级锁 标志位: 00 重量级锁 标志位: 10 首次加锁 另一线程 竞争 JDK 15+ 跳过偏向锁,直接轻量级 CAS 失败 (膨胀) 锁升级是单向的 —— 膨胀不可逆(绝大多数 JDK 版本) 各级锁的相对性能(单线程基准 ≈ 100%) 无锁: ~100% 偏向锁: ~97% 轻量级锁: ~85% 重量级锁: ~55% * 以上为典型场景近似值,具体取决于 JVM 版本和竞争模式
图 4锁升级状态机:从无锁到重量级锁的完整升级路径,膨胀不可逆
锁升级触发条件
无锁 → 偏向锁:第一次加锁,CAS 写入线程 ID
偏向锁 → 轻量级锁:另一个线程尝试加锁(撤销偏向)
轻量级锁 → 重量级锁:CAS 替换 Mark Word 失败(检测到真实竞争)

JDK 15+ 之后:无锁 → 轻量级锁 → 重量级锁(偏向锁已移除)
关于"锁降级",面试怎么说

标准答案是:锁升级单向不可逆,重量级锁不会自动降级回轻量级锁。严格补充一句:JDK 8u20 之后存在有限的安全点批量降级,把已无竞争的重量级锁恢复为轻量级锁,但它不是实时的、也不面向普通业务代码,所以面试时先把"单向不可逆"答对,再提降级细节当加分项。

第 11 站

wait / notify:与 Monitor 的 WaitSet 打交道

前面第 4 站的图里,WaitSet 一直没细讲。现在来补上——wait()notify()notifyAll() 是 synchronized 体系里"线程间协作"的三板斧,它们全部围绕 WaitSet 工作。

先记住第一条铁律:这三个方法必须持有锁才能调用(即在 synchronized 块/方法内),否则抛 IllegalMonitorStateException。原因很直白:wait() 要"释放锁并把线程放进 WaitSet",notify() 要"操作 WaitSet 把线程移出去"——这俩动作都动 monitor 的内部结构,没有锁就没有操作资格。

线程在 Owner / EntryList / WaitSet 之间的流转 Owner 当前持锁线程(同一时刻一个) EntryList 等锁线程(BLOCKED) WaitSet 等条件线程(WAITING) wait():释放锁 → 进 WaitSet 等条件 notify()/notifyAll():移到 EntryList 重新等锁 唤醒后抢到锁 → 成为新 Owner BLOCKED = 在 EntryList 等锁;WAITING = 在 WaitSet 等条件;持锁线程在 Owner
图 5wait/notify 的完整流转:wait 释放锁进 WaitSet,notify 移回 EntryList,抢到锁回到 Owner

完整流程走一遍:

  • 线程持有锁(Owner)时调用 wait()释放锁,线程进入 WaitSet,状态变为 WAITING;
  • 另一个线程持锁后调用 notify() → 从 WaitSet 挑一个线程移到 EntryList(状态变 BLOCKED,重新排队等锁);
  • notifyAll() → 把 WaitSet 里所有线程都移到 EntryList;
  • 被唤醒的线程在 EntryList 抢到锁后,才回到 Owner 继续执行 wait() 之后的代码。

一个可以直接复制运行的经典生产者-消费者示例:

WaitNotifyDemo.java · wait/notifyAll 实现生产者消费者(可直接运行)
public class WaitNotifyDemo {

    private static final Object LOCK = new Object();
    private static int count = 0;
    private static final int CAP = 5;   // 容量上限

    public static void main(String[] args) {
        // 生产者:满了就等,不满就生产并唤醒消费者
        new Thread(() -> {
            while (true) {
                synchronized (LOCK) {
                    while (count == CAP) {          // 注意是 while,不是 if
                        try { LOCK.wait(); } catch (InterruptedException e) {
                            Thread.currentThread().interrupt();
                        }
                    }
                    System.out.println("生产,当前数量: " + (++count));
                    LOCK.notifyAll();                     // 唤醒消费者
                }
            }
        }, "生产者").start();

        // 消费者:空了就等,不空就消费并唤醒生产者
        new Thread(() -> {
            while (true) {
                synchronized (LOCK) {
                    while (count == 0) {          // 注意是 while,不是 if
                        try { LOCK.wait(); } catch (InterruptedException e) {
                            Thread.currentThread().interrupt();
                        }
                    }
                    System.out.println("消费,剩余数量: " + (--count));
                    LOCK.notifyAll();                     // 唤醒生产者
                }
            }
        }, "消费者").start();
    }
}
为什么条件判断必须用 while,不能用 if?

因为存在虚假唤醒(spurious wakeup):线程可能在没有被 notify 的情况下被唤醒(JVM/OS 允许这样做)。如果只用 if 判断一次条件,唤醒后直接往下走,很可能条件已经不成立(比如队列又满了/又空了),导致数据错乱。用 while 循环的话,被唤醒后会重新检查条件,不成立就继续 wait——这是所有 wait/notify 代码的硬性规范。

还有两个高频对比,面试必考:

维度wait()sleep()
谁的静态方法Object 的实例方法Thread 的静态方法
是否释放锁释放(进 WaitSet)不释放(抱着锁睡)
是否需要 synchronized必须(否则 IllegalMonitorStateException)不需要
如何唤醒notify/notifyAll(或虚假唤醒)时间到自动醒(可被 interrupt)
典型用途线程间协作(等条件)定时/暂停(不涉及协作)
追问:"notify() 和 notifyAll() 怎么选?"
点破

notify() 只唤醒 WaitSet 里一个线程——如果唤醒的不是恰好等到了自己条件的那个,被唤醒的线程会重新 wait,甚至可能饿死notifyAll() 全部唤醒,让它们重新竞争、各自重新检查条件,最安全。生产实践:条件简单、只有一个消费者时用 notify() 可以;条件复杂或不确定时,一律用 notifyAll(),牺牲一点"惊群"开销换正确性。

第 12 站

JIT 锁优化:锁消除与锁粗化

除了加锁时的锁升级,JIT 编译器还会在运行时做两类"拆锁"优化——这是面试官验证你是否真的懂 synchronized 性能的试金石。

锁消除(Lock Elision)

JIT 通过逃逸分析(Escape Analysis)判断一个对象会不会"逃逸"出当前线程/方法。如果对象是方法内的局部变量、没有被其他线程看到(不逃逸),那么对它的 synchronized 加锁就完全可以安全地抹掉

锁消除 · 局部 StringBuffer
public String joinNames(List<String> names) {
    StringBuffer sb = new StringBuffer();  // sb 是局部变量,不会逃逸出方法
    for (String name : names) {
        sb.append(name).append(",");      // append 是 synchronized 的
    }
    return sb.toString();                  // 但 sb 没被别的线程看到 → 锁可被消除
}

这段代码里每次 append 都带锁,但因为 sb 根本不逃逸,JIT 会把这些 synchronized 全部消除——所以"StringBuffer 慢"在局部变量场景下是个伪命题。这也是为什么规范常说"局部拼串用 StringBuilder 就行"——它省的是语法层面的锁,而不是运行时优化兜底。

锁粗化(Lock Coarsening)

反过来,如果 JIT 发现同一个线程对同一把锁短时间内反复加解锁,它会把这些相邻的加解锁合并成一把大锁,减少加解锁的次数:

锁粗化 · 循环内加锁
// 优化前:每次循环都加锁/解锁
for (int i = 0; i < 1000; i++) {
    synchronized (lock) {
        sum += i;
    }
}

// 优化后(JIT 自动粗化):合并成一把大锁
synchronized (lock) {
    for (int i = 0; i < 1000; i++) {
        sum += i;
    }
}

注意粗化是有代价的:持锁时间变长了,其他线程等待更久。所以 JIT 会权衡——临界区太长就不粗化。它是个"兜底优化",不该成为你随意写锁的借口。

生产启示
  • 锁消除、锁粗化依赖逃逸分析,JDK 8 服务端模式默认开启(-XX:+DoEscapeAnalysis-XX:+EliminateLocks);
  • 它们是运行时兜底,别依赖它救性能——该用 StringBuilder 就用 StringBuilder,该缩小临界区就缩小;
  • 也别因为"JIT 会优化"就乱加锁——优化只在特定场景生效,正确性永远靠自己。
第 13 站

synchronized vs ReentrantLock:怎么选

这是整篇最常被面试官追着问的对比,把表格记牢:

synchronized vs ReentrantLock 全面对比
特性synchronizedReentrantLock
实现层面JVM 内置(字节码指令 monitorenter/monitorexit)JDK 层面(AQS + CAS)
锁释放自动释放(字节码 finally 保证)必须手动 unlock()(通常放 finally)
可重入支持(_owner + _recursions)支持(AQS state 计数)
可中断不可中断lockInterruptibly() 支持
超时获取不支持tryLock(timeout, unit) 支持
公平锁不支持(非公平)new ReentrantLock(true) 支持公平
条件变量一个(Object.wait/notify)多个(newCondition(),可建多个条件队列)
锁升级有(无锁→偏向→轻量→重量)无(state 直接计数)
jstack 可读性能看到 synchronized 方法名能看到 lock 的线程及"parking to wait for"
性能(JDK 6+)锁升级后接近 ReentrantLock基于 AQS + CAS,高竞争下稳定
可读性简洁,关键字语法稍复杂,需要 try-finally

什么场景必须用 ReentrantLock?四个刚需,缺一个都用不上它:

  • 需要可中断地等锁:比如线程池 shutdownNow() 要打断正在等锁的线程,synchronized 做不到(等锁期间不可中断);
  • 需要超时获取tryLock(3, TimeUnit.SECONDS)——等 3 秒拿不到就放弃,避免无限阻塞;
  • 需要公平锁:防止线程饥饿,代价是吞吐下降;
  • 需要多个条件队列:一个锁配多个 Condition(如"队列不满"和"队列不空"两个条件),wait/notify 只有一套。
LockDemo.java · 可中断 + 超时获取(ReentrantLock 的刚需场景)
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;

public class LockDemo {
    private final ReentrantLock lock = new ReentrantLock();

    public void doJob() {
        try {
            // 等锁过程中可以被 interrupt,也可以超时放弃
            if (lock.tryLock(3, TimeUnit.SECONDS)) {
                try {
                    // 临界区
                } finally {
                    lock.unlock();   // 手动释放,必须 finally
                }
            } else {
                System.out.println("3 秒没拿到锁,放弃");
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();  // 恢复中断标志位
        }
    }
}
选型建议(面试标准答案)

默认用 synchronized:简洁、自动释放、JVM 持续优化(锁升级),出错概率低;只有明确需要可中断 / 超时 / 公平 / 多条件队列之一时,才换 ReentrantLock。这句话说出口,面试官就知道你不是背的。

第 14 站

生产实践:锁对象怎么选

写 synchronized 最容易翻车的不是语法,而是选错了锁对象。三个真实生产坑,每一个都酿过线上事故。

LockObjectDemo.java · 锁对象的正确与错误示范
public class LockObjectDemo {

    // ❌ 错误示范 1:锁 String 字面量 —— 常量池陷阱
    private static final String LOCK = "pay";
    // "pay" 在字符串常量池里全局只有一份!另一个模块也写 synchronized ("pay"),
    // 两把"不同的锁"其实是同一把锁 → 毫无关系的代码互相阻塞,甚至死锁。

    // ❌ 错误示范 2:锁 Integer / Long 包装类型 —— 缓存陷阱
    private static final Integer LOCK2 = 1;
    // Integer.valueOf(1) 命中 -128~127 缓存:所有锁"1"的代码共享同一个 Integer 对象
    // 你的锁可能和 JDK 内部、第三方库的锁撞在一起。

    // ❌ 错误示范 3:锁的引用被重新赋值 —— 锁了个寂寞
    private static Integer counter = 0;
    public static void badIncr() {
        synchronized (counter) {   // 锁的是当前 counter 指向的对象
            counter++;                  // 拆箱 + 加 1 + 装箱 → counter 指向了另一个对象!
        }                               // 下一次加锁锁的是新对象 → 并发完全失效
    }

    // ✅ 正确示范:private final 专用锁对象
    private final Object lock = new Object();  // 每个实例一把独立锁,谁也碰不到
    public void pay() {
        synchronized (lock) {
            // 临界区
        }
    }
}
三个坑的原理
  • String 字面量:字符串字面量会 intern 进常量池,同一个 "pay" 在整个 JVM 里只有一个实例。所以"锁字面量"等于"全 JVM 共用一把锁",作用范围完全失控;
  • 包装类型缓存Integer.valueOf()-128 ~ 127 返回缓存对象,Long 同理。锁一个"值"可能撞上别的代码锁同一个"值";超出缓存范围时,自动装箱又可能产生新对象,导致加锁对象不一致、锁形同虚设
  • 引用被重新赋值synchronized (counter) 锁的是"引用当前指向的对象",一旦 counter++ 让引用指向新对象,下一次加锁就锁到了别的对象上——两个线程各锁各的,并发安全瞬间归零。这是包装类型锁里最阴的坑。
锁对象选择清单
  • 首选private final Object lock = new Object()——专用、独立、final 防重新赋值;
  • 别锁:String 字面量、包装类型(Integer/Long/Boolean)、this(范围太大)、Class 对象(全局唯一,等于全局锁,除非你就要全局互斥);
  • 锁粒度:能锁代码块就别锁整个方法,能用细粒度锁(多个小锁对象/分段)就别用一把大锁;但多个锁同时出现时,务必保证全局一致的加锁顺序,否则就是死锁温床。
第 15 站

锁竞争排查实战:jstack 怎么看

回到第 1 站的事故。学了整篇,现在教你怎么用 5 分钟定位锁竞争热点。

jstack 里,线程在锁上的等待只有两种状态,一眼分清:

jstack · BLOCKED vs WAITING
// 情况一:BLOCKED —— 在 EntryList 排队等锁(等"锁")
"http-nio-8080-exec-42" #42 ...
   java.lang.Thread.State: BLOCKED (on object monitor)
        at com.demo.order.OrderService.createOrder(OrderService.java:58)
        - waiting to lock <0x00000007ff8f4d10> (a java.lang.Object)

// 情况二:WAITING —— 在 WaitSet 等条件(等"通知")
"pool-1-thread-3" #13 ...
   java.lang.Thread.State: WAITING (parking)
        at jdk.internal.misc.Unsafe.park(Native Method)
        - parking to wait for <0x00000007ff8f4d10> (a java.lang.Object)
        at java.util.concurrent.locks.LockSupport.park(...)

BLOCKED 说明它在 EntryList 等锁(第 4 站的图);WAITING (in Object.wait()) 说明它在 WaitSet 等条件(第 11 站的图)。大量 BLOCKED = 锁竞争热点,大量 WAITING 往往只是正常的等待。

排查三步走:

排查命令 · 三步定位锁热点
# 1. 抓三把线程快照(间隔 5 秒),避免误判瞬时现象
jstack <pid> > stack1.txt
sleep 5 && jstack <pid> > stack2.txt

# 2. 统计哪个锁对象被最多线程等待
grep "waiting to lock" stack1.txt | sort | uniq -c | sort -rn | head

# 3. 用锁地址反查持锁线程卡在哪
grep "locked <0x00000007ff8f4d10>" stack1.txt stack2.txt stack3.txt

找到持锁线程后,看它卡在临界区里干什么。第 1 站的订单事故,根因就是 createOrder() 的 synchronized 块里做了一次 300ms 的远程扣库存调用——锁被远程 IO 拽住,后面几百个线程全部陪葬。修复方案:

手段做法适用场景
缩小临界区把远程调用、IO、耗计算移出 synchronized大部分锁竞争的第一步
降低锁粒度分段锁、读写锁、ConcurrentHashMap读多写少、热点分散
无锁化CAS、原子类、CopyOnWrite 容器单变量更新、读多写极少
红线锁内不要 sleep、不要远程调用、不要等待永远不要
事故复盘

createOrder() 里的扣库存远程调用移出临界区(先本地锁保护状态,再异步扣减),P99 从 3s 回到 80ms,几百个 BLOCKED 线程瞬间消失。记住这句话:锁竞争优化的第一原则,永远是把临界区缩到最小,而不是换更快的锁——锁再快,也扛不住你把远程 IO 锁在里面。

监控指标

线上常盯三个数:BLOCKED 线程占比(锁竞争强度)、持锁线程的临界区耗时、线程池的活跃线程数。配合 APM 的"锁等待时间"埋点,锁热点基本无处遁形。

总结

这一篇你掌握了什么

核心知识点回顾

  • 三种用法:实例方法锁 this、静态方法锁 Class 对象、代码块锁指定对象;两个实例的实例锁互不干扰,静态锁和实例锁不互斥;
  • Mark Word:64 位位图,按锁状态复用存储——无锁放 hashCode(31bit)+ age(4bit),偏向锁放线程 ID(54bit),轻量级锁放 Lock Record 指针,重量级锁放 ObjectMonitor 指针;最低 2 位锁标志位 01/00/10/11;调用过 hashCode() 的对象进不了偏向锁;
  • 管程 Monitor:ObjectMonitor 三组结构——Owner 持锁、EntryList 排队等锁、WaitSet 等条件;新竞争线程先入 cxq 再批量转移;
  • 锁升级链路:无锁 → 偏向锁 → 轻量级锁(CAS + 自旋)→ 重量级锁(挂起线程),单向不可逆,仅安全点有有限批量降级;
  • 准确版本事实:偏向锁 JDK 15(JEP 374)默认关闭并废弃、JDK 18 彻底移除,现代 JVM 从轻量级锁起步;
  • 可重入:_owner + _recursions 计数,同一线程重复进入,减到 0 才释放;不可重入会自死锁;
  • wait/notify:必须持有锁(否则 IllegalMonitorStateException);wait 释放锁进 WaitSet,notify 移到 EntryList;条件判断必须用 while 防虚假唤醒;notifyAll 更安全;
  • JIT 锁优化:锁消除(逃逸分析,局部对象)与锁粗化(合并相邻加解锁);
  • 选型:默认 synchronized;需要可中断/超时/公平/多条件队列时才用 ReentrantLock;
  • 锁对象:首选 private final Object;别锁 String 字面量(常量池)和包装类型(-128~127 缓存、引用重赋值陷阱)。

一句话总结

synchronized = JVM 内置锁:对象头 Mark Word 存锁状态,无锁 → 偏向锁 → 轻量级锁 → 重量级锁单向升级。它的性能演进史就是一句话:能用 CAS 就不用自旋,能用自旋就不用挂起线程。JDK 6 之后它早已不是"重量级锁",默认优先用它;遇到锁竞争,第一原则永远是缩小临界区。

面试 30 秒总结

30 秒版答案:「synchronized 是 JVM 层面的互斥锁,锁状态存在对象头的 Mark Word 里,会经历无锁 → 偏向锁 → 轻量级锁 → 重量级锁的升级,升级单向不可逆;偏向锁 JDK 15 默认关闭、JDK 18 移除。轻量级锁靠 CAS + 自旋,重量级锁才挂起线程。JDK 6 之后它性能已接近 ReentrantLock,默认优先用 synchronized;只有需要可中断、超时、公平、多条件队列时才换 ReentrantLock。」

追问预案:① 锁的到底是谁?实例方法锁 this、静态方法锁 Class、代码块锁指定对象;② 可重入吗?可,_owner + _recursions 计数;③ EntryList 和 WaitSet 区别?一个等锁一个等条件;④ 为什么重量级锁慢?线程挂起涉及用户态↔内核态切换;⑤ 锁对象怎么选?private final Object,别锁 String 和包装类型。

Comments · 评论