synchronized全过程原理
synchronized 是 Java 最基础、也是面试和生产代码里最常见的互斥工具。它不是“给代码加个标记”这么简单,背后涉及锁对象、对象头、Monitor、字节码、可重入、内存可见性、锁升级和线上阻塞排查。
学习目标
| 目标 | 要掌握什么 |
|---|---|
| 会用 | 同步代码块、同步实例方法、同步静态方法分别锁谁 |
| 懂原理 | monitorenter、monitorexit、Monitor owner、重入计数 |
| 懂可见性 | 为什么进入和退出同一把锁能看见共享变量最新值 |
| 懂优化 | 偏向锁、轻量级锁、重量级锁、自旋、锁消除、锁粗化 |
| 会排查 | 线程 BLOCKED、死锁、锁内慢 IO、锁粒度过大怎么定位 |
| 会面试 | 能把“是什么、为什么、怎么工作、不这样会怎样”说完整 |
synchronized 锁的到底是什么
synchronized 锁的不是一段代码,而是一个对象关联的 Monitor。线程进入同步区域前,必须先拿到这个对象的 Monitor。
| 写法 | 锁对象 |
|---|---|
synchronized (obj) {} | obj 这个对象 |
public synchronized void m() | 当前实例对象 this |
public static synchronized void m() | 当前类的 Class 对象,例如 UserService.class |
示例:
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
}
}为什么必须知道锁对象?
如果两个线程锁的不是同一个对象,就不会互斥:
public void wrong() {
synchronized (new Object()) {
// 每次都是新对象,多个线程根本不是同一把锁。
}
}基本执行流程
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 才真正释放锁。
字节码层面怎么看
同步代码块会编译成 monitorenter 和 monitorexit:
public void add() {
synchronized (this) {
count++;
}
}可以用:
javac Counter.java
javap -c -v Counter你会看到类似结构:
monitorenter
...
monitorexit
...
monitorexit为什么可能有两个 monitorexit?
因为 JVM 要保证正常执行结束和异常退出时都能释放锁。也就是说:
synchronized (lock) {
if (error) {
throw new RuntimeException();
}
}即使同步代码块里抛异常,只要异常离开同步块,锁也会释放。这个能力由编译器和 JVM 字节码结构保证。
同步方法不会直接在方法体里看到 monitorenter,而是在方法访问标志上加 ACC_SYNCHRONIZED。JVM 调用同步方法前后会隐式获取和释放 Monitor。
可重入为什么重要
可重入表示:同一个线程已经拿到某把锁后,可以再次拿这把锁,不会把自己阻塞住。
public class ReentrantDemo {
public synchronized void outer() {
inner();
}
public synchronized void inner() {
System.out.println("inner");
}
}outer() 和 inner() 都锁 this。如果 synchronized 不可重入,线程进入 outer() 后调用 inner() 会被自己挡住,直接死锁。
Monitor 用重入计数解决这个问题:
| 步骤 | 计数 |
|---|---|
进入 outer | 1 |
调用 inner | 2 |
inner 返回 | 1 |
outer 返回 | 0,真正释放 |
synchronized 为什么保证可见性
多线程问题不只有“同时改”导致的数据错乱,还有“看不到别人改过的值”。
JMM 规定:
对同一把锁的 unlock happens-before 后续对同一把锁的 lock也就是说,一个线程释放锁前对共享变量做的修改,另一个线程之后获取同一把锁时必须可见。
public class ConfigHolder {
private String mode = "old";
public synchronized void update(String newMode) {
mode = newMode;
}
public synchronized String get() {
return mode;
}
}只要 update 和 get 使用同一把锁,读线程就能看到写线程释放锁前写入的值。
如果读写没有用同一把锁,可见性就不成立:
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 做了分层优化。
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 的指针。
可以粗略理解为:
Java对象
├── 对象头
│ ├── Mark Word:锁状态、hash、GC年龄等
│ └── Klass Pointer:指向类元数据
└── 实例数据锁优化为什么能发生?
因为 JVM 可以根据对象头里的锁标记判断当前对象处于无锁、偏向锁、轻量级锁还是重量级锁状态,并通过 CAS 修改对象头完成加锁尝试。竞争严重时,对象头会指向重量级 Monitor。
synchronized 和 Lock 怎么选
| 对比项 | synchronized | ReentrantLock |
|---|---|---|
| 形态 | Java 关键字 | JUC 类 |
| 释放锁 | 自动释放 | 必须手动 unlock |
| 可重入 | 支持 | 支持 |
| 可中断等待 | 不支持普通进入锁时中断 | lockInterruptibly 支持 |
| 尝试超时 | 不支持 | tryLock(timeout) 支持 |
| 多条件队列 | 只有 wait/notify | 可创建多个 Condition |
| 公平锁 | 不支持显式公平 | 构造参数可选公平 |
选择建议:
| 场景 | 建议 |
|---|---|
| 简单互斥,小临界区 | synchronized |
| 需要自动释放,降低出错概率 | synchronized |
| 需要超时获取锁 | ReentrantLock |
| 需要可中断等待 | ReentrantLock |
| 需要多个条件队列 | ReentrantLock + Condition |
商业 Demo:防止重复刷新本地缓存
下面是一个订单状态字典缓存。多个请求会读取缓存,缓存过期时只允许一个线程刷新。
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;
}
}这里为什么 cache 和 expireAt 用 volatile?
读缓存时没有每次都进入 synchronized,所以需要 volatile 保证读线程能看到刷新线程替换后的新引用和新过期时间。synchronized 保证同一时间只有一个线程刷新,volatile 保证锁外读取的可见性。
如果不用双重检查,会怎样?
if (expired) {
synchronized (refreshLock) {
refresh();
}
}多个线程都发现过期后会排队进入锁,然后一个一个重复刷新数据库。写锁内再检查一次,可以避免重复刷新。
常见坑
| 坑 | 后果 | 正确做法 |
|---|---|---|
锁 new Object() | 每次锁对象不同,完全不互斥 | 锁固定对象 |
| 锁字符串常量 | 不同业务可能锁到同一字符串常量 | 用私有 final 锁对象 |
| 锁粒度太大 | 接口吞吐下降,线程大量阻塞 | 只把共享数据读写放进锁 |
| 锁内远程调用 | 下游慢会拖死所有等待线程 | 远程调用尽量放锁外 |
| 读写用不同锁 | 无法互斥,也无法建立可见性 | 同一份共享数据用同一把锁 |
| 在锁内调用外部回调 | 外部代码不可控,可能反向拿锁死锁 | 锁内只做可控逻辑 |
线上排查
如果线上接口变慢,CPU 不高,但请求大量堆积,要怀疑线程阻塞。
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"]排查话术:
我会先用 jstack 看是否大量线程处于 BLOCKED,并找到它们等待的 monitor 对象。然后找到持有该 monitor 的线程,看它在锁内执行什么。如果持锁线程卡在数据库、HTTP、文件 IO 或日志写入,就说明锁范围太大或者锁内做了慢操作。处理上要缩小锁粒度、把慢 IO 移到锁外,必要时拆分锁或改成无锁快照。面试标准回答
synchronized 的原理是什么
synchronized 是 Java 内置互斥锁。同步代码块会编译为 monitorenter 和 monitorexit,同步方法会加 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 的实现思路 |
| 读写锁全过程 | 对比普通互斥锁和读写锁 |
