synchronized 在 Java 并发编程中的地位,有点像螺丝刀在工具箱里的位置——你未必天天用它,但遇到并发问题的时候,第一个想到的往往就是它。不过正因为太常见了,很多同学对它的理解停留在“加锁就完事了”的层面,一旦面试被问到“synchronized 的锁升级过程”“monitor 和对象头的关系”,或者线上遇到锁性能问题,就会露怯。
这篇文章我不打算照着官方文档给你念一遍,而是把 synchronized 拆开揉碎,从特性、用法到底层的锁机制,结合我自己在实际项目里踩过的坑,尽量讲清楚每一个“为什么”。内容会偏实战一些,也适合正在准备面试的同学作为复习提纲。
1. 特性剖析:synchronized 到底解决了什么问题
1.1 原子性、可见性、有序性,一个锁全管了
先说结论:synchronized 不是简单的“互斥锁”,它同时保证了并发编程三大特性——原子性、可见性、有序性。这一点很多人忽略,以为它只管互斥。
- 原子性:被 synchronized 包裹的代码块,在同一时刻只能有一个线程执行,不会出现线程切换导致的操作中断。比如
count++这种非原子操作,放进同步块后就安全了。 - 可见性:线程进入 synchronized 代码块前,会清空工作内存中的共享变量副本,重新从主内存加载;退出时,会把修改后的值刷回主内存。这相当于给变量加了一层“强制刷新”的屏障。
- 有序性:synchronized 块内的代码不会被处理器重排序到块外,这在一定程度上防止了指令重排带来的问题。
所以你在面试时如果说“synchronized 是互斥锁,能保证原子性”,这只是答对了一半。完整的答案应该是:synchronized 通过 monitor 锁机制,同时保证了原子性、可见性和有序性。
1.2 可重入性:同一个线程可以重复获取同一把锁
synchronized 是可重入的。意思是:同一个线程已经持有了某把锁,再次进入需要这把锁的代码块时,不需要重新竞争锁,可以直接进入。
这里有个经典误区:很多人以为重入是“重新加锁一次”,其实不是。JVM 在锁对象里记录了持有锁的线程和重入次数。每次重入,计数器加一;每次退出同步块,计数器减一;减到零,锁才真正释放。
java复制public class ReentrantDemo {
public synchronized void outer() {
// 已经持有锁
inner(); // 再次进入 synchronized 方法,不需要重新竞争锁
}
public synchronized void inner() {
// 这里可以直接进入
}
}
如果没有可重入性,上面这段代码会直接死锁——outer 方法持有锁,调用 inner 时又去申请同一把锁,永远等不到。所以可重入是 synchronized 设计的底线,也是它比很多手写锁方案更安全的原因。
1.3 非公平性:为什么它不是“先来后到”
synchronized 是非公平锁。也就是说,当锁被释放时,所有等待的线程都可能被唤醒去竞争锁,而不是严格按请求顺序获取。
这样设计的原因很简单:如果真的实现公平锁,线程切换和排队管理的开销会非常大,反而降低吞吐量。非公平锁允许新来的线程直接去抢锁,抢不到才进入等待队列,这样在锁竞争不激烈的时候,性能会更好。
实际项目中,如果你遇到某个线程长期拿不到锁的“饥饿”问题,synchronized 的非公平性确实是一个因素。但在绝大多数业务场景下,这个概率很低,不必过度担心。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 用法详解:三种加锁姿势,别再用错了
2.1 同步方法:最简单,但最容易被忽视的细节
java复制public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
}
这里的锁对象是 this,也就是调用这个方法的实例。如果两个线程调用的是同一个 Counter 对象的 increment 方法,它们会互斥;如果是两个不同的 Counter 对象,互不干扰。
有一个常见的坑:在 Spring 默认单例模式下,bean 是单例的,所以 synchronized 方法能生效;但如果 bean 的 scope 是 prototype,每个地方拿到的是不同实例,synchronized 方法就形同虚设。我之前就遇到过这个问题,排查了半天才发现是 bean 的作用域配错了。
2.2 同步代码块:细粒度锁的正确打开方式
java复制public class OrderService {
private final Object lock = new Object();
public void processOrder(String orderId) {
// 这里不锁,可以并发执行
checkOrder(orderId);
// 只锁需要保护的代码
synchronized (lock) {
updateOrderStatus(orderId);
}
// 这里也不锁
notifyUser(orderId);
}
}
同步代码块的好处是锁的粒度可控。你只需要保护真正存在并发问题的代码,不需要把整个方法都锁住。这样能显著提升系统的并发能力。
关键问题来了:锁对象怎么选?三种选择,各有坑:
synchronized(this):锁的是当前对象。问题在于,如果外部代码也持有这个对象并加了锁,可能会导致不必要的阻塞。synchronized(OrderService.class):锁的是整个类,所有实例共享同一把锁。适合保护静态变量或全局资源。synchronized(lock):锁一个私有对象,推荐这种。外部代码无法访问这个对象,不会产生意外的锁竞争。
2.3 静态方法:锁的是 Class 对象,不是实例
java复制public class ConfigManager {
private static Map<String, String> configs = new HashMap<>();
public static synchronized void updateConfig(String key, String value) {
configs.put(key, value);
}
}
同步静态方法锁的是 Class 对象(比如 ConfigManager.class),而不是某个实例。这一点很重要:同一个类的静态 synchronized 方法和实例 synchronized 方法,用的是两把完全不同的锁,它们之间不会互斥。
举个例子:如果一个线程在调用静态方法 updateConfig,另一个线程同时调用实例方法 increment,这两个操作是并行的,不会相互阻塞。理解了这一点,你在设计多线程访问静态和实例资源时,就不会搞混锁的边界。
2.4 锁对象选择的避坑清单
我总结了一些实际踩坑后才想明白的规则:
- 锁对象必须是引用类型,不能是基本类型。
- 锁对象如果是字符串字面量,比如
"lock",要小心:JVM 中内容相同的字符串字面量指向同一个对象,可能导致意想不到的锁共享。 - 锁对象如果是可变对象,要保证它的引用不会被修改。一旦有人把 lock 变量重新赋值为新对象,原来的锁就失效了。
- 不要用
Integer、Long等包装类型做锁对象,因为自动装箱可能产生新对象,导致锁对象不一致。
3. 锁机制深入:从对象头到锁升级,JVM 做了什么
3.1 对象头和 Mark Word:锁信息藏在这里
Java 对象的锁信息存储在对象头的 Mark Word 中。一个 Java 对象在内存中由三部分组成:对象头、实例数据、对齐填充。对象头又包含 Mark Word 和 Klass Pointer。
Mark Word 是一块 64 位(64 位 JVM)的内存区域,记录了对象的哈希码、GC 分代年龄、锁状态标志等信息。锁状态的转变,本质上就是修改 Mark Word 中的标志位。
以 64 位 JVM 为例,Mark Word 的布局大致如下:
| 锁状态 | 存储内容 |
|---|---|
| 无锁 | 对象哈希码、分代年龄、偏向锁标志位为 0 |
| 偏向锁 | 线程 ID、Epoch、分代年龄、偏向锁标志位为 1 |
| 轻量级锁 | 指向栈中锁记录的指针 |
| 重量级锁 | 指向 monitor 对象的指针 |
| GC 标记 | 空,用于垃圾回收标记 |
3.2 锁升级全过程:偏向锁 → 轻量级锁 → 重量级锁
JDK 1.6 对 synchronized 做了大量优化,引入了锁升级机制。锁不是一开始就是重量级锁,而是根据竞争情况逐步升级。锁升级是单向的,只能从低到高,不能降级。
偏向锁
偏向锁的核心思路是:如果一段同步代码始终由同一个线程访问,就让这个线程持有锁,不需要每次加锁/解锁。JVM 会在 Mark Word 中记录持有锁的线程 ID。当该线程再次进入同步块时,检查线程 ID 是否匹配,匹配则直接进入。
偏向锁只有在发生竞争时才会撤销:如果有另一个线程来竞争,先判断持有锁的线程是否存活。如果已退出,则设置 Mark Word 为无锁状态,重新偏向新线程;如果仍然存活,则升级为轻量级锁。
轻量级锁
当第二个线程尝试获取偏向锁时,锁会升级为轻量级锁。轻量级锁的实现原理是自旋:线程不进入阻塞状态,而是在用户态循环等待锁释放。JVM 会在线程栈帧中创建锁记录空间,尝试通过 CAS 操作将 Mark Word 更新为指向锁记录的指针。
轻量级锁适用于线程交替执行临界区的场景。如果自旋次数太多(默认自旋次数是 10 次,可以通过 -XX:PreBlockSpin 调整),仍然获取不到锁,就升级为重量级锁。
重量级锁
重量级锁依赖操作系统的互斥量(mutex)实现,线程获取不到锁时进入阻塞状态,涉及用户态和内核态的切换,开销最大。这也是为什么早期 synchronized 被诟病性能差的原因——在没有优化的年代,synchronized 直接就是重量级锁。
一个完整的锁升级流程可以这样理解:
- 线程 A 进入同步块,JVM 检查 Mark Word,发现无锁状态,记录线程 A 的 ID,进入偏向锁状态。
- 线程 A 再次进入同步块,检查通过,直接进入。
- 线程 B 来竞争,偏向锁撤销,升级为轻量级锁,线程 B 自旋等待。
- 自旋超过阈值,锁升级为重量级锁,线程 B 进入阻塞队列。
- 线程 C 来竞争时,直接进入重量级锁的阻塞队列。
JDK 15 之后,偏向锁被标记为废弃,JDK 18 中默认禁用了偏向锁。原因很简单:偏向锁的撤销和重偏向逻辑带来的复杂度,在现代应用场景中(大量短生命周期对象、高并发竞争)收益已经不明显。但锁升级的整体框架仍然清晰:无锁 → 轻量级锁 → 重量级锁。
3.3 monitor:重量级锁的底层数据结构
重量级锁的底层是 monitor 对象,也叫管程。每个 Java 对象在需要时都会关联一个 monitor,通过 ObjectMonitor 实现。
monitor 包含三个关键部分:
- Owner:当前持有锁的线程。
- EntryList:竞争锁失败、进入阻塞状态的线程队列。
- WaitSet:调用
wait()方法后进入等待状态的线程队列。
wait() 和 notify() 方法的实现就依赖于 monitor 的这些队列。wait() 让当前线程释放锁,进入 WaitSet;notify() 从 WaitSet 中唤醒一个线程,让它重新进入 EntryList 竞争锁。
这里有个面试高频考点:为什么 wait() 和 notify() 必须放在 synchronized 代码块中? 因为这两个方法需要操作 monitor 对象的 WaitSet,而只有持有锁的线程才能访问 monitor。如果不在 synchronized 块中调用,线程无法确认自己持有锁,也无法保证 WaitSet 操作的安全性。这也是为什么调用 wait() 后会释放锁——它要把 monitor 的 Owner 让出来,否则其他线程永远无法进入同步块。
4. 实战中的坑与排查技巧:synchronized 并没有想象中那么安全
4.1 锁对象变了,锁就失效了
这是我在线上踩过的一个比较隐蔽的坑。当初为了灵活性,把锁对象设计成可变的:
java复制public class ResourceManager {
private Object lock = new Object();
public void setLock(Object newLock) {
this.lock = newLock;
}
public void execute() {
synchronized (lock) {
// 业务逻辑
}
}
}
问题出现了:如果某个线程在 execute 执行期间,另一个线程调用了 setLock() 改换了 lock 对象,那么后续进入 execute 的线程拿到的锁对象跟之前不同,原本的互斥保护就失效了。当时排查了很久才发现是运行时被某个工具类替换了 lock 引用。
经验是:锁对象必须是 final 的。除非你能 100% 确定不会有人修改锁对象的引用,否则不要用非 final 对象做锁。
4.2 synchronized 方法和 synchronized(this) 是同一把锁
很多人会忽略一个点:synchronized 修饰的实例方法,等价于用 synchronized(this) 包裹整个方法体。这意味着,如果你在一个类里同时用这两种方式保护不同的代码,它们用的是同一把锁,会互相阻塞。
java复制public class AccountService {
public synchronized void methodA() {
// 锁的是 this
}
public void methodB() {
synchronized (this) {
// 锁的也是 this,和 methodA 互斥
}
}
}
这不一定是坏事,但你要清楚它带来的性能影响。如果 methodA 很耗时,methodB 也会被阻塞。根据实际场景,灵活选择 synchronized 方法和 synchronized(this) 的粒度。
4.3 synchronized 与 volatile:什么时候可以替代
遇到共享变量的可见性问题,有些人习惯直接用 volatile。两者确实有重叠,但定位不同:
volatile保证可见性和有序性,不保证原子性。synchronized同时保证原子性、可见性和有序性。
所以单靠 volatile 无法实现 count++ 这类复合操作的原子性。但如果只是用一个 boolean 标志位控制线程启停,volatile 就足够了,不需要 synchronized 的互斥开销。
java复制public class Worker implements Runnable {
private volatile boolean running = true;
public void stop() {
running = false;
}
@Override
public void run() {
while (running) {
work();
}
}
}
4.4 synchronized 和 ReentrantLock 怎么选
面试经常被问到这个对比,实际开发中也经常纠结。两者的主要差异:
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 锁获取方式 | 自动,JVM 管理 | 手动 lock() / unlock() |
| 锁释放 | 自动释放,异常自动解锁 | 必须手动释放,通常在 finally 中 |
| 公平性 | 非公平 | 支持公平和非公平 |
| 可中断性 | 不支持,线程会一直等待 | 支持 lockInterruptibly() |
| 超时机制 | 不支持 | 支持 tryLock(timeout) |
| 条件变量 | 通过 wait() / notify() | 支持多个 Condition |
| 性能 | JDK 1.6 后经过优化,性能接近 | 在高竞争场景下略优 |
我的建议是:默认优先用 synchronized。代码简洁,不存在忘记释放锁的问题。只有在需要超时、可中断、多个条件队列等高级功能时,才考虑 ReentrantLock。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 加了 synchronized 但仍然出现并发问题 | 锁对象不是同一个实例 | 确认所有线程操作的是同一个对象 |
| 锁失效,多个线程同时进入临界区 | 锁对象引用被修改 | 锁对象用 final 修饰 |
| 使用静态同步方法未生效 | 类被多个 ClassLoader 加载 | 检查 ClassLoader 体系 |
| synchronized 代码块内调用 wait() 后不执行 | notify() 没有执行或使用 notifyAll() | 检查条件判断和唤醒逻辑 |
| 多线程访问静态变量互斥失效 | 实例 synchronized 和静态 synchronized 混淆 | 明确锁的级别,统一用类锁 |
5. 写在最后的体会
synchronized 这套知识体系,看起来简单,但每个“简单”背后都有复杂的机制支撑。偏向锁为了处理单线程重复进入的场景,轻量级锁为了减少用户态和内核态的切换开销,重量级锁保证绝对互斥——每一步优化都是针对真实场景的性能折衷。
从面试的角度说,能把这个知识体系完整串起来的人,对并发编程的理解基本就过关了。从实战的角度说,理解 synchronized 的边界,知道它哪里能兜底、哪里兜不住,才能写出真正健壮的多线程程序。
最后分享一个我个人的习惯:在写 synchronized 之前,先问自己三个问题——我要保护的数据是什么?所有访问这段数据的线程是否共享同一个锁对象?锁的粒度是否足够小,不影响核心业务的并发能力?这三个问题想清楚,再动手写代码,能少踩很多坑。
另外多说一句,JDK 21 之后虚拟线程(Virtual Threads)的普及,让很多传统同步代码的写法变得没那么“昂贵”了,但这不等于 synchronized 过时了。虚拟线程的 pinning 问题(即 synchronized 块中虚拟线程会钉在载体线程上,可能导致载体线程被阻塞)目前依然存在,所以在高并发场景下的锁选型,仍然要结合具体场景做权衡。synchronized 作为一个语言内建的关键字,它的地位在短期内不会动摇。
