入行头几年,我一度觉得synchronized是 Java 里最“没技术含量”的关键字:往方法上一放,或者往代码块上一包,并发问题好像就解决了。直到有一次线上出现偶发数据错乱,排查到最后发现两个线程进的“锁”根本不是同一把,我才意识到:真正理解 synchronized,光知道语法没用,你得搞清楚它背后到底锁住了哪个对象、JVM 又是怎么帮你完成互斥的。
后端的并发面试里,“synchronized 有哪几种用法”几乎是个必问题。但很多人答完“修饰实例方法、修饰静态方法、修饰代码块”就停了,这恰恰丢了最关键的考察点。面试官真正想听的是:三种写法对应的锁对象分别是谁?锁的粒度有差别?字节码层面有什么不同?带着这些问题往下走,你会发现 synchronized 的内容远比想象中深。
1. 三种使用方式,对应着三种完全不同的锁面
1.1 先看最小可运行的三种写法
直接上代码,这是最直观的入口。假设我们要给一个计数器做并发安全的自增:
public class CounterService { private int count = 0; // 方式一:同步实例方法,等价于 synchronized(this) 包住整个方法体 public synchronized void addByMethod() { count++; } // 方式二:同步静态方法,等价于 synchronized(CounterService.class) 包住整个方法体 public synchronized static void resetStatic() { // 静态方法操作的是类级别的共享数据 } // 方式三:同步代码块,可以精确控制锁的范围和锁对象 public void addByBlock(Object lock) { synchronized (lock) { count++; } } }三种写法最大的差别不在语法形态,而在 JVM 最终拿哪个对象作为互斥依据。
- 实例方法上加
synchronized:锁的是调用这个方法的实例对象,也就是this。 - 静态方法上加
synchronized:锁的是当前类的Class对象,因为静态方法不依赖实例,不能锁this。 - 代码块加
synchronized:锁的是括号里指定的那个引用,可以传this、可以传class,也可以传一个独立的Object。
网上很多总结喜欢说“静态方法锁 Class,实例方法锁 this,代码块锁指定对象”,这句话没问题,但没有解释为什么这样分。实际上根源只有一个:Java 里任何对象都可以当锁,而 JVM 的同步机制只认对象,不认“方法”。一个方法要想被同步,势必要绑定到一个具体的对象上,方法归属哪个对象,最自然就锁哪个对象。静态方法不属于任何实例,那就只能退到 Class 对象这一层。
1.2 同一类不同方式,锁面不一定是同一把
很多初级工程师踩的第一个坑,就是误以为“只要是 synchronized 就一定能互斥”。我在项目里见过一个很典型的例子:
public class OrderService { public synchronized void createOrder() { // 处理建单逻辑 } public synchronized static void cancelOrder() { // 处理取消逻辑 } }如果createOrder是实例方法,锁的是OrderService实例;而cancelOrder是静态方法,锁的是OrderService.class。这两个并不是同一个对象,所以两个线程完全可以同时执行这两个方法,互相之间没有任何阻塞。如果你以为“都是 OrderService 里的同步方法”而把业务状态设计成互斥的,那这里就会出现并发覆盖。
同理,代码块锁this和同步代码块锁OrderService.class,也是两套互不干扰的锁。做并发设计前,先画一条“锁面对象”线,比直接写代码重要得多。
1.3 判断代码块在锁谁,用“运行时对象”而不是“变量类型”
代码块锁的对象,是运行时实际传给 synchronized 括号的那个引用,不是变量声明的静态类型。这个很多人容易忽略,来看一个反直觉的小例子:
public class LockHolder { private static final Object LOCK = new Object(); public void methodA() { synchronized (LOCK) { // 业务代码 } } public void methodB() { Object localLock = LOCK; synchronized (localLock) { // 业务代码 } } }运行时localLock和LOCK指向同一个对象,两个方法就能互斥。反之如果你在括号里写new Object(),每次执行都新建一个锁对象,那就完全失去了锁的意义——每个线程都持有不同的“锁”,彼此谁也不会拦谁。
2. 对象头里的 Mark Word 与 Monitor:锁到底存放在哪
2.1 Java 对象不是只有字段,还有一层“对象头”
学 synchronized 绕不开 JVM 的对象内存布局。JVM 中一个 Java 对象在堆内存里大致分为三块:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。对象头里最关键的是 Mark Word,它是一块动态变化的数据区,在不同时刻记录不同的信息。
放在 Java 8 的 64 位 JVM 下看,Mark Word 通常长这样:
| 锁状态 | Mark Word 中存储的内容 |
|---|---|
| 无锁 | 对象的 identity hashcode、分代年龄、偏向锁标志位 0、锁标志位 01 |
| 偏向锁 | 持有偏向锁的线程 ID、epoch、分代年龄、偏向锁标志位 1、锁标志位 01 |
| 轻量级锁 | 指向栈中锁记录的指针,锁标志位 00 |
| 重量级锁 | 指向堆中 Monitor 对象的指针,锁标志位 10 |
这份分布说明了一个关键结论:synchronized 锁的信息不是单独存在某个“全局锁表”里,而是直接写进了被锁对象的对象头。对象头就像是对象身上的一块“状态黑板”,线程们能不能进入临界区,就看谁能在黑板上成功写下自己的标记。
2.2 Monitor 机制:每个 Java 对象背后都藏着一间“监控室”
但互斥逻辑总不能只靠 CAS 改一个 Mark Word 就完成,后面还有“抢不到锁的线程怎么办”“持锁线程释放后叫醒谁”这些问题。JVM 给出的答案是:每个对象都可以关联一个 Monitor(监视器锁),重量级锁的实现依赖它。
可以把 Monitor 理解成一个独立的“房间管理设备”,里面最关键的数据包括:
_owner:当前持有锁的线程。_recursions:可重入次数计数,同一个线程重复进入同一把锁时会累加。_WaitSet:调用了wait()后进入等待状态的线程队列。_EntryList:想要获取锁但还没抢到的线程队列。
线程执行synchronized进入临界区,本质上就是去竞争某个对象的 Monitor。抢到后_owner指向自己,_recursions加 1;方法执行完并退出同步块,_recursions减 1,减到 0 时说明锁已释放,再从等待队列中唤醒线程。
为什么这里要单独强调“可重入”?因为很多死锁疑案其实不是死锁,而是线程自己把自己锁住了。Java 的 synchronized 是可重入锁,意味着同一个线程可以多次进入同一把锁,不需要释放再重抢。这种设计是必要的:很多时候同步方法内部会再调用另一个同步方法,如果锁不可重入,那一次简单调用就变成自我死锁了。
2.3 可重入性在代码里长什么样
看下面这段代码,它不会死锁:
public class ReentrantDemo { public synchronized void outer() { // 进入时 count=1 inner(); // 出来时 count 回落到 0 } public synchronized void inner() { // 再次进入同一把锁,count=2 // 正常执行,不会阻塞 } public static void main(String[] args) { new ReentrantDemo().outer(); } }两个方法都是实例同步方法,锁的都是同一个ReentrantDemo实例。执行outer()时线程已经持有了该实例的 Monitor,走到inner()时 JVM 检测到持有者就是当前线程,不会重新阻塞,而是给重入计数加 1。这就是_recursions存在的意义。
3. 反编译手段确认同步逻辑:从字节码看两种底层指令
3.1 javap 是理解 synchronized 的“照妖镜”
写 Java 的人一定要习惯javap -v。它能把你写的高级语法还原成 JVM 真正执行的字节码,“锁到底怎么生效”这种问题,看一遍字节码就全清楚了。
先准备一个测试类,包含一个同步方法和一个同步代码块:
public class SyncDemo { private int total = 0; public synchronized void methodLock() { total++; } public void blockLock(Object lock) { synchronized (lock) { total--; } } }编译之后用命令查看:
javac SyncDemo.java javap -v SyncDemo.class会看到同步方法methodLock()的访问标志里多了一项:
flags: (0x0029) ACC_PUBLIC, ACC_SYNCHRONIZED也就是说,同步方法并不是说 JVM 会“特殊翻查方法体里的代码”,而是在方法访问标志上标记了一个ACC_SYNCHRONIZED。执行引擎看到这个标志,就要求当前线程先成功持有方法所属对象的 Monitor,然后才进入方法体;方法正常返回或异常结束,都会自动释放 Monitor。
3.2 同步代码块则是显式的 monitorenter/monitorexit
再看blockLock方法,字节码里面会清清楚楚地出现指令对:
0: aload_1 1: dup 2: astore_2 3: monitorenter 4: aload_0 5: dup 6: getfield #2 // Field total:I 9: iconst_1 10: isub 11: putfield #2 // Field total:I 14: aload_2 15: monitorexit 16: goto 24 19: astore_3 20: aload_2 21: monitorexit 22: aload_3 23: athrow 24: return这段字节码信息量很大,我拆开讲:
monitorenter出现在代码块开头,表示线程要在这里去尝试获取括号中对象的 Monitor。- 正常执行完代码块后,会有一条
monitorexit释放锁。 - 真正需要注意的是:字节码里出现了两条
monitorexit。第 15 行是正常路径的释放,第 21 行是异常路径的释放,中间还夹着一个异常表条目。
这说明编译器在翻译synchronized代码块时,会自动生成异常处理逻辑:哪怕代码块里抛了 RuntimeException,JVM 也会走异常路径的monitorexit把锁释放掉,然后再把异常重新抛出去。这也是为什么 synchronized 锁一般不会因为异常而“锁死”的原因——不用你写 finally,字节码里已经帮你兜底了。
3.3 两种方式背后的统一性
有人会好奇:方法级同步用标志位,代码块同步用指令,两种方式底层逻辑一致吗?答案是基本一致。
- 实例同步方法本质上就等价于在方法体外面包一层
synchronized(this)。 - 静态同步方法等价于包一层
synchronized(当前类.class)。 - 代码块则允许你自定义锁对象,理论上最灵活。
理解到这一层,再回到文章开头那个面试题,你就知道为什么“三种用法”这个问法有深度了——它不仅考语法,还考你能不能把语法翻译到 JVM 层面的执行模型。
4. JDK 6 之后的锁升级链路:偏向锁、轻量级锁、重量级锁的真实路径
4.1 synchronized 早期被诟病“重”,后来靠锁升级翻身
在 JDK 5 及以前,synchronized 一旦被线程竞争,就直接向操作系统申请互斥量,线程抢不到锁会被挂起,进入内核态,代价非常高。所以那会儿很多高并发代码宁可自己写 CAS 也不用 synchronized。
但从 JDK 6 开始,HotSpot 对 synchronized 做了大量优化,核心思路是“能不阻塞就不阻塞,能乐观就乐观”。锁不再是死的状态,而会根据竞争激烈程度动态升级,路径是:
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁要强调一点:这个升级是单向的,锁只能膨胀,不会降级。一旦升级到重量级锁,就保持重量级,直到锁被释放,下一次竞争再从无锁状态重新开始。
4.2 偏向锁的适用场景:只有一个线程反复进入临界区
偏向锁要解决的是“同一个线程反复进入同步块”的成本问题。在没有竞争的情况下,每次执行同步代码都要做 CAS 去抢锁,完全没必要。偏向锁的做法是:第一次有线程拿到锁后,就会在 Mark Word 里记录这个线程的 ID。之后该线程再次进入同步块,只要检查到 Mark Word 里的偏向线程 ID 是自己,就直接通过,不需要任何 CAS 操作。
但偏向锁不处理竞争。一旦另一个线程尝试获取同一把锁,说明“偏向”的前提不成立了,JVM 会让持有偏向锁的线程到达安全点后撤销偏向,让锁升级为轻量级锁。
有个细节很多人不知道:JDK 8 及更早版本里偏向锁默认开启,但有个启动延迟,默认是 4 秒。也就是说应用刚启动那几秒钟,所有锁都在无锁状态走轻量级逻辑,之后才开启偏向。如果做压测,想统一锁状态便于观察,可以把延迟设成 0:
-XX:BiasedLockingStartupDelay=0另外,从 JDK 15 开始,偏向锁被默认禁用,到了 JDK 18 相关实现已被移除。因为偏向锁在竞争稍多的场景里反而要承担额外的撤销成本,现代 JVM 选择尽量减少偏向逻辑的开销,这也解释了为什么有些新版本 JDK 上线后,同样的代码锁相关指标反而更干净。
4.3 轻量级锁的自旋、CAS 与“短暂竞争”假设
偏向锁被撤销后,锁会进入轻量级锁状态。轻量级锁的底层还是 CAS,它会在线程的栈帧中分配一块锁记录空间,然后尝试用 CAS 把对象头 Mark Word 替换成指向锁记录的指针。如果成功,表示抢锁成功;失败说明存在竞争。
轻量级锁的核心假设是“线程竞争时间很短,很多线程可能只是稍微错开了一下”,所以抢不到锁的线程不会立刻挂起,而是会自旋等待,也就是空转 CPU 反复重试 CAS。自旋有次数限制,超过阈值后,锁就会膨胀成重量级锁,线程才真正进入阻塞挂起。
这里有一个非常实用的经验:同步代码块的执行时间越短,锁越适合停留在轻量级状态;如果临界区里做了耗时很长的 IO 操作或远程调用,轻量级自旋纯属浪费 CPU,最好一开始就走重量级锁。而 synchronized 本身无法控制自旋阈值,所以确实存在“适用范围”问题,这正是后面要对比 JUC 锁的原因之一。
4.4 重量级锁:真正的 Monitor 阻塞队列
锁升级到最后就是重量级锁,走的是第 2 节说的 Monitor 机制。抢不到锁的线程会进入_EntryList,不再消耗 CPU,而是通过操作系统线程调度来阻塞与唤醒。代价是线程状态切换涉及用户态和内核态的切换,成本很高。
所以在高并发、临界区执行时间又不短的场景下,synchronized 有时候会显得力不从心。它不能像ReentrantLock那样在等待锁时响应中断、设置超时时间,也不能用tryLock()做非阻塞尝试。理解了锁升级链路,你就知道什么时候适合用 synchronized,什么时候要考虑换锁。
4.5 JDK 版本不同,锁细节差异很大
这里必须提醒一句:讨论锁升级时,不要把 JDK 8 的经验直接套到 JDK 11、JDK 17 上。不同版本的 JVM 在偏向锁默认开关、对象头布局、甚至 GC 与锁协作方式上都有差别。
比如 JDK 8 时代,偏向锁默认开启但延迟 4 秒;JDK 15 开始偏向锁默认关闭;JDK 18 移除偏向锁。如果团队用的是很新的 LTS 版本,网上大量基于 JDK 8 写的老文章会有偏差,最好的方法是拿自己项目的 JDK 跑一个 javap 和锁竞争压测,亲眼看行为。
5. 真实项目里锁粒度与锁对象选择的常见踩坑
5.1 锁粒度太大,接口直接被打垮
我见过一个账单查询接口,原来没有并发问题,后来加了个统计逻辑,开发图省事直接在方法上写同步:
public synchronized BillResult queryBill(String billNo) { // 大量只读查询 // 少量统计逻辑 }在高并发下,这个接口的 TPS 掉到原来的十分之一不到,所有请求都排队等同一把锁。问题在于:查询操作大部分都是只读的,根本不需要全局互斥,一个方法锁把不同类型的账单查询全部串行化了。
改造思路是把锁粒度缩小到真正需要保护的那段写操作,比如“防止重复创建订单”只锁创建动作:
public BillResult createOrder(OrderReq req) { // 前置校验不设锁,并发读没问题 checkParam(req); synchronized (orderCreateLock.intern()) { // 防重校验 + 写入订单 return doCreate(req); } }很多人以为 synchronized 性能差,其实在临界区很短的情况下,轻量级锁的性能相当好。真正差的是用大锁包住大量无关紧要的代码。
5.2 字符串常量作为锁对象:经典的“你以为锁的是 A,其实别人也在锁 A”
Java 字符串有常量池机制,两个字面量相同的字符串,指向的可能是同一个 String 对象。我用字符串做锁的时候,就吃过亏。比如:
public void payByOrder(String orderId) { synchronized (orderId) { // 支付逻辑 } }看起来没什么问题:每个订单号都有自己的锁对象,不同订单互不干扰。但实际上 JVM 里"1001"这种字面量字符串如果被多处复用,所有传同一个值的线程都会拿到同一把锁,导致毫不相干的业务互相排队。更糟的是,如果订单号来自外部请求,字符串对象可能各不相同,反而让锁形同虚设。
正确做法是不要直接用业务字符串当锁,而是维护一个独立的锁对象池,或者用String.intern()把字符串规范到常量池中再锁。不过intern()在 JDK 7 之后进入堆,且可能带来常量池膨胀问题,用之前要想清楚容量控制。
5.3 用可变对象做锁,锁被替换了还在傻等
另一个我在代码 review 时经常看到的坑是:锁对象被声明成普通字段,而且没有 final。比如:
public class InventoryService { private Object lock = new Object(); public void deduct() { synchronized (lock) { // 扣库存 } } public void rebuild() { lock = new Object(); // 锁被换掉了! } }如果某处代码执行了rebuild(),把lock指向了新对象,那后续线程进入deduct()时抢的已经是新锁,和持有旧锁的线程完全不互斥。这种问题极其隐蔽,因为看起来代码没问题,运行现象却像锁失效。
所以锁对象有一个铁律:必须是private static final,或者至少要做到初始化后不可变。用不可变对象当锁,能从根本上避免这种“锁被替换”的诡异问题。
5.4 双重检查锁里到底该不该加 volatile
单例模式里的双重检查锁写法,十个人有八个写不对,问题就出在变量要不要加 volatile 上:
public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }instance必须加volatile,原因是new Singleton()不是原子操作,它可以拆成三步:分配内存、调用构造函数初始化对象、把引用指向这块内存。JVM 和 CPU 都有可能对后两步进行重排,如果另一个线程在“引用已指向内存但对象还没初始化完成”的瞬间读到了非空引用,它拿到的就是一个“半个对象”。
synchronized 只保证临界区内的互斥和释放锁时的可见性,它不能禁止临界区外对共享变量的乱序读取。这里 volatile 的职责是禁止指令重排和保证可见性。所以看到双重检查锁,先看一眼字段有没有 volatile,基本就能判断作者是不是真的理解并发。
6. 死锁场景剖析与 jstack 排查实战
6.1 一个教科书级死锁,但线上真的会发生
死锁不是面试题里才有的东西,真实业务里一旦锁顺序处理不当,直接让线程池里的所有线程互相卡死。最简单的复现是这样的:
public class DeadlockDemo { private static final Object LOCK_A = new Object(); private static final Object LOCK_B = new Object(); public static void main(String[] args) { Thread t1 = new Thread(() -> { synchronized (LOCK_A) { System.out.println("t1 get LOCK_A"); sleep(100); synchronized (LOCK_B) { System.out.println("t1 get LOCK_B"); } } }); Thread t2 = new Thread(() -> { synchronized (LOCK_B) { System.out.println("t2 get LOCK_B"); sleep(100); synchronized (LOCK_A) { System.out.println("t2 get LOCK_A"); } } }); t1.start(); t2.start(); } private static void sleep(long millis) { try { Thread.sleep(millis); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }t1 先拿住 LOCK_A 想等 LOCK_B,t2 先拿住 LOCK_B 想等 LOCK_A,两边都不放手,程序就永久卡住了。
6.2 jstack 快照能直接告诉你“死锁发生在哪一行”
遇到疑似死锁,不要瞎猜,直接用 JDK 自带的jstack抓线程快照:
jps -l jstack <pid> > dump.txt打开 dump 文件,最前面就能看到这样的关键信息:
Found one Java-level deadlock: ============================= "Thread-1": waiting to lock monitor 0x000000001a5e6b00 (object 0x000000076b4c5870, a java.lang.Object), which is held by "Thread-0" "Thread-0": waiting to lock monitor 0x000000001a5e6800 (object 0x000000076b4c5880, a java.lang.Object), which is held by "Thread-1" Java stack information for the threads listed above: =================================================== "Thread-1": at DeadlockDemo.lambda$main$1(DeadlockDemo.java:18) - waiting to lock <0x000000076b4c5880> (a java.lang.Object) at DeadlockDemo.lambda$main$1(DeadlockDemo.java:20) - locked <0x000000076b4c5870> (a java.lang.Object)这里每一条都写得非常清楚:哪个线程持有了哪个锁对象,正在等哪个锁对象,在源码第几行。把这两行对应回代码,就能立刻定位到是锁顺序交叉导致的死锁。
6.3 死锁的四个必要条件与锁顺序约定
死锁发生的必要条件有四个:互斥、持有并等待、不可剥夺、循环等待。前面三条在 synchronized 中基本无法避免,因为 synchronized 本身设计就是互斥且不可抢占的,唯一能动手脚的是“循环等待”这个条件。
只要所有线程都按照同一个全局顺序去获取多把锁,循环等待就不可能形成。比如把上面例子改成“无论是 t1 还是 t2,都先锁 LOCK_A 再锁 LOCK_B”,死锁就不会发生。在真实项目中,如果业务需要同时锁多个资源,就按资源 ID 从小到大排序后加锁,这是一种很实用的防死锁策略。
6.4 一种常见的隐蔽活锁:锁顺序不一致引起的偶发卡顿
比纯死锁更难排查的是“活锁”或“锁顺序不一致导致的高延迟”。我在一个多数据源同步项目里遇到过:两个线程各自持有数据分片 A 和 B,然后互相尝试获取对方的分片锁,但它们的执行节奏不固定,死锁状态并不是一直存在,而是偶尔卡住几秒后其中一方超时释放,看起来像系统抖动。
这种问题用 jstack 抓一次往往抓不到,需要连续抓多份线程快照,对比同一对锁对象的持有关系是否在反复交叉,才能逐步定位。排查经验是:线上怀疑锁问题,第一件事就是连续抓 3 到 5 次线程 dump,间隔 3 到 5 秒,不要只抓一次。
7. 面试连环追问之后,我的选型体会
学完 synchronized 的三种写法和背后的锁升级机制,很多人的下一个问题是:那到底该用 synchronized 还是 ReentrantLock?
我的经验是分场景看。
如果是 JDK 8 及以上版本的日常业务开发,临界区很短、竞争不激烈,synchronized 优先。它代码简洁、不会因为忘记释放锁而出问题、JVM 的锁升级让它已经不输给 ReentrantLock,而且未来还会有持续的偏向优化。
如果需要可中断的锁等待、带超时的抢锁、或者多个 Condition 队列做精细化的线程协作,那就上 ReentrantLock。这些是 synchronized 在语法层面给不了的能力。
还有一个场景我遇到过不少次:多个服务实例同时操作同一个数据库或 Redis 中的数据。这种时候 synchronized 没有任何用处,因为锁是 JVM 内存层面的,跨不了进程。
7.1 分布式锁不是 synchronized 的替代品
有些面试者会把分布式锁和 synchronized 放在一起背题,但这两者解决的问题边界完全不同。
synchronized 解决的是“同一个 JVM 内多个线程竞争同一个对象”的互斥;分布式锁解决的是“多个进程甚至多台机器上的多个线程操作同一份共享资源”的互斥。选型时先搞清楚竞争范围,如果只是单机多线程,用 synchronized 完全够;如果是多实例部署,单靠 synchronized 再有本事也锁不住其他机器上的线程。
常见的分布式锁实现方式有基于 Redis 的 SETNX + 过期时间方案、基于 ZooKeeper 的临时顺序节点方案、基于数据库唯一索引的方案。它们各有各的坑:Redis 方案要考虑锁过期和主从切换,ZooKeeper 方案要处理会话断开后的锁释放,数据库方案要处理连接池耗尽和死锁。这些我以后再单独写,但核心认知要立住:分布式锁和 synchronized 是兄弟,不是父子。
7.2 我用几个问题自测是否真正懂了 synchronized
分享几个我平时审核代码和面试时常问自己的问题,如果都能答上来,说明对三种锁方式的理解才算过关:
- 实例同步方法、静态同步方法、代码块三者之间,哪些能互相阻塞,哪些不能?
- 两级同步代码块嵌套锁同一个对象,会死锁吗?不会,因为锁是可重入的。
- 同步代码块里抛异常,锁会释放吗?会,字节码里编译出了异常路径的 monitorexit。
- synchronized 能锁住 Integer、Long 这样的包装类吗?能,但要小心包装类的缓存和自动装箱导致两个线程用了不同对象。
- 偏向锁在 JDK 15 之后为什么被默认禁用?因为偏向锁在少量竞争场景下的撤销成本可能高于收益。
最后一个问题尤其能看出一个人是不是只背了八股。把这些串起来,你就能知道 synchronized 并不是简单的一个关键字,它背后是一整套围绕对象头、Monitor、CAS、线程阻塞与唤醒的复杂协作机制。