Skip to content
Charles Shao
Go back

synchronized 与锁升级:对象头、Monitor 与偏向 / 轻量 / 重量级锁

views

很多人对 synchronized 的印象还停留在”重量级、慢、能不用就不用”。但从 JDK 6 引入锁升级机制后,synchronized无竞争或低竞争下的开销已经非常小——它会先尝试最廉价的偏向锁,竞争加剧才逐级升级到重量级锁。理解这条升级路径,才能判断什么时候 synchronized 完全够用、什么时候该换成 ReentrantLock 或无锁结构。

JMM 与可见性 可知:synchronized 不仅提供互斥,还借监视器锁规则保证可见性与有序性。这一篇拆开它的底层——对象头、Monitor、锁升级——以及何时该换成显式锁或 CAS。

TL;DR

Table of contents

Open Table of contents

1. synchronized 的三种用法与本质

synchronized 有三种写法,锁的都是某个对象

public synchronized void m() { }          // 锁 this(实例方法)
public static synchronized void s() { }    // 锁 Class 对象(静态方法)
public void b() {
    synchronized (lock) { /* 临界区 */ }   // 锁显式指定的 lock 对象
}

编译后,同步块对应一对 monitorenter / monitorexit 字节码;同步方法则在方法的访问标志上打 ACC_SYNCHRONIZED,由 JVM 在进入/退出时隐式加解锁。两者最终都归结为获取对象关联的监视器(Monitor)

而对象与它的锁状态怎么关联?答案在对象头。

2. 对象头与 Mark Word

HotSpot 中一个普通对象在堆里的内存布局分三块:对象头(Object Header)实例数据对齐填充。对象头又含两部分:

Mark Word 是复用的:它在不同锁状态下存不同内容。64 位 JVM 下的布局(简化):

锁状态        | Mark Word (64-bit) 主要内容                        | 标志位
-------------|---------------------------------------------------|------
无锁          | unused:25 | hashCode:31 | unused:1 | age:4 | 0    | 01
偏向锁        | threadID:54 | epoch:2 | unused:1 | age:4 | 1       | 01
轻量级锁      | 指向栈中 Lock Record 的指针:62                      | 00
重量级锁      | 指向 ObjectMonitor 的指针:62                       | 10
GC 标记       | (空)                                             | 11

关键点:锁标志位 + 是否偏向位,共同决定当前处于哪种锁状态。加锁/升级的过程,就是通过 CAS 修改这几个 bit 与对应字段。(想看对象头实际布局,可用 OpenJDK 的 JOL 工具 ClassLayout.parseInstance(obj).toPrintable()。)

3. 锁升级:一条单向的路

JDK 6 之后,synchronized 按竞争程度逐级升级,尽量把开销压到最低:

synchronized 锁升级路径。从左到右四个状态:无锁 → 偏向锁(同线程反复进)→ 轻量级锁(CAS+自旋)→ 重量级锁(ObjectMonitor);箭头标注首线程进入、他线程竞争/撤销、自旋失败。旁注 JDK 15+ 默认关闭偏向锁,常从无锁直入轻量级。底部说明升级单向、无竞争留用户态、竞争加剧才入内核。

升级单向不可逆:HotSpot 不做运行时锁降级(仅在 STW 的安全点有极有限的批量降级场景)。下面逐级看。

3.1 偏向锁(Biased Locking)

假设:绝大多数锁在其生命周期里只被同一个线程反复获取,从无竞争。既然如此,何必每次都做 CAS?

机制:第一次加锁时,用 CAS 把当前线程 ID 写进 Mark Word。之后该线程再进入,只需检查 Mark Word 里的线程 ID 是不是自己——是就直接进,连 CAS 都省了

撤销(Revoke):一旦有第二个线程尝试获取这把偏向锁,偏向就得撤销。撤销必须在全局安全点(safepoint) 暂停持有偏向的线程,检查它是否还在临界区,再把锁转成无锁或轻量级。撤销开销很大。

批量重偏向 / 批量撤销:JVM 以类为单位统计撤销次数,超过阈值就批量重偏向(换偏向 epoch)甚至对整个类禁用偏向,避免逐个撤销的抖动。

现状:偏向锁在竞争型工作负载下经常是负优化,且维护成本高。JDK 15(JEP 374)起默认关闭偏向锁并标记为废弃(deprecate for removal);新版本 JVM 上实际路径往往是「无锁 → 轻量级 → 重量级」。

3.2 轻量级锁(Lightweight Lock)

适用:多个线程交替进入临界区,实际很少真正碰撞,且临界区很短。

机制:线程进入同步块时,在自己的栈帧里创建 Lock Record,把对象当前 Mark Word 拷贝进去(Displaced Mark Word),再用 CAS 把对象 Mark Word 改成指向这个 Lock Record

轻量级锁示意图。左侧「线程栈帧」中有 Lock Record(含 Displaced Mark Word 副本);右侧「堆对象」Mark Word 指向该 Lock Record(标志位 00),下方为 Klass Pointer;中间箭头标注 CAS 抢占。底部说明:CAS 成功持锁,失败则自适应自旋,仍失败升级重量级。

轻量级锁的价值:在没有真正阻塞的场景下,全程在用户态用 CAS 解决,不陷入内核

3.3 重量级锁(Heavyweight Lock)

适用:竞争激烈、或临界区较长,自旋只会白白烧 CPU。

机制:Mark Word 指向一个 ObjectMonitor 结构,它内部维护:

抢不到锁的线程通过 park() 挂起,进入 _EntryList——涉及用户态到内核态切换,成本在微秒级,远高于 CAS。持锁线程 unlock 时从 _EntryList 唤醒后继者。

ObjectMonitor 结构示意图。四个芯片:_owner(持锁线程)、_recursions(重入计数)、_EntryList(阻塞等锁)、_WaitSet(wait 等待)。底部说明 park 进入 EntryList 的内核态成本,以及 wait/notify 必须在 synchronized 内调用。

wait() / notify() / notifyAll() 操作 _WaitSetwait 释放锁并进 _WaitSetnotify 把线程移回 _EntryList 重新竞争锁。这三个方法必须在 synchronized 块内调用,否则抛 IllegalMonitorStateException

4. 编译器优化:锁消除与锁粗化

JIT 还会替你省掉不必要的同步。

4.1 锁消除(Lock Elision)

通过逃逸分析证明某个锁对象不可能被多个线程共享,就直接把锁去掉。典型例子:StringBufferappend 是同步方法,但如果它是方法内的局部变量、没有逸出,JIT 会消除这些锁:

public String concat(String a, String b) {
    StringBuffer sb = new StringBuffer(); // 局部变量,不逸出
    sb.append(a).append(b);               // append 是 synchronized,但会被锁消除
    return sb.toString();
}

4.2 锁粗化(Lock Coarsening)

如果一连串相邻操作反复对同一个对象加解锁,JIT 会把它们合并成一次更大的加锁,避免频繁进出临界区:

for (int i = 0; i < n; i++) {
    synchronized (lock) { list.add(i); }  // 每次循环都加解锁
}
// 锁粗化后近似于:
synchronized (lock) {
    for (int i = 0; i < n; i++) { list.add(i); }
}

推论:不必为了”性能”手动把同步块拆到极细——JIT 的锁粗化可能比你手写的更好,而过细的同步块反而增加加解锁次数。

5. synchronized vs 显式锁:什么时候该换

synchronized 在多数场景已经够用且不易写错(自动释放、可重入、JVM 深度优化)。但它也有硬限制:

能力synchronizedReentrantLock(见下一篇)
自动释放(出块即解锁)❌ 必须 finally 手动 unlock
可重入
可中断地获取锁lockInterruptibly()
尝试获取 / 带超时tryLock() / tryLock(timeout)
公平锁❌(非公平)✅ 可选公平
多个条件变量❌ 只有一个 wait set✅ 多个 Condition
读写分离ReentrantReadWriteLock

换成显式锁的信号:需要超时/可中断获取(避免死等)、需要 tryLock 做无阻塞探测、需要公平性、需要多个条件队列、需要读写分离。这些正是 AQS 与 Lock 家族要解决的问题。

换成无锁的信号:临界区只是一个计数或一个引用的更新——直接用 AtomicInteger/AtomicReference(CAS),比任何锁都轻。竞争极高时再考虑 LongAdder 之类的分段累加。

6. 常见陷阱

需要超时、可中断、公平、多条件或读写分离时,就该从 synchronized 换到基于 AQS 的显式锁。

延伸阅读


views
Share this post on:

Previous Post
AQS 与 Lock 家族:ReentrantLock、读写锁与 Condition
Next Post
JMM 与可见性:volatile、happens-before 与重排序