前几周做代码评审,看到同事写了一个 synchronized 方法,又有一个新同事问:“这个锁到底锁的是方法,还是锁的是对象?” 当时很多人随口就答“锁的是方法”,但真让他解释对象头、Monitor、锁升级,场面就安静了。我自己也是在一次线上超卖事故里被迫把 synchronized 的底层链路啃了一遍,越往深处越发现,JDK 对这把锁做的优化远比想象中多。这篇文章不打算背八股,就从那段出问题的代码讲起,沿着“用法 → 字节码 → 对象头 → Monitor → JDK 优化 → 实测踩坑”这条线,把 synchronized 从表面到原理完整捋一遍。
1. 从一个并发事故讲起:synchronized 锁的到底是什么
1.1 一段能稳定复现的并发 Bug
先看一段非常典型的代码。两个线程同时执行 increment(),理想结果当然是 200000,但实际跑起来经常是 19 万出头,甚至更少。
java复制public class SyncDemo {
private int count = 0;
public void increment() {
count++;
}
public static void main(String[] args) throws InterruptedException {
SyncDemo demo = new SyncDemo();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
demo.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
demo.increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(demo.count);
}
}
很多人第一反应是“给 count++ 加锁”。这个方向没错,但必须说清楚为什么。count++ 看起来是一行代码,编译成字节码之后其实至少三步:读取当前值、加 1、写回。两个线程可能同时读到同一个旧值,各自加 1 后再写回,结果就少了一次递增。这就是典型的“读改写”竞态条件。
如果把 increment() 声明为 synchronized,结果会稳定变成 200000。所以 synchronized 做的最核心的一件事,就是保证同一时刻只有一个线程能进入受保护的临界区,并且让临界区内的读写对其他线程可见。
1.2 锁的职责不是“阻止执行”,而是“划定临界区”
我见过不少刚入行的同学会把“加锁”理解成“让代码不能执行”,这是错的。锁的真正作用是划定临界区,并规定进入临界区的规则:同一时刻只允许一个线程持有锁;其他线程想进来,必须等在门外。等前一个线程退出临界区并释放锁,等待者才有机会竞争。
用生活里的例子类比,厕所门上的插销就是一把锁。门本身不阻止任何人靠近,但插销一旦扣上,同一时间只有里面的人能用。外面的人要么等,要么换地方。代码里的临界区就是“厕所”,锁对象就是“插销”。
这里有个很容易搞混的点:synchronized 修饰代码块时,括号里的对象决定用哪把锁;修饰方法时,JVM 默认给你指定了锁。也就是说,synchronized 从来都不是在锁“代码”,而是在锁“对象”,代码只是被这个对象锁保护起来的区域。
1.3 每个 Java 对象都自带一把“内置锁”
这是理解 synchronized 的基石:在 HotSpot 虚拟机里,任何一个 Java 对象天生就携带一把可以被 synchronized 使用的锁,官方术语叫内置锁,也叫 Monitor 锁。无论是 new Object() 还是自定义的实体类对象,它都有这把锁,只是平时没人用它而已。
这把锁和对象本身强绑定,存在对象头里。于是“给方法加锁”“给代码块加锁”在底层全都等价于“拿某个对象的 Monitor”。于是问题就来了:到底拿哪个对象的?这就引出了 synchronized 的三种经典用法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三种加锁姿势与方法级/块级锁的等价关系
2.1 实例方法、静态方法、同步代码块到底锁谁
jdk 中 synchronized 的三种写法,区别只在锁对象的选择上。下面这张表建议直接收藏。
| 写法 | 锁对象 | 等价写法 |
|---|---|---|
| 修饰实例方法 | 当前实例 this |
synchronized (this) { ... } 包住整个方法体 |
| 修饰静态方法 | 当前类的 Class 对象 |
synchronized (Xxx.class) { ... } 包住整个方法体 |
| 修饰代码块 | 括号里手动指定的任意对象 | 无等价写法,因为锁对象可自定义 |
特别注意:synchronized 修饰静态方法和修饰实例方法,用的是两把完全不同的锁。前者是 Class 对象,后者是实例对象。如果一个类里既有静态同步方法,又有实例同步方法,两个线程可以分别进入它们,因为它们争夺的不是同一把锁。
java复制public class LockDemo {
// 锁的是 LockDemo.class
public static synchronized void staticMethod() {
// do something
}
// 锁的是 this 实例
public synchronized void instanceMethod() {
// do something
}
// 锁的是自定义对象
private final Object lock = new Object();
public void blockMethod() {
synchronized (lock) {
// do something
}
}
}
我见过不少人在静态方法上加 synchronized,然后又在实例方法里加 synchronized,以为“都是同一个类的锁,会互斥”,结果线上并发出问题。它们互斥个寂寞,锁对象根本不是同一个。
2.2 代码块锁粒度控制的真实案例
修饰方法的写法最简单,但锁的粒度也最粗。如果一个方法里既有需要保护的共享变量操作,又有不需要保护的计算或 IO,把整个方法都锁住会白白损失并行度。
举个实际例子:一个订单服务,每次调用要先查一下用户信息,再修改订单状态,最后发一条通知。用户查询和通知都是耗时操作,与订单状态的并发安全无关。如果整个方法加 synchronized,所有用户都串行排队,性能直接崩。正确的做法是只把“修改订单状态”这一段放入 synchronized 代码块,锁对象选一个专门的锁对象,或者干脆用订单号对应的对象锁。
java复制private final Object lock = new Object();
public void processOrder(Order order) {
User user = userService.getById(order.getUserId()); // 不需要锁
synchronized (lock) {
order.setStatus(OrderStatus.PROCESSED); // 需要保护
orderDao.update(order);
}
notifyService.send(order); // 不需要锁
}
锁粒度越小,竞争概率越低,吞吐越好。但也不是越小越好,如果拆得太碎,业务上无法保证原子性,反而引入新的 Bug。后面我会专门聊这个话题。
2.3 可重入:同一线程能反复进入同一把锁
synchronized 是可重入锁。所谓可重入,就是同一个线程已经持有一把锁之后,再次请求同一把锁,不会被自己挡住。最典型的场景是递归调用,或者一个同步方法内部调用同类里的另一个同步方法。
java复制public synchronized void outer() {
// 当前线程已持有 this 的锁
inner();
}
public synchronized void inner() {
// 再次获取 this 的锁,合法
}
如果 synchronized 不可重入,上面这段代码一进入 inner() 就死锁了。操作系统层的互斥量本身也有类似机制,但 synchronized 的可重入是在 Monitor 层面通过计数器实现的。每次重入计数器加 1,每次退出减 1,减到 0 才真正释放锁。这个计数器就是后面会讲到的 _recursions 字段。
2.4 锁对象最常见的三个坑
我在这块踩过的坑足够写一整篇,先列几个高频的。
第一,锁对象是 String 字面量或者 Integer 缓存对象。"lock" 这个字符串在常量池里可能被多处共享,你以为你在锁自己的锁,结果别人也在锁同一把锁。更危险的是,不同业务模块互相干扰,导致莫名其妙的阻塞。
java复制// 不推荐,字符串可能引用常量池里的同一个对象
synchronized ("lock") { ... }
第二,锁对象在运行期被重新赋值。synchronized 锁的是对象引用指向的那个对象,而不是引用本身。如果代码里执行了 lock = new Object(),后来的线程拿的是新对象的 Monitor,旧锁形同虚设。
java复制private Object lock = new Object();
public void bad() {
synchronized (lock) {
// 如果此时别的线程执行 lock = new Object();
// 后续再进入这里的线程用的是新 lock,锁被突破
}
}
第三,锁对象是 null。synchronized (null) 会直接抛出 NullPointerException,因为 null 没有对象头,自然也没有 Monitor。这听起来很蠢,但当你把锁对象从外部传入、没有做判空时,很容易中招。
提示:锁对象最好用
private final修饰,保证不可变、不可替换,也避免被外部拿到引用后在别处意外synchronized(lock)造成跨模块干扰。
3. 走进字节码与对象头:synchronized 的底层载体
3.1 javap 看编译产物:monitorenter 与 monitorexit
用 javap -v -p 查看上面 SyncDemo 编译后的字节码,会看到两个关键指令:monitorenter 和 monitorexit。
bash复制javap -v -p SyncDemo.class
monitorenter 出现在进入同步代码块的位置,monitorexit 出现在正常退出和异常退出的位置,所以字节码里通常能看到两个 monitorexit,保证异常情况下锁也能被释放。这也是 synchronized 相比一些手动加锁方式更安全的原因:JVM 层面强制处理了异常路径。
如果加锁的是整个方法,比如 public synchronized void increment(),字节码里看不到 monitorenter 和 monitorexit,而是方法的访问标志 ACC_SYNCHRONIZED。JVM 看到这个标志,就知道进入方法前要先获取锁,方法返回或异常退出时释放锁。两种方式底层最终都会走对象的 Monitor,只是入口标识不同。
3.2 Java 对象的内存布局与 Mark Word
HotSpot 里,一个 Java 对象在堆中的布局由三部分组成:对象头、实例数据、对齐填充。synchronized 的锁状态就存在对象头里,所以这一步必须看懂。
对象头又分两部分:第一部分是 Mark Word,存储运行时数据;第二部分是类型指针 Klass Pointer,指向方法区的类元数据,用来确定这个对象是哪个类的实例。开启压缩指针后,类型指针通常 4 字节;Mark Word 在 64 位虚拟机上固定 8 字节。
Mark Word 里包含哈希码、GC 分代年龄、锁状态标志位、偏向锁线程 ID、锁记录指针等。关键点在于,这些信息不是同时存的,而是根据锁状态复用同一块空间。这就好比一个多用途抽屉,不同场景下放的东西不一样。
3.3 Mark Word 的几种状态布局
在 JDK 15 以前,锁状态主要分五种:无锁、偏向锁、轻量级锁、重量级锁、GC 标记。我用一张表列出关键位(以 64 位 JVM 为例,不同版本位数有细节差异,但逻辑一致)。
| 锁状态 | Mark Word 关键内容 | 锁标志位 |
|---|---|---|
| 无锁 | 对象哈希码、分代年龄 | 01 |
| 偏向锁 | 偏向线程 ID、epoch、分代年龄 | 01 |
| 轻量级锁 | 指向栈中 Lock Record 的指针 | 00 |
| 重量级锁 | 指向 ObjectMonitor 的指针 | 10 |
| GC 标记 | 空(留给 GC 使用) | 11 |
注意一点,无锁和偏向锁的标志位都是 01,靠前面的偏向锁标志位来区分。JDK 15 之后偏向锁默认关闭,所以一般只有四种状态。
3.4 ObjectMonitor:重量级锁的“服务器”
当锁膨胀为重量级锁时,Mark Word 里存的指针会指向一个 ObjectMonitor 对象。这是 HotSpot 虚拟机内部用 C++ 实现的一个同步器,可以理解成一把锁的“服务器”,它负责记录谁持有锁、谁在排队、谁在等待。
ObjectMonitor 里有几个核心字段:
_owner:当前持有锁的线程。_recursions:锁的重入次数,为 0 表示锁未被持有或已释放。_EntryList:等待获取锁的线程队列。_WaitSet:调用了wait()并等待被唤醒的线程队列。
wait() 和 notify() 也是基于 Monitor 实现的。所以 Java 里凡是配合 synchronized 用的是 wait/notify,不能脱离 synchronized 单独调,因为线程必须先持有这把锁,才有资格注册到对应的 _WaitSet 上。
4. JDK 6 以来的优化:从偏向锁到重量锁的升级链路
4.1 优化之前的 synchronized 为什么被叫“重量级锁”
早期的 synchronized 走的是操作系统的互斥量,一旦竞争不到锁,线程就会被挂起,进入内核态。用户态切换到内核态,代价非常大,这是 Java 并发性能被诟病的重要原因之一。
但其实不是所有场景都需要这种“重武器”。很多时候锁竞争非常短暂:一个线程进入临界区,几微秒就出来了,其他线程完全可以稍微等一下,而不是立刻挂起。JDK 6 的优化思路就是围绕“尽量别挂起线程”展开的,最终形成了无锁 → 偏向锁 → 轻量级锁 → 重量级锁的升级链路。
4.2 偏向锁:一个线程反复进入同一把锁
偏向锁是针对“同一个线程反复获取同一把锁”的场景。在 JDK 15 以前默认开启时,当某个线程第一次进入同步块,JVM 会通过 CAS 把线程 ID 写入 Mark Word,表示“这把锁偏爱这个线程”。之后这个线程再进出同步块,只需要检查 Mark Word 里的线程 ID 是不是自己,如果是,直接进入,连 CAS 都不需要。
一旦有另一个线程来竞争,偏向锁就要撤销。偏向锁的撤销需要等待全局安全点,也就是所有线程都暂停的时刻,因此撤销成本很高。正因如此,在高竞争场景下偏向锁不但不省事,反而增加开销。
JDK 15 的 JEP 374 把偏向锁默认关掉了,JDK 17 也维持这个状态,并且标记为废弃。原因是现代应用里大量使用线程池,线程数量多,锁竞争普遍,偏向锁带来的收益已经不如它的维护成本。这也是为什么你在 JDK 17 上运行并发程序,jstack 里很少看到与偏向锁相关的内容。
提示:如果确实想验证旧版行为,可以用
-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0启动 JVM,但生产环境不建议再开。
4.3 轻量级锁:CAS 替换 Mark Word
当偏向锁被撤销,或者 JDK 15 之后没有偏向锁阶段,下一个机制是轻量级锁。它的核心思想:用 CAS 代替互斥量。
线程进入同步块时,会在自己的栈帧中创建一个 Lock Record,然后尝试用 CAS 把 Mark Word 里的内容替换成指向这个 Lock Record 的指针。如果替换成功,锁就拿到了,Mark Word 变成 00 状态;如果替换失败,说明有人正在用这把锁,锁会膨胀为重量级锁。
轻量级锁的退出过程也是 CAS:线程把 Mark Word 恢复原样。整个过程没有线程挂起,非常适合“临界区很短、竞争很少”的情形。但如果竞争非常激烈,CAS 大概率失败,线程照样会挂起,这时候轻量级锁反而多了一次 CAS 的成本。
4.4 重量级锁:互斥量的兜底
当竞争进一步加剧,轻量级锁 CAS 迟迟不成功,JVM 会把锁膨胀为重量级锁。此时 Mark Word 里存的是指向 ObjectMonitor 的指针,线程获取锁的流程变成:先尝试自旋,自旋失败进入 _EntryList,被操作系统挂起,等待前一个线程释放锁时唤醒。
重量级锁保证的是一对一互斥,所以不存在“两个线程同时进来”的可能。代价就是线程状态切换和内核调用。在绝大多数现代 JDK 上,只要竞争真的激烈,最终都会走到这一层,所以不要指望 synchronized 能避免所有挂起。
4.5 自旋锁与自适应自旋:挂起前的最后缓冲
在膨胀为重量级锁的过程中,JVM 不会立刻把线程挂起,而是先让线程“空转”一会儿,反复尝试获取锁,这就是自旋。自旋的初衷是赌“持有锁的线程马上就会释放”,与其切换线程,不如原地等。
早期自旋次数是固定的,JDK 6 之后引入了自适应自旋。JVM 会根据上一次自旋等待的成功率,动态调整下一次自旋的次数。如果上次自旋成功,说明锁竞争时间短,这次就多转一会儿;如果上次自旋失败,说明竞争时间长,就少转甚至直接挂起。
自旋是会消耗 CPU 的,所以它适合临界区非常短、锁持有时间很低的场景。如果临界区里有数据库操作、远程调用这种耗时代码,自旋基本是浪费 CPU,这属于代码层面没把锁粒度控制好,不能指望 JVM 优化兜底。
4.6 锁消除与锁粗化:JIT 的额外惊喜
除了锁升级,还有两个偏编译期的优化。
锁消除发生在 JIT 编译时,基于逃逸分析。如果 JVM 发现一个锁对象根本没有逃逸出当前线程,也就是只有当前线程能访问它,那么这个锁就是多余的,会直接被去掉,连加锁动作都不做。比如局部变量作为锁对象,每次调用都新建,且不会被其他线程看到,JIT 可能会把这个 synchronized 块整体优化掉。
锁粗化则是反过来。如果 JIT 检测到连续一小段代码反复对同一把锁加锁解锁,比如循环里的 synchronized 块,它会把这些操作合并成一个更大的同步范围,减少频繁加锁解锁的开销。这也能解释为什么循环里写 synchronized 不一定要手工拆到循环外,JIT 有时会帮你做。
5. JDK 17 实测:对象头变化、线程状态与避坑清单
5.1 用 JOL 观察对象头的锁状态变化
理论说多了容易飘,还是得实测。最常见的方式是用 OpenJDK 的 JOL 工具来看对象内存布局。
xml复制<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
java复制import org.openjdk.jol.info.ClassLayout;
public class JOLDemo {
public static void main(String[] args) {
Object obj = new Object();
System.out.println("无锁状态:");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
synchronized (obj) {
System.out.println("持有锁状态:");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
}
}
在 JDK 17 上运行,无锁状态的 Mark Word 一般是 0x0000000000000001,没有偏向位。进入 synchronized 块后,Mark Word 会变成指向 Lock Record 的指针,也就是轻量级锁状态。如果你开了偏向锁参数,上锁后就能看到 Mark Word 记录了线程 ID。这个实验特别适合用来理解“锁状态存在对象头里”这件事。
当多个线程同时竞争时,持有锁的线程用 JOL 看对象头,会观察到从轻量级锁升级成重量级锁的过程,Mark Word 里的指针变成指向 ObjectMonitor。这种直观感受比背十遍升级链路都管用。
5.2 竞争激烈时用 jstack 看线程到底卡在哪
线上排查锁问题,最实用的工具是 jstack。线程如果卡在 synchronized 上,通常会看到两种状态:
BLOCKED:线程在_EntryList里等待重量级锁。WAITING或TIMED_WAITING:线程调用了wait()/join()/sleep()等方法。
bash复制jstack -l <pid>
抓到的线程栈里会明确显示哪一行代码在等待。我印象最深的一次线上事故,就是靠 jstack 连续抓了两份快照,发现大量线程都堵在同一个订单更新方法上,最后定位到锁对象用错、锁粒度过大。
真实场景里,如果大量线程 BLOCKED 且长时间不退,常见原因有三个:锁内做了耗时的网络调用、锁对象竞争异常激烈且临界区太长、某个线程持锁后因为异常或死锁没释放。虽然 synchronized 会自动释放锁,但线程如果卡在锁内部的 IO 上,释放仍然遥遥无期。
5.3 避坑清单:这些教训都是真金白银踩出来的
结合排查经验,我整理了五条最常见的坑。
第一,不要在 synchronized 块里做远程调用。数据库查询、HTTP 调用、Redis 操作都可能耗时几十毫秒甚至更久,一旦持锁执行,其他线程全堵住,系统吞吐瞬间归零。
第二,注意锁对象的可见性。如果锁对象不是 final 的,并且被多个线程共享修改,可能导致不同线程拿到不同的锁,锁保护直接失效。
第三,synchronized 无法响应中断,也无法设置超时。如果持有锁的线程因为某种原因一直不释放,其他线程只能无限等下去。需要超时控制时,考虑 ReentrantLock 的 tryLock。
第四,不要用 synchronized 锁 Integer 等包装类型的缓存对象。Integer 在 -128 到 127 之间有缓存实列,多个无关线程可能共享同一个缓存对象,导致意外互斥。
第五,警惕 wait() 和 notify() 的顺序问题。wait() 必须在持有锁时调用,而且要在循环里判断等待条件,不能简单 if,否则可能被虚假唤醒。
6. 选型与工程心得:什么时候继续用 synchronized,什么时候换 Lock
6.1 synchronized vs ReentrantLock vs StampedLock
很多人在选型时纠结用哪个锁。我做了个对比表,方便一眼看清差异。
| 能力 | synchronized | ReentrantLock | StampedLock |
|---|---|---|---|
| 语法复杂度 | 最简单,方法或代码块即可 | 需要手动 lock/unlock | 更高 |
| 自动释放锁 | 是,异常也释放 | 否,必须 finally 解锁 | 否 |
| 可重入 | 是 | 是 | 是 |
| 可中断等待 | 否 | 是 | 部分 |
| 支持超时 | 否 | 是 | 部分 |
| 公平锁 | 否 | 可配置 | 否 |
| 多条件队列 | 只能配合 wait/notify | Condition 可多个 | 无 |
| 读写分离 | 不支持 | 不支持 | 读锁写锁 |
从性能角度看,JDK 6 优化之后,synchronized 和 ReentrantLock 在常规竞争下差距已经很小,甚至在低竞争场景下 synchronized 因为偏向锁和锁消除的存在可能更快。不要迷信“Lock 一定比 synchronized 快”这种过时结论。
6.2 从 ConcurrentHashMap 演进看 synchronized 的现代地位
ConcurrentHashMap 是个很有意思的案例。JDK 7 时代它用分段锁,每段一把锁,降低竞争粒度;JDK 8 之后换成了 CAS + synchronized 锁桶节点。连 JDK 自己都在新实现里回归了 synchronized,这说明经过优化的它已经足够高效。
JDK 8 在向桶里插入元素时,如果当前桶是空的,用 CAS 直接放入;如果桶里已经有链表或红黑树,才用 synchronized 锁住头节点。也就是说,不同桶之间天然并行,只有哈希冲突到同一个桶的写操作才互斥。这种设计比单纯一个大锁高效得多。
这也给了我一个启发:不要一上来就上高级锁,先用好 synchronized 配合合理的数据结构设计,很多并发问题就能解决。
6.3 我在并发写代码时的几条工程原则
压箱底的几条心得,分享给你们。
第一,优先用 synchronized,因为它写起来最简单,不会因为忘记解锁而出问题。在不需要可中断、可超时、公平排队这些高级能力时,不要引入多余的复杂度。我一个朋友的项目里出现过 ReentrantLock 忘记在 finally 里解锁,比 synchronized 惨痛多了。
第二,锁粒度优先考“业务原子性”。不要为了性能把必要的复合操作拆开,也不要为了省事锁整个方法。先保证正确,再谈优化。如果锁粒度调小后依然不够快,再考虑读写分离、分段锁、无锁化。
第三,能无锁就无锁。ThreadLocal、volatile、AtomicInteger、不可变对象都是比加锁更高级的姿势。数据不可变,就根本不需要锁;每个线程自己一份数据,隔离了就没有竞争。锁是解决问题的兜底手段,不是首选方案。
第四,设计锁顺序,避免死锁。如果多个线程需要同时持有多个锁,一定要保证所有线程加锁的顺序一致,否则就可能死锁。比如线程 A 先拿锁 1 再拿锁 2,线程 B 先拿锁 2 再拿锁 1,两边互等,谁也进不去。用 synchronized 时没有超时机制,死锁一旦发生很难自动恢复,只能靠 JVM 参数或重启解决。
第五,压测环境一定要模拟真实竞争。synchronized 在单线程下加了等于没加,感觉不到任何问题;一旦多线程压测,对象头里的锁状态、GC 停顿、线程调度全都会放大出来。我自己每次改动锁相关代码,都会用高并发压测脚本跑一遍,再结合 jstack 看线程分布。
现在回头看,当年那个 synchronized 到底锁了什么的问题,答案其实一句话:锁对象,不是锁方法。但这句话背后是从对象头到 Monitor、从偏向锁到重量锁的一整套机制。真想把它用好,把这些机制串起来理解一遍,比背多少面试题都值。
