Skip to content

synchronized全过程原理

synchronized 是 Java 最基础、也是面试和生产代码里最常见的互斥工具。它不是“给代码加个标记”这么简单,背后涉及锁对象、对象头、Monitor、字节码、可重入、内存可见性、锁升级和线上阻塞排查。

学习目标

目标要掌握什么
会用同步代码块、同步实例方法、同步静态方法分别锁谁
懂原理monitorentermonitorexit、Monitor owner、重入计数
懂可见性为什么进入和退出同一把锁能看见共享变量最新值
懂优化偏向锁、轻量级锁、重量级锁、自旋、锁消除、锁粗化
会排查线程 BLOCKED、死锁、锁内慢 IO、锁粒度过大怎么定位
会面试能把“是什么、为什么、怎么工作、不这样会怎样”说完整

synchronized 锁的到底是什么

synchronized 锁的不是一段代码,而是一个对象关联的 Monitor。线程进入同步区域前,必须先拿到这个对象的 Monitor。

写法锁对象
synchronized (obj) {}obj 这个对象
public synchronized void m()当前实例对象 this
public static synchronized void m()当前类的 Class 对象,例如 UserService.class

示例:

java
public class LockTargetDemo {
    private final Object lock = new Object();

    public void blockLock() {
        synchronized (lock) {
            // 锁 lock 对象
        }
    }

    public synchronized void instanceLock() {
        // 锁 this
    }

    public static synchronized void classLock() {
        // 锁 LockTargetDemo.class
    }
}

为什么必须知道锁对象?

如果两个线程锁的不是同一个对象,就不会互斥:

java
public void wrong() {
    synchronized (new Object()) {
        // 每次都是新对象,多个线程根本不是同一把锁。
    }
}

基本执行流程

mermaid
flowchart TD
    A["线程进入 synchronized"] --> B["找到锁对象"]
    B --> C{"Monitor 是否空闲"}
    C -->|空闲| D["线程成为 owner"]
    D --> E["重入计数设为 1"]
    E --> F["执行同步代码"]
    C -->|当前线程已持有| G["重入计数 + 1"]
    G --> F
    C -->|其他线程持有| H["进入阻塞等待"]
    H --> I["等待 owner 释放后再次竞争"]
    I --> C
    F --> J["执行 monitorexit"]
    J --> K["重入计数 - 1"]
    K --> L{"重入计数是否为 0"}
    L -->|否| F
    L -->|是| M["释放 Monitor 并唤醒竞争线程"]

一句话:

线程先竞争锁对象的 Monitor,拿到 owner 才能执行;同一个线程重复进入会增加重入计数;退出时重入计数减到 0 才真正释放锁。

字节码层面怎么看

同步代码块会编译成 monitorentermonitorexit

java
public void add() {
    synchronized (this) {
        count++;
    }
}

可以用:

bash
javac Counter.java
javap -c -v Counter

你会看到类似结构:

text
monitorenter
...
monitorexit
...
monitorexit

为什么可能有两个 monitorexit

因为 JVM 要保证正常执行结束和异常退出时都能释放锁。也就是说:

java
synchronized (lock) {
    if (error) {
        throw new RuntimeException();
    }
}

即使同步代码块里抛异常,只要异常离开同步块,锁也会释放。这个能力由编译器和 JVM 字节码结构保证。

同步方法不会直接在方法体里看到 monitorenter,而是在方法访问标志上加 ACC_SYNCHRONIZED。JVM 调用同步方法前后会隐式获取和释放 Monitor。

可重入为什么重要

可重入表示:同一个线程已经拿到某把锁后,可以再次拿这把锁,不会把自己阻塞住。

java
public class ReentrantDemo {
    public synchronized void outer() {
        inner();
    }

    public synchronized void inner() {
        System.out.println("inner");
    }
}

outer()inner() 都锁 this。如果 synchronized 不可重入,线程进入 outer() 后调用 inner() 会被自己挡住,直接死锁。

Monitor 用重入计数解决这个问题:

步骤计数
进入 outer1
调用 inner2
inner 返回1
outer 返回0,真正释放

synchronized 为什么保证可见性

多线程问题不只有“同时改”导致的数据错乱,还有“看不到别人改过的值”。

JMM 规定:

text
对同一把锁的 unlock happens-before 后续对同一把锁的 lock

也就是说,一个线程释放锁前对共享变量做的修改,另一个线程之后获取同一把锁时必须可见。

java
public class ConfigHolder {
    private String mode = "old";

    public synchronized void update(String newMode) {
        mode = newMode;
    }

    public synchronized String get() {
        return mode;
    }
}

只要 updateget 使用同一把锁,读线程就能看到写线程释放锁前写入的值。

如果读写没有用同一把锁,可见性就不成立:

java
public class WrongVisibility {
    private final Object writeLock = new Object();
    private final Object readLock = new Object();
    private String mode = "old";

    public void update() {
        synchronized (writeLock) {
            mode = "new";
        }
    }

    public String get() {
        synchronized (readLock) {
            return mode;
        }
    }
}

这两个方法锁的不是同一个对象,不能建立可靠的 happens-before 关系。

锁升级为什么存在

如果每次进入 synchronized 都直接阻塞线程、唤醒线程、进入操作系统互斥量,成本会很高。真实业务中很多锁没有激烈竞争,所以 HotSpot 做了分层优化。

mermaid
flowchart TD
    A["无锁"] --> B["偏向锁:偏向第一个线程"]
    B --> C{"是否出现其他线程竞争"}
    C -->|否| B
    C -->|是| D["轻量级锁:CAS 与自旋"]
    D --> E{"自旋能否很快成功"}
    E -->|能| D
    E -->|不能| F["重量级锁:Monitor 阻塞等待"]
锁状态适合情况原理重点代价
偏向锁基本只有一个线程反复进入对象头记录偏向线程出现竞争要撤销
轻量级锁短时间轻微竞争CAS 尝试替换对象头,失败后自旋自旋消耗 CPU
重量级锁竞争激烈或锁持有时间长线程进入 Monitor 阻塞队列阻塞唤醒成本高

版本注意:

JDK面试重点
JDK 6/7/8偏向锁、轻量级锁、重量级锁是高频面试点
JDK 15 以后偏向锁被废弃或默认关闭,不能只背“永远有偏向锁”
JDK 21更要理解同步语义和锁优化思想,不要把某个版本细节当永久规则

用户项目里 JDK 7、JDK 8 仍然非常重要,所以面试回答要以 JDK 8 常见模型为主,再补充新版本变化。

对象头和 Mark Word

HotSpot 对象头里有一块 Mark Word,用来存对象运行时信息,例如哈希码、GC 年龄、锁状态、偏向线程信息、指向锁记录或 Monitor 的指针。

可以粗略理解为:

text
Java对象
├── 对象头
│   ├── Mark Word:锁状态、hash、GC年龄等
│   └── Klass Pointer:指向类元数据
└── 实例数据

锁优化为什么能发生?

因为 JVM 可以根据对象头里的锁标记判断当前对象处于无锁、偏向锁、轻量级锁还是重量级锁状态,并通过 CAS 修改对象头完成加锁尝试。竞争严重时,对象头会指向重量级 Monitor。

synchronized 和 Lock 怎么选

对比项synchronizedReentrantLock
形态Java 关键字JUC 类
释放锁自动释放必须手动 unlock
可重入支持支持
可中断等待不支持普通进入锁时中断lockInterruptibly 支持
尝试超时不支持tryLock(timeout) 支持
多条件队列只有 wait/notify可创建多个 Condition
公平锁不支持显式公平构造参数可选公平

选择建议:

场景建议
简单互斥,小临界区synchronized
需要自动释放,降低出错概率synchronized
需要超时获取锁ReentrantLock
需要可中断等待ReentrantLock
需要多个条件队列ReentrantLock + Condition

商业 Demo:防止重复刷新本地缓存

下面是一个订单状态字典缓存。多个请求会读取缓存,缓存过期时只允许一个线程刷新。

java
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;

public class OrderStatusCache {
    private final Object refreshLock = new Object();
    private volatile Map<String, String> cache = new HashMap<>();
    private volatile Instant expireAt = Instant.MIN;

    public String getName(String code) {
        if (Instant.now().isAfter(expireAt)) {
            synchronized (refreshLock) {
                if (Instant.now().isAfter(expireAt)) {
                    Map<String, String> newCache = loadFromDatabase();
                    cache = newCache;
                    expireAt = Instant.now().plusSeconds(300);
                }
            }
        }
        return cache.get(code);
    }

    private Map<String, String> loadFromDatabase() {
        Map<String, String> data = new HashMap<>();
        data.put("WAIT_PAY", "待支付");
        data.put("PAID", "已支付");
        data.put("CLOSED", "已关闭");
        return data;
    }
}

这里为什么 cacheexpireAtvolatile

读缓存时没有每次都进入 synchronized,所以需要 volatile 保证读线程能看到刷新线程替换后的新引用和新过期时间。synchronized 保证同一时间只有一个线程刷新,volatile 保证锁外读取的可见性。

如果不用双重检查,会怎样?

java
if (expired) {
    synchronized (refreshLock) {
        refresh();
    }
}

多个线程都发现过期后会排队进入锁,然后一个一个重复刷新数据库。写锁内再检查一次,可以避免重复刷新。

常见坑

后果正确做法
new Object()每次锁对象不同,完全不互斥锁固定对象
锁字符串常量不同业务可能锁到同一字符串常量用私有 final 锁对象
锁粒度太大接口吞吐下降,线程大量阻塞只把共享数据读写放进锁
锁内远程调用下游慢会拖死所有等待线程远程调用尽量放锁外
读写用不同锁无法互斥,也无法建立可见性同一份共享数据用同一把锁
在锁内调用外部回调外部代码不可控,可能反向拿锁死锁锁内只做可控逻辑

线上排查

如果线上接口变慢,CPU 不高,但请求大量堆积,要怀疑线程阻塞。

mermaid
flowchart TD
    A["接口慢或请求堆积"] --> B["jstack 导出线程栈"]
    B --> C{"大量 BLOCKED?"}
    C -->|是| D["查看 blocked on 哪个对象"]
    D --> E["找到持锁线程 locked 对象"]
    E --> F{"持锁线程在做什么"}
    F --> G["慢 SQL 或远程调用"]
    F --> H["文件 IO 或日志阻塞"]
    F --> I["复杂计算或死循环"]
    C -->|否| J["继续看 WAITING、TIMED_WAITING、CPU、GC"]

排查话术:

text
我会先用 jstack 看是否大量线程处于 BLOCKED,并找到它们等待的 monitor 对象。然后找到持有该 monitor 的线程,看它在锁内执行什么。如果持锁线程卡在数据库、HTTP、文件 IO 或日志写入,就说明锁范围太大或者锁内做了慢操作。处理上要缩小锁粒度、把慢 IO 移到锁外,必要时拆分锁或改成无锁快照。

面试标准回答

synchronized 的原理是什么

synchronized 是 Java 内置互斥锁。同步代码块会编译为 monitorentermonitorexit,同步方法会加 ACC_SYNCHRONIZED 标志。每个锁对象都关联一个 Monitor,线程进入同步区域前要获取 Monitor 的 owner,拿不到就阻塞等待。同一线程重复获取同一把锁会增加重入计数,退出时计数减到 0 才真正释放锁。

synchronized 能保证什么

它能保证互斥和可见性。互斥是同一时间只有一个线程能进入同一把锁保护的临界区;可见性来自 JMM 的监视器锁规则:对同一把锁的 unlock happens-before 后续对同一把锁的 lock。所以写线程释放锁前的修改,读线程后续获取同一把锁后能看到。

synchronized 锁升级怎么说

以 JDK 8 常见模型来说,锁会根据竞争程度从无锁、偏向锁、轻量级锁逐步升级到重量级锁。偏向锁适合长期只有一个线程进入,轻量级锁适合轻微竞争下 CAS 和自旋,重量级锁适合竞争激烈时把线程阻塞起来。新版本里偏向锁逐步弱化或废弃,所以回答时要说明版本差异。

synchronized 和 ReentrantLock 区别

synchronized 是关键字,自动释放锁,适合简单互斥;ReentrantLock 是 JUC 类,需要手动 unlock,但支持可中断获取锁、超时获取锁、公平锁和多个 Condition。简单业务优先 synchronized,需要更强控制能力时用 ReentrantLock

关联知识点

知识点为什么要看
synchronized基础快速回顾基础说明和字节码入口
Java内存模型理解 happens-before、可见性和有序性
volatile与Atomic对比可见性、CAS 和原子类
AQS与JUC全过程对比 JUC 锁和 synchronized 的实现思路
读写锁全过程对比普通互斥锁和读写锁