synchronized 原理与使用
这份文档把
synchronized讲到"你能讲清楚底层 + 能写对代码"的程度:对象头、Monitor、锁升级、wait/notify、可见性、以及工程里的坑。
1. synchronized 是什么?到底锁住了谁?
synchronized 是 JVM 级别的互斥锁(Monitor Lock),用于保证:
- 互斥(Mutual Exclusion):同一时刻只有一个线程进入临界区
- 可见性(Visibility):释放锁前对共享变量的写入,对之后获取同一把锁的线程可见(happens-before)
- 有序性(Ordering):临界区内的读写不会被 JVM/CPU 随意重排到临界区外
1.1 三种写法与"锁对象"
(1) 同步实例方法
java
public synchronized void inc() { ... }
锁住的是:当前实例对象 this
(2) 同步静态方法
java
public static synchronized void inc() { ... }
锁住的是:Class 对象 (XXX.class)
(3) 同步代码块
java
synchronized (lock) { ... }
锁住的是:括号里的 lock 引用指向的对象
结论:
synchronized不是"锁代码",是"锁对象"。同一把锁 = 同一个对象。
2. JVM 怎么实现 synchronized?字节码层面看懂它
2.1 同步代码块:monitorenter / monitorexit
编译后会生成:
monitorenter:尝试获取监视器(Monitor)monitorexit:释放监视器
而且编译器会插入 异常路径的 monitorexit,保证即使抛异常也能释放锁(类似 finally)。
2.2 同步方法:ACC_SYNCHRONIZED 标记
同步方法不会显式出现 monitorenter 指令,而是在方法的 access flags 上加 ACC_SYNCHRONIZED。
JVM 调用该方法时会隐式获取/释放对应 Monitor。
3. Monitor 是啥?对象头又是啥?
3.1 每个 Java 对象都可能"关联一个 Monitor"
HotSpot 里常说:
- 对象头(Object Header)里有 Mark Word
synchronized的锁信息会编码在 Mark Word 里- 膨胀(Inflate)时会指向一个 Monitor(ObjectMonitor)
3.2 Mark Word(很关键)
Mark Word 是对象头的一部分,里面会存:
- hashCode(可能)
- GC 分代年龄
- 锁状态(无锁 / 偏向 / 轻量 / 重量)
- 指向锁记录(Lock Record)或 Monitor 的指针等
不同锁状态下,Mark Word 的布局不同(JVM 会复用这块位域)。
直觉类比:Mark Word 像"对象自带的锁信息小本本",能记录当前谁在拿锁、拿的是什么形态。
4. 锁升级(无锁 → 偏向 → 轻量 → 重量)
synchronized 不是一上来就重量级互斥锁。HotSpot 有 锁优化/锁升级,大致顺序:
4.1 偏向锁(Biased Locking)
目标:如果这个锁长期被同一个线程反复获取,连 CAS 都省了。
- 第一次获取:把线程 ID 记录进对象头(Mark Word)
- 后续同线程进入:几乎就是"看一眼线程 ID",直接过
何时撤销?
- 另一个线程也来抢:触发偏向撤销(可能到 safepoint),然后升级为轻量或重量
注意:偏向锁在新版本 JDK 上有过变化(有的版本默认关闭/逐步移除)。具体以你用的 JDK 为准。
4.2 轻量级锁(Lightweight Lock)
目标:低竞争时用 CAS + 自旋,避免线程挂起/唤醒的系统调用成本。
过程(简化):
- 线程在栈上创建 Lock Record(锁记录)
- CAS 把对象头指向这个 Lock Record(并保存旧的 Mark Word)
- 成功:拿到锁
- 失败:说明有竞争,可能开始自旋,再不行升级重量级
4.3 重量级锁(Heavyweight Lock)
竞争激烈时,锁会膨胀为 ObjectMonitor:
- 持有者线程:Owner
- 竞争队列:EntryList
- 等待队列:WaitSet(用于 wait/notify)
重量级锁会涉及线程 park/unpark(挂起/唤醒),开销更大但更稳。
5. 自旋与适应性自旋:为什么"等一等"有时更快?
当锁持有时间很短(比如临界区就几条指令),直接把线程挂起/唤醒反而更慢。
所以轻量级锁会:
- 先自旋几次(空转等待)
- 如果还拿不到,才升级重量级并阻塞
适应性自旋:JVM 会根据历史竞争情况动态调整自旋次数。
6. synchronized 的内存语义(可见性/有序性)
synchronized 的 happens-before 规则:
- 解锁(monitorexit) 先行发生于 后续对同一把锁的加锁(monitorenter)
也就是说:
- 线程 A 在临界区写的共享变量
- 在 A 释放锁后
- 线程 B 获取同一把锁进入临界区时一定能看到
这等价于:
- 进入 synchronized:相当于一个 Acquire 屏障
- 退出 synchronized:相当于一个 Release 屏障
7. wait/notify/notifyAll:它们和 synchronized 什么关系?
7.1 必须在 synchronized 内调用
wait/notify/notifyAll 是 Object 的方法,但必须持有该对象的 Monitor 才能调用,否则抛 IllegalMonitorStateException。
7.2 wait 做了什么?
在持有锁的情况下调用 lock.wait():
- 当前线程释放该 lock 的 Monitor
- 线程进入该 Monitor 的 WaitSet 等待
- 被 notify/notifyAll 唤醒后,线程会去竞争锁
- 重新拿到锁后 wait 才返回
7.3 notify vs notifyAll
notify():随机唤醒一个等待线程notifyAll():唤醒所有等待线程(但最终还要抢锁)
工程建议:
- 绝大多数场景优先
notifyAll()(避免"唤醒错人"导致死等) - 但
notifyAll()可能导致惊群效应,需要权衡
7.4 正确姿势:while 而不是 if
java
synchronized (lock) {
while (!condition) {
lock.wait();
}
// condition 满足
}
因为:
- 可能虚假唤醒(spurious wakeup)
- 也可能被唤醒时条件已被别的线程改回去了
8. 常见坑(高频踩雷)
8.1 锁对象变了:等于没锁
java
synchronized (new Object()) { ... } // 每次都是新锁,等于没锁
8.2 锁住了字符串常量:容易"跨模块串锁"
java
synchronized ("LOCK") { ... } // 字符串常量会被驻留(intern),可能全局共享
建议:用私有 final Object 作为锁。
8.3 锁粒度太大:性能直接崩
把 IO、RPC、慢 SQL 放进 synchronized,等于把并发变串行。
8.4 误以为 synchronized 能跨进程
synchronized 只在 同一个 JVM 进程内有效。分布式要用 Redis/ZK/DB 锁等。
8.5 死锁(经典)
两个线程以不同顺序获取两把锁:
java
// T1: lockA -> lockB
// T2: lockB -> lockA
解决:统一加锁顺序 / 尽量减少多锁嵌套 / 使用 tryLock(ReentrantLock)
9. 实战:三段"写对就能上线"的代码
9.1 私有锁对象(推荐写法)
java
public class Counter {
private final Object lock = new Object();
private int x;
public void inc() {
synchronized (lock) {
x++;
}
}
public int get() {
synchronized (lock) {
return x;
}
}
}
9.2 双重检查单例(DCL)必须配 volatile
java
public class Singleton {
private static volatile Singleton INSTANCE;
public static Singleton getInstance() {
if (INSTANCE == null) {
synchronized (Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
原因:没有 volatile 可能发生指令重排,别的线程看到"半初始化对象"。
9.3 生产者-消费者(wait/notifyAll)
java
import java.util.ArrayDeque;
import java.util.Queue;
public class BlockingQueueSimple<T> {
private final Object lock = new Object();
private final Queue<T> q = new ArrayDeque<>();
private final int cap;
public BlockingQueueSimple(int cap) { this.cap = cap; }
public void put(T v) throws InterruptedException {
synchronized (lock) {
while (q.size() == cap) {
lock.wait();
}
q.add(v);
lock.notifyAll();
}
}
public T take() throws InterruptedException {
synchronized (lock) {
while (q.isEmpty()) {
lock.wait();
}
T v = q.poll();
lock.notifyAll();
return v;
}
}
}
10. synchronized vs ReentrantLock 怎么选?
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 语法 | 关键字,简单 | API,灵活 |
| 可中断获取锁 | 不支持 | 支持 lockInterruptibly() |
| 超时获取 | 不支持 | 支持 tryLock(timeout) |
| 公平锁 | 基本不提供 | 可选公平/非公平 |
| 条件队列 | wait/notify(单一 WaitSet) |
Condition(可多个条件队列) |
| 性能 | 现代 JVM 已很强(偏向/轻量/自旋) | 高度可控,复杂场景更强 |
工程建议:
- 简单互斥:优先 synchronized(少犯错)
- 需要超时/可中断/多个条件队列:上 ReentrantLock/Condition
11. 面试"讲清楚"模板(30 秒版)
synchronized 锁的是对象 Monitor。同步代码块对应字节码
monitorenter/monitorexit,同步方法是ACC_SYNCHRONIZED标记。对象头 Mark Word 记录锁状态,HotSpot 有无锁/偏向/轻量/重量级锁升级机制:低竞争时用偏向或轻量(CAS+自旋),竞争激烈膨胀为重量级 ObjectMonitor,线程会阻塞/唤醒。内存语义上,解锁 happens-before 后续同锁加锁,保证可见性和有序性。wait/notify 必须在持有同一把锁的 synchronized 内调用,wait 会释放锁进入 WaitSet,被唤醒后重新竞争锁。
12. 进一步阅读(你如果要深挖)
- HotSpot 对象头与 Mark Word
- ObjectMonitor 结构(Owner、EntryList、WaitSet)
- JIT 与锁消除/锁粗化(逃逸分析相关)
- JDK 版本差异(偏向锁的默认策略变化)