synchronized关键字到底锁住了什么
synchronized 看起来只是给方法或代码块加一个关键字,背后却涉及对象监视器、monitorenter/monitorexit、互斥、可见性和可重入。本文从它锁住的对象讲起,结合字节码和实际代码说明 synchronized 怎么用、解决什么问题,以及性能和使用边界。
Java 并发里,synchronized 大概是最早接触、也最容易被低估的关键字。
很多人对它的印象是:加上之后,同一时间只能有一个线程执行。
这个说法没错,但还不够完整。
到底是谁被锁住了?
锁住的是方法,还是代码?
为什么同一个对象上的两个 synchronized 方法会互相影响,而两个不同对象却可以同时执行?
synchronized 和 Lock 的比较,我之前已经单独写过一篇 Java synchronized 和 Lock 该怎么选。
这篇不重复做选型表,而是专门把 synchronized 自己拆开,看它到底怎么工作。
synchronized 解决什么问题
多线程同时访问共享数据时,最容易出现三类问题:
- 多个线程同时修改,结果互相覆盖
- 一个线程修改后,另一个线程看不到最新值
- 多步操作被其他线程插入,导致状态处于中间过程
例如库存扣减:
库存初始值:1
线程A:读取库存 1
线程B:读取库存 1
线程A:判断库存足够
线程B:判断库存足够
线程A:扣减成功
线程B:也扣减成功
最终出现超卖
synchronized 提供了一把互斥锁,让同一个锁保护的代码在同一时刻只允许一个线程进入。
线程A ──┐
├── 竞争同一个监视器
线程B ──┘
│
├── A 获得锁:进入临界区
└── B 等待锁:暂时不能进入
它保护的不是某一行代码本身,而是进入这段代码前需要获得的对象监视器。
理解这一点,后面所有的“锁住了什么”就顺了。
三种写法锁什么
synchronized 常见有三种写法:同步实例方法、同步静态方法和同步代码块。
同步实例方法
public synchronized void updateStock() {
// 当前对象 this 作为锁
stock = stock - 1;
}
它等价于锁住当前对象 this。
假设两个线程调用的是同一个 Service 实例,那么它们会竞争同一把锁。
如果是两个不同的 Service 实例,锁对象也不同,两个方法可以并发执行。
对象A.this ── 线程1竞争
对象B.this ── 线程2竞争
锁对象不同,互不阻塞
这也是一个很常见的误区:看到方法上有 synchronized,就以为全局只能有一个线程执行。
实际上它只锁当前实例。
同步静态方法
public static synchronized void refreshConfig() {
// 锁住当前类的 Class 对象
reloadConfig();
}
静态方法没有 this,所以它锁的是当前类对应的 Class 对象。
可以粗略理解为:
public static void refreshConfig() {
synchronized (ConfigService.class) {
reloadConfig();
}
}
实例锁和类锁不是同一把锁。
同一个类里的同步实例方法和同步静态方法,通常可以同时执行,因为它们保护的对象不同。
同步代码块
public void updateStock() {
synchronized (this) {
stock = stock - 1;
}
}
也可以使用一个专门的锁对象:
private final Object stockLock = new Object();
public void updateStock() {
synchronized (stockLock) {
stock = stock - 1;
}
}
代码块的好处是锁范围更小。
如果只有几行代码需要保护,就不要把整个大方法都锁起来。
锁的范围越大,线程等待的时间通常越长,并发能力也越差。
Monitor 到底是什么
每个 Java 对象都可以关联一个监视器,也就是 Monitor。
当线程进入 synchronized 代码时,需要先获取对应对象的 Monitor;离开代码块时释放 Monitor。
对象 Object
│
└── 关联 Monitor
├── 当前持有锁的线程
├── 重入次数
└── 等待进入的线程
Monitor 可以把线程分成两类:
- 已经拿到锁、正在执行临界区的线程
- 还没有拿到锁、等待进入临界区的线程
synchronized 的互斥效果,本质上就是同一时刻同一个 Monitor 只能被一个线程持有。
字节码里的 monitorenter
同步代码块编译后,底层会体现为类似 monitorenter 和 monitorexit 的指令。
monitorenter
执行临界区代码
monitorexit
如果临界区中途抛出了异常,也需要保证 Monitor 被释放。
可以把它理解成编译器帮我们做了类似 try-finally 的收尾工作。
获取 Monitor
│
▼
执行同步代码
│
├── 正常结束:释放 Monitor
└── 抛出异常:释放 Monitor 后继续抛出
这也是 synchronized 相对省心的地方:不用像手动 Lock 那样担心漏写 unlock。
同步方法的字节码标记
同步实例方法和同步静态方法,编译后还可以表现为方法级别的同步标记。
实例 synchronized 方法:锁 this
static synchronized 方法:锁 Class 对象
同步代码块:显式 monitorenter / monitorexit
平时不需要手动阅读字节码,但知道这几个对应关系,排查锁对象时会更有底气。
synchronized 的三个特性
互斥
互斥是最直观的能力。
同一个对象监视器,在同一时间只能由一个线程持有。
public void add() {
synchronized (counterLock) {
counter = counter + 1;
}
}
如果所有修改 counter 的地方都使用同一个 counterLock,就能把读改写这一组操作保护起来。
注意是“同一个锁对象”。
如果一个地方锁 this,另一个地方锁 counterLock,即使它们保护的是同一个变量,也没有并发互斥效果。
可见性
线程 A 释放锁之前对共享变量的修改,对之后成功获取同一把锁的线程 B 可见。
线程A:获得锁 -> 修改数据 -> 释放锁
│
▼ happens-before
线程B:等待锁 -> 获得同一把锁 -> 读取数据
所以 synchronized 不只是防止同时执行,也能建立内存可见性。
这和只做互斥、但没有考虑内存可见性的错误加锁方式不同。
可重入
同一个线程已经拿到某个对象的锁后,再次进入这个对象上的 synchronized 方法,不会把自己阻塞。
public synchronized void outer() {
inner();
}
public synchronized void inner() {
// 同一个线程可以再次进入
}
如果锁不可重入,outer 调用 inner 时线程就会把自己堵死。
synchronized 会记录当前线程的重入次数,进入一次加一,退出一次减一,直到计数归零才真正释放锁。
wait 和 notify 怎么配合
synchronized 还经常和 wait、notify、notifyAll 一起出现。
它们都属于对象监视器相关机制。
public synchronized void waitForData() throws InterruptedException {
while (!ready) {
wait();
}
consume();
}
public synchronized void produceData() {
ready = true;
notifyAll();
}
这里有几个要点:
- 调用
wait、notify、notifyAll时,线程必须持有对应对象的 Monitor wait会释放当前 Monitor,让其他线程有机会进入- 线程被唤醒后不会立刻执行,而是要重新竞争 Monitor
- 条件判断要使用
while,不能只用if
为什么要用 while?
因为线程被唤醒时,条件可能已经被其他线程再次改变,或者只是发生了不保证条件成立的唤醒。
进入同步区
│
▼
检查条件
├── 条件不满足:wait,释放锁
└── 条件满足:继续处理
notify 只唤醒一个等待线程,notifyAll 唤醒所有等待线程。
实际业务中如果条件比较复杂,Lock + Condition 往往更容易表达多个等待队列,这部分放到下一篇讲。
使用时最容易踩的坑
锁错对象
下面这种写法看起来像加锁,实际上每次调用都创建了一个新对象:
public void update() {
synchronized (new Object()) {
doUpdate();
}
}
每个线程拿到的都是不同对象,当然不会互相阻塞。
锁对象必须是多个线程能够共享的稳定对象。
锁字符串常量
synchronized ("USER_LOCK") {
updateUser();
}
字符串可能进入常量池,被其他不相关代码拿到同一个对象,锁的边界会变得不可控。
一般建议使用私有的 final 对象作为锁。
锁范围太大
如果把远程调用、文件操作和复杂计算都放进 synchronized,锁持有时间会被无限拉长。
synchronized
├── 查询本地数据
├── 调用远程接口 等待很久
├── 写文件 又等很久
└── 修改共享状态
更好的做法是只把共享状态的读取和修改放进临界区。
误以为锁住了数据库
synchronized 只在当前 JVM 进程内生效。
如果应用部署了多个实例,每个实例都有自己的 Monitor,实例之间互相不知道对方加了锁。
实例A:synchronized 只管实例A里的线程
实例B:synchronized 只管实例B里的线程
A 和 B 之间没有天然互斥
跨进程场景需要数据库锁、分布式锁或其他协调机制。
性能到底怎么样
早期 Java 版本里,大家经常把 synchronized 和性能差绑定在一起。
现代 JVM 对 synchronized 做了很多优化,包括锁消除、锁粗化、自适应自旋和轻量级竞争处理等。
所以不能看到 synchronized 就直接判断“性能一定不行”。
真正需要关注的是:
- 锁竞争是否激烈
- 临界区是否过大
- 锁对象是否合理
- 是否存在长时间阻塞
- 是否真的需要共享可变状态
如果竞争很低,直接使用 synchronized 往往是最简单可靠的方案。
如果竞争很激烈,先优化共享数据和临界区,再考虑换工具。
为了追求理论上的微小性能差异,把简单代码改成复杂锁逻辑,最后通常得不偿失。
和 Lock 的关系
synchronized 和 Lock 的比较,我已经在 Java synchronized 和 Lock 该怎么选中单独展开。
这里只保留一个判断方向:
- 需要简单互斥、自动释放和较少配置:优先 synchronized
- 需要可中断获取、超时尝试、公平策略或多个 Condition:考虑 Lock
不要为了“看起来高级”就把所有 synchronized 换成 Lock。
工具越灵活,管理成本通常也越高。
小结
synchronized 的核心不是一个神奇的性能开关,而是基于对象 Monitor 的 JVM 内置同步机制。
它主要提供:
- 互斥:同一个 Monitor 同时只允许一个线程进入
- 可见性:释放锁和后续获取同一把锁之间建立 happens-before
- 可重入:同一个线程可以重复获取自己已经持有的锁
- 自动释放:正常结束或异常退出时都能释放 Monitor
使用时牢记四件事:
- 先确认锁住的到底是哪一个对象
- 保证所有访问共享数据的路径使用同一把锁
- 缩小临界区,不要把远程调用塞进锁里
- 记住 synchronized 只覆盖当前 JVM,不能替代分布式锁
一句话总结:synchronized 不难,难的是把正确的共享对象、正确的锁范围和正确的并发边界设计出来。
