跳转到正文
Siolin'Log
返回

Java synchronized:用法、Monitor 与锁竞争

概述

Java 并发锁与同步机制概览

synchronized 是 Java 提供的内置互斥同步机制。理解它不能只停留在“加锁”,它同时解决三个问题:

如果还不熟悉这三个维度,可以先阅读 Java 并发问题:原子性、可见性与有序性

解决问题

下面的代码不是线程安全的:

private int count;

public void increment() {
    count++;
}

count++ 实际上包含多个步骤:

读取 count
计算 count + 1
写回 count

两个线程可能同时读取到 count = 0,然后都写入 1,导致一次更新丢失。

加锁后:

private int count;

public synchronized void increment() {
    count++;
}

对于同一个实例,同一时刻只能有一个线程执行 increment(),因此读取、计算和写回这一组操作对其他使用同一把锁的线程具有原子性。

基本用法

可以把 synchronized 理解为获取某个对象关联的监视器;是否互斥取决于不同线程使用的是不是同一个监视器。

修饰实例方法

public synchronized void increment() {
    count++;
}

在锁语义上相当于:

public void increment() {
    synchronized (this) {
        count++;
    }
}

锁对象是当前实例 this。不同实例之间不会互斥。

修饰静态方法

private static int count;

public static synchronized void increment() {
    count++;
}

在锁语义上相当于:

private static int count;

public static void increment() {
    synchronized (Counter.class) {
        count++;
    }
}

锁对象是 Counter.class。同一个已加载的 Counter 类调用该静态同步方法时,会竞争这个 Class 对象关联的监视器。

同步代码块

private final Object lock = new Object();

public void increment() {
    synchronized (lock) {
        count++;
    }
}

需要精确控制锁对象和临界区范围时,同步代码块通常更合适,因为可以:

底层模型

锁的是什么

synchronized 锁的不是代码本身,而是某个对象关联的监视器(monitor)

比如说,下面看起来加了锁,实际上没有形成互斥:

public void increment() {
    synchronized (new Object()) {
        count++;
    }
}

正确写法:

private final Object lock = new Object();

public void increment() {
    synchronized (lock) {
        count++;
    }
}

判断两个 synchronized 是否互斥,只需要问:它们运行时锁住的是不是同一个对象?

例如:

public synchronized void methodA() {
}

public void methodB() {
    synchronized (this) {
    }
}

methodA()methodB() 会互斥,因为锁对象都是 this

但是:

public static synchronized void methodA() {
}

public synchronized void methodB() {
}

它们通常不会互斥:

字节码

对于源码:

synchronized (lock) {
    doSomething();
}

编译后,核心字节码类似:

monitorenter:尝试获取对象监视器

执行同步块代码

monitorexit:释放对象监视器

编译器必须保证同步代码块无论正常完成还是异常完成都能释放监视器,因此通常会生成正常退出路径和异常处理路径。具体出现多少条 monitorexit 指令取决于编译器生成的控制流,不应固定理解成恰好两条。

因此下面的代码不会因为异常永久占有锁:离开同步块时,生成的异常处理路径会释放监视器。

synchronized (lock) {
    throw new RuntimeException();
}

同步方法的字节码通常通过方法访问标志 ACC_SYNCHRONIZED 表示,而不是在方法体中直接放置 monitorentermonitorexit;JVM 会在调用和退出方法时执行对应的监视器操作。

Monitor

在 HotSpot JVM 中,一个普通 Java 对象可以粗略理解为:

Java 对象
├── 对象头
│   ├── Mark Word
│   └── Klass Pointer
├── 实例数据
└── 对齐填充

其中 Mark Word 会保存或编码一些运行时信息。当发生复杂竞争时,锁通常会关联到 JVM 内部的 Monitor:

Java 对象

关联 Monitor
    ├── 当前锁持有线程
    ├── 重入计数
    ├── 竞争进入的线程
    └── wait 等待集合

可以把 Monitor 抽象理解为:

Monitor
├── owner:当前持有锁的线程
├── recursion:当前线程重入次数
├── entry queue:等待获取锁的线程
└── wait set:调用 wait() 后等待条件的线程

逻辑模型:

flowchart TD
    A["新线程"] -->|"尝试获取锁"| B{"获取 Monitor 成功?"}

    B -->|"成功"| C["Owner Thread<br/>成为锁的持有者"]
    B -->|"失败"| D["Entry Queue<br/>等待获取锁"]

    D -->|"锁被释放,重新竞争"| B

    C -->|"执行同步代码"| E{"是否调用 wait()?"}
    E -->|"否"| F["退出同步块<br/>释放 Monitor"]
    F --> D

    E -->|"是:释放 Monitor"| G["Wait Set<br/>进入等待状态"]

    G -->|"notify() / notifyAll()"| D

需要特别注意:图中的 Monitor 是便于理解的逻辑模型。Java 对象不一定从创建开始就分配了一个完整的重量级 Monitor。

如果每个对象创建时都分配操作系统级锁,成本会非常高。HotSpot 通常会先使用更轻量的锁表示和快速路径;只有竞争加剧或遇到某些操作时,才可能创建或关联更完整的监视器结构。

锁竞争与膨胀

flowchart TD
    A["进入 synchronized"] --> B{"能否快速获取锁?"}

    B -->|"可以"| C["成为锁持有者"]
    B -->|"不可以"| D["短暂竞争或自旋"]

    D --> E{"竞争成功?"}
    E -->|"成功"| C
    E -->|"失败或竞争加剧"| F["进入 JVM Monitor 竞争机制"]

    F -->|"线程被挂起或等待"| G["等待锁释放"]
    G -->|"被唤醒"| B

    C --> H{"是否重入?"}
    H -->|"是"| I["增加重入计数"]
    H -->|"否"| J["执行临界区"]

    I --> J
    J --> K["退出 synchronized"]
    K --> L["减少计数或完全释放锁"]

Java synchronized 的 Monitor 竞争模型

无竞争快速路径

如果锁没有被其他线程持有,JVM 可以通过少量检查和原子操作完成加锁。

此时通常不需要:

因此:

synchronized (lock) {
    count++;
}

在完全无竞争时,不应该简单理解为“每次都要进行一次昂贵的内核锁操作”。

短暂竞争

线程 B 获取失败时,JVM 不一定立刻将其挂起。

原因是持锁线程 A 可能马上就会退出:

线程 A:只剩几条指令就释放锁
线程 B:获取失败

如果线程 B 立即阻塞:

用户态 → 内核态
线程 B 挂起
线程调度
线程 A 释放锁
线程 B 被唤醒
内核态 → 用户态

这套成本可能比等待几次更高。

因此 JVM 可能选择短暂自旋:

while (!tryAcquire(lock)) {
    // 暂时不进入阻塞,继续尝试
}

真实实现不会是如此简单的 Java 循环,JVM 会结合运行时信息决定如何竞争。

竞争持续

如果锁长期得不到,继续自旋会浪费 CPU。

此时 JVM 可能使锁关联到更完整的 ObjectMonitor,竞争线程进入等待结构,必要时被阻塞。

这通常被称为:锁膨胀

膨胀后的模型更接近:

ObjectMonitor
├── 当前 owner
├── 重入计数
├── 等待进入锁的线程
├── 调用 wait() 的线程
└── 用于排队、唤醒和状态维护的数据

可重入

synchronized 是可重入的:已经持有某个监视器的线程可以再次获取同一个监视器,而不会把自己阻塞。考虑递归调用:

public synchronized void recursive(int n) {
    if (n > 0) {
        recursive(n - 1);
    }
}

每次调用锁对象都是 this

如果 synchronized 不可重入,第一次调用获得 this 后,第二次递归调用会等待自己释放锁,形成自我死锁。

实际判断过程概念上是:

锁空闲:
    currentThread 成为 owner

锁被占用:
    如果 owner == currentThread:
        记录重入
        允许进入
    否则:
        进入竞争

退出时必须与进入次数对应:

进入 3 次
退出第 1 次:仍持有
退出第 2 次:仍持有
退出第 3 次:真正释放

synchronized 与 wait/notify

synchronized 负责互斥,wait()notify()notifyAll() 负责基于同一个对象监视器进行条件等待与线程协调。调用这些方法前,当前线程必须已经持有该对象的监视器,否则会抛出 IllegalMonitorStateException

wait()
notify()
notifyAll()

调用 wait() 会释放当前对象的监视器并进入 wait set;notify()notifyAll() 只负责唤醒等待线程,并不会立即释放监视器。被唤醒的线程仍要等通知线程退出同步区域后,重新竞争同一把锁。

这种关系类似于 Java Lock 和 Java Condition 的协作方式,但两套 API 不能混用。

这里依旧要区分 Monitor 中的两个队列:



上一篇
Java ReentrantLock:重入、公平性与条件队列
下一篇
Java 线程池:参数、任务调度与拒绝策略