一句话总结
synchronized 在 JDK 6 之后引入了锁升级机制:无锁 → 偏向锁(记录线程 ID,同一线程重入无需 CAS)→ 轻量级锁(CAS 自旋,适用于竞争不激烈场景)→ 重量级锁(操作系统 mutex,线程阻塞唤醒)。锁的存储依赖对象头中的 Mark Word,重量级锁关联ObjectMonitor(_owner、_EntryList、_WaitSet)。JDK 6+ 还做了锁消除、锁粗化、适应性自旋等优化。
初级理解
synchronized 的三种使用方式
| 使用方式 | 锁对象 | 示例 |
|---|---|---|
| 修饰实例方法 | 当前实例对象 this | public synchronized void method() |
| 修饰静态方法 | 当前类的 Class 对象 | public static synchronized void method() |
| 修饰代码块 | 括号中指定的对象 | synchronized(obj) { ... } |
对象头 — 锁存在哪里?
每个 Java 对象在堆内存中由三部分组成:
| 组成部分 | 说明 |
|---|---|
| 对象头(Object Header) | 包含 Mark Word 和 Klass Pointer,锁信息存在 Mark Word 中 |
| 实例数据(Instance Data) | 对象的成员变量 |
| 对齐填充(Padding) | 保证对象大小是 8 字节的倍数 |
Mark Word 是对象头中的核心字段,32 位 JVM 占 32 bit,64 位 JVM 占 64 bit。它存储了对象的 hashCode、GC 分代年龄、锁状态标志等信息。锁的状态就记录在 Mark Word 的最后几位。
中级深入
锁升级全过程
JDK 6 之后,synchronized 不再是直接使用重量级锁,而是有一个逐步升级的过程:
| 锁状态 | 存储内容 | 获取方式 | 适用场景 |
|---|---|---|---|
| 无锁 | hashCode、分代年龄 | — | 初始状态 |
| 偏向锁 | 线程 ID、Epoch | CAS 设置线程 ID | 只有一个线程访问 |
| 轻量级锁 | 指向栈中锁记录的指针 | CAS 自旋 | 多线程交替访问 |
| 重量级锁 | 指向 monitor 的指针 | 操作系统 mutex | 多线程竞争激烈 |
偏向锁 — 为什么要有偏向锁?
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获取。偏向锁就是为了在这个场景下减少不必要的 CAS 操作。
工作流程:
1. 线程第一次获取锁时,通过 CAS 将自己的线程 ID 写入 Mark Word
2. 之后该线程再次进入同步块时,只需检查 Mark Word 中的线程 ID 是否是自己,不需要任何 CAS 操作
3. 如果有其他线程竞争,偏向锁会撤销(在安全点暂停原线程,检查是否还在同步块中),升级为轻量级锁
轻量级锁 — CAS 自旋
当偏向锁被撤销或有第二个线程尝试获取锁时,升级为轻量级锁。
工作流程:
1. 线程在自己的栈帧中创建锁记录(Lock Record),存储 Mark Word 的副本
2. 通过 CAS 尝试将对象头的 Mark Word 替换为指向锁记录的指针
3. CAS 成功 → 获取轻量级锁成功
4. CAS 失败 → 检查 Mark Word 是否已经指向自己的锁记录(重入),是则成功;否则自旋等待
5. 自旋一定次数后仍未获取到锁 → 膨胀为重量级锁
重量级锁 — ObjectMonitor
当自旋失败或竞争激烈时,锁膨胀为重量级锁。重量级锁依赖操作系统的 mutex,线程会阻塞和唤醒(涉及用户态到内核态的切换,开销大)。
每个 Java 对象都可以关联一个 ObjectMonitor(C++ 实现),核心结构:
| 字段 | 作用 |
|---|---|
| _owner | 指向持有锁的线程,null 表示锁空闲 |
| _EntryList | 竞争锁失败的线程进入此队列,处于 BLOCKED 状态 |
| _WaitSet | 调用 wait() 的线程进入此队列,处于 WAITING 状态,等待 notify() |
| _recursions | 记录重入次数,同一线程每次进入 +1,退出 -1,为 0 时释放锁 |
高级拓展
锁消除(Lock Elimination)
JIT 编译器通过逃逸分析,发现某些同步代码不可能被多线程访问时,会直接去掉锁。
锁粗化(Lock Coarsening)
如果一系列连续操作反复对同一个对象加锁解锁,JIT 会将锁的范围扩大(粗化),减少加锁解锁的次数。
适应性自旋(Adaptive Spinning)
JDK 6 引入了适应性自旋:自旋时间不再固定,而是由前一次在同一个锁上的自旋时间和锁的拥有者状态来决定。
- 如果上次自旋成功获取了锁 → 这次自旋时间更长
- 如果上次自旋失败 → 这次自旋时间更短,甚至不自旋直接阻塞
synchronized 和 ReentrantLock 对比
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 实现层面 | JVM 层面,C++ 实现 | JDK 层面,Java 实现(AQS) |
| 锁释放 | 自动释放(代码块结束或异常) | 必须手动 unlock(),通常在 finally 中 |
| 可中断 | 不可中断,一直等待 | 可中断(lockInterruptibly) |
| 公平锁 | 非公平 | 支持公平和非公平(构造参数) |
| 条件变量 | 单一 wait/notify | 多个 Condition,精确唤醒 |
| 尝试获取 | 不支持 | tryLock() 尝试获取,可设超时 |
| 性能(JDK 6+) | 经过大量优化,接近 ReentrantLock | 略优,但差距很小 |
为什么说 synchronized 是"非公平锁"?
synchronized 的 _EntryList 中的线程被唤醒后,需要和新来的线程一起竞争锁,不会按等待顺序分配。新来的线程可能"插队"成功,这就是非公平的体现。
实战场景
场景一:单例模式 — DCL 为什么用 volatile?
场景二:用 synchronized 实现线程安全的计数器
常见坑
| 坑 | 原因 | 解决方案 |
|---|---|---|
| 锁了不同的对象 | 实例方法锁 this,静态方法锁 Class,两个锁互不影响 | 确保多线程操作的是同一个锁对象 |
| 锁了可变对象 | 锁对象被修改后,其他线程锁的是新对象 | 锁 final 对象或不可变对象 |
| 锁 String 常量 | 字符串常量池共享,可能和其他模块冲突 | 用 new Object() 作专用锁 |
| getter 没加锁 | 只给 setter 加锁,getter 可能读到中间状态 | 读写都要加锁,或用 volatile |
面试模拟
Q:synchronized 的锁升级过程是怎样的?
A:JDK 6 之后引入了锁升级机制,锁状态存在对象头的 Mark Word 中。升级路径是单向的:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。偏向锁在 Mark Word 中记录线程 ID,同一线程重入无需 CAS;轻量级锁通过 CAS 自旋获取,适用于线程交替执行的场景;自旋失败次数达到阈值后膨胀为重量级锁,依赖操作系统的 mutex,线程会阻塞。此外还有锁消除、锁粗化、适应性自旋等 JIT 优化。
Q:偏向锁什么时候会被撤销?
A:偏向锁在两种情况下会被撤销:1)有其他线程竞争锁时,会在安全点暂停偏向线程,检查它是否还在同步块中,如果已退出则撤销偏向锁并升级为轻量级锁,如果还在执行则升级为轻量级锁让竞争线程自旋;2)调用对象的 hashCode() 时,因为偏向锁的 Mark Word 没有空间存储 hashCode(偏向线程 ID 占用了 hashCode 的位置),会撤销偏向锁并计算 hashCode 存入 Mark Word。
Q:synchronized 和 ReentrantLock 怎么选?
A:JDK 6 之后 synchronized 性能已经和 ReentrantLock 非常接近。选择原则:1)优先用 synchronized,代码简洁,自动释放锁,不容易出错;2)需要可中断获取锁、超时获取锁、公平锁、多个条件变量时用 ReentrantLock;3)synchronized 是 JVM 内置的,一些监控工具(如 jstack)可以直接看到锁信息,排查问题更方便。
Q:wait() 为什么要放在 while 循环里而不是 if?
A:因为虚假唤醒(spurious wakeup):线程可能在没被 notify() 的情况下被唤醒。如果用 if 判断条件,被虚假唤醒后会直接往下执行,可能操作一个不满足条件的状态。用 while 循环可以在被唤醒后再次检查条件,条件不满足则继续 wait()。
Q:synchronized 修饰静态方法和实例方法的区别?
A:实例方法锁的是 this 对象,同一个实例的多个 synchronized 实例方法互斥;静态方法锁的是类的 Class 对象,同一个类的所有 synchronized 静态方法互斥。但实例方法和静态方法之间不互斥,因为它们锁的是不同的对象(this vs Class)。