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

使用时牢记四件事:

  1. 先确认锁住的到底是哪一个对象
  2. 保证所有访问共享数据的路径使用同一把锁
  3. 缩小临界区,不要把远程调用塞进锁里
  4. 记住 synchronized 只覆盖当前 JVM,不能替代分布式锁

一句话总结:synchronized 不难,难的是把正确的共享对象、正确的锁范围和正确的并发边界设计出来。

更多推荐

章节目录