主题
synchronized 深入:用法、原理与锁升级
本文是 Java 并发系统学习系列的 L2 核心篇。前置:线程基础与生命周期、JMM 与并发三性。 学完可以配合面试题食用:Synchronized 底层实现:偏向锁 -> 轻量锁 -> 重量锁
为什么需要 synchronized
多个线程同时读写同一个共享变量时,如果不加同步,结果取决于线程调度顺序——这就是竞态条件(race condition)。比如经典的 counter++,它实际上是"读-改-写"三步操作,两个线程交错执行时会丢失更新。Java 提供的最原生解法就是 synchronized 关键字:同一时刻只允许一个线程进入被它修饰的区域,其余线程阻塞等待,从而保证互斥与可见性。
一个最小例子:
java
class Counter {
private int count = 0;
public synchronized void increment() { count++; } // 锁的是 this
public int get() { return count; }
}把 synchronized 去掉跑一万次自增、两个线程并发,多半得到小于 20000 的结果。加回来,结果稳定 20000。这就是它存在的意义。
三种用法与"锁的到底是谁"
synchronized 有三种写法,核心问题只有一个:锁对象是谁。
1. 实例方法:锁的是当前实例 this。
java
public synchronized void methodA() { ... } // 等价于 synchronized(this) { ... }2. 静态方法:锁的是类的 Class 对象(Xxx.class),全局唯一。
java
public static synchronized void methodB() { ... } // 等价于 synchronized(Xxx.class) { ... }3. 代码块:锁的是括号里指定的任意对象,粒度最细。
java
synchronized (lockObject) { ... }三个隐含推论:
- 实例方法和静态方法锁的是不同对象,可以同时被两个线程进入,不存在互斥
- 不同实例的实例方法之间也不互斥——每个实例一把锁
- 锁代码块时选错了锁对象,同步直接失效
经典错误:锁 Integer 缓存。 想给账户加锁时随手写了:
java
class Account {
private Integer balance = 100; // Integer.valueOf 对 -128~127 有缓存
public void withdraw(int amt) {
synchronized (balance) { // 危险!
if (balance >= amt) balance -= amt;
}
}
}balance -= amt 会自动装箱,调用 Integer.valueOf;当值在 -128~127 之间时返回缓存对象——同一个对象。这意味着两个不同的 Account 实例,只要余额恰好都是 100,锁的就是同一个 Integer 对象,本不该互斥的操作互相阻塞,甚至可能死锁(线程 A 拿着账户 1 的锁等账户 2 的锁,线程 B 反过来)。同理,String 字面量有常量池缓存,Boolean.valueOf 只有俩实例,都不能当锁。锁对象准则:private final Object lock = new Object(),专用、不可变指向、外部拿不到。
monitor 与对象头:锁信息存在哪
对象本身没有"锁"这个字段,锁信息存在对象头(Object Header)的 Mark Word 里。64 位 JVM 上对象头 12 字节(Mark Word 8 字节 + Klass Pointer 压缩后 4 字节),Mark Word 里除了锁标志位,还复用存了 hashCode、GC 分代年龄:
text
|----------------------------------------------|------------------|
| Mark Word (64 bit) | 锁状态 |
|----------------------------------------------|------------------|
| unused:25 | hash:31 | unused:1 | age:4 | bias:1 | lock:2 = 01 | 无锁可偏向 |
| thread:54 | epoch:2 | unused:1 | age:4 | bias:1 | lock:2 = 01 | 偏向锁 |
| ptr:62(栈中锁记录地址) | lock:2 = 00 | 轻量级锁 |
| ptr:62(ObjectMonitor 地址) | lock:2 = 10 | 重量级锁 |
|----------------------------------------------|------------------|注意这个设计的取舍:Mark Word 只有 64 位,要塞下 hashCode、分代年龄、锁指针,只能按锁状态复用同一块空间——比如对象一旦计算过 identity hashCode,就没法进偏向状态(31 位 hash 没地方放),只能直接升级轻量级锁。
至于 monitorenter / monitorexit 字节码指令与 HotSpot 的 ObjectMonitor:编译器把同步方法/代码块翻译成对 monitor 的进入退出,monitor 内部维护 entry list(竞争失败线程阻塞队列)与 wait set(wait 挂起队列),这属于重量级锁的实现细节,下一节的升级路径会讲到它何时才真正登场。
锁升级:无锁 -> 偏向 -> 轻量级 -> 重量级
JDK 6 之前 synchronized 直接走重量级锁(操作系统 mutex + 线程阻塞挂起),代价极高。JDK 6 引入锁升级优化,核心假设:大多数锁不但没有竞争,甚至整个生命周期只有一个线程在用。
mermaid
stateDiagram-v2
[*] --> 可偏向: 对象创建(biasable,尚未偏向)
可偏向 --> 偏向锁: 首次获取,CAS 写入线程 ID
偏向锁 --> 轻量级锁: 其他线程竞争,撤销偏向
可偏向 --> 轻量级锁: 已有竞争直接 CAS 栈帧锁记录
轻量级锁 --> 重量级锁: 自旋失败 / wait
重量级锁 --> [*]偏向锁(Biased Locking):第一个线程拿到锁后,把自己的线程 ID CAS 进 Mark Word,之后该线程再进入同步块只需比对线程 ID,连 CAS 都省了。适合"锁总被同一线程持有"的场景(如 StringBuffer 单线程使用)。当第二个线程到来,需要等到安全点撤销偏向(revocation)——如果同一类的撤销次数过多,还会批量重偏向或批量撤销整个类的偏向。撤销本身有成本。
轻量级锁(Thin/Stack Lock):竞争出现后,线程在自己栈帧里建 Lock Record,把 Mark Word 换成指向它的指针(CAS)。其他线程 CAS 失败则自旋等持有者释放。适合线程交替执行、临界区极短的场景——自旋几微秒就能等到,比挂起/唤醒便宜得多。
重量级锁(Inflated):自旋超过阈值(自适应自旋)或发生 wait,锁膨胀为 ObjectMonitor,竞争线程直接 park 挂起进内核,避免 CPU 空转。适合临界区长、竞争激烈的场景——挂起虽贵,但总比无限自旋烧 CPU 强。
各阶段代价:偏向锁 < 轻量级锁(少量 CAS + 自旋) < 重量级锁(内核态阻塞唤醒)。
偏向锁为何在 JDK 15 被废弃(JEP 374):撤销偏向需要 safepoint,带来不可忽略的 STW 开销;能享受到偏向锁收益的场景(真·单线程)本可以不用锁或用其他手段;而且它让同步代码路径和 hashCode 逻辑变得复杂。维护成本大于收益,JDK 15 起默认禁用并标记废弃,轻量级锁成为默认起点。
synchronized vs ReentrantLock 怎么选
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 加锁/释放 | 关键字,异常自动释放 monitor | lock()/unlock(),必须 finally 手动释放 |
| 可中断 | 阻塞不可中断(不响应 interrupt) | lockInterruptibly() 可中断 |
| 超时获取 | 不支持 | tryLock(timeout) 支持 |
| 公平锁 | 仅非公平 | 构造参数可选公平 |
| 条件变量 | 一个(wait/notify) | 多个 Condition 分队唤醒 |
| 性能 | JDK 6 后充足,多数场景无差距 | 同级 |
选型建议:默认 synchronized(语法简单、不会忘记释放、JVM 持续优化);需要 tryLock 超时、可中断、公平队列、多个条件队列时用 ReentrantLock。别为了"性能"预选 ReentrantLock——现代 JVM 上这个理由基本不成立。
动手实操:用 JOL 亲眼看锁状态变化
JOL(Java Object Layout)能直接打印对象内存布局,观察 Mark Word 的锁标志位变化。
Maven 依赖:
xml
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>观察代码(注意 JDK 15+ 需显式加 -XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0 才能看到偏向,默认已禁用):
java
import org.openjdk.jol.info.ClassLayout;
import java.util.concurrent.CountDownLatch;
public class LockUpgradeDemo {
static Object lock = new Object();
public static void main(String[] args) throws Exception {
// 1. 无锁(注意:打印过 hashCode 的对象无法偏向,这里不调 hashCode)
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
// 2. 单线程持锁 -> 偏向锁(低三位 101;JDK 15+ 需开启偏向锁参数)
synchronized (lock) {
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}
// 3. 两个线程交替竞争 -> 轻量级锁(低两位 00)
CountDownLatch latch = new CountDownLatch(2);
for (int i = 0; i < 2; i++) {
new Thread(() -> {
synchronized (lock) { System.out.println("t hold"); }
latch.countDown();
}).start();
}
latch.await();
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
// 4. 激烈竞争 -> 重量级锁(低两位 10)
for (int i = 0; i < 3; i++) {
new Thread(() -> {
for (int j = 0; j < 1_000_000; j++) {
synchronized (lock) { }
}
}).start();
}
Thread.sleep(2000);
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}
}关注输出里的 Mark Word 末尾十六进制位:(biased bit)/lock bits 从 0x01(无锁/可偏向)-> 0x05(偏向)-> 0x00(轻量级,栈指针)-> 0x02(重量级,monitor 指针)。锁一旦膨胀到重量级,通常不会自动降级——这是 HotSpot 的实现取舍,所以"短临界区尽量别长时间高频竞争"有实际意义。
常见误区与小结
- 锁 Integer/String/Boolean 当锁对象:缓存与常量池导致不同逻辑对象共享同一把锁,轻则莫名串行、重则死锁。锁对象永远
new Object()私有持有 - 以为静态同步方法和实例同步方法互斥:一个锁 Class 对象一个锁实例,两条路可以同时进
- 以为轻量级锁一定比重量级快:轻量级靠自旋,竞争激烈时自旋白白烧 CPU,反而不如直接挂起
- 调了 identity hashCode 之后纳闷进不了偏向锁:Mark Word 复用空间,hash 一占偏向就放不下,属正常行为
- 把 synchronized 当性能原罪:锁升级优化后多数场景开销很低,先测量再优化
小结:本篇从用法(三种姿势、锁对象是谁)讲到原理(Mark Word 复用设计)再到锁升级状态机,把 synchronized 从"会用"补到"懂实现"。它是并发系列的地基之一,后面讲 AQS、ReentrantLock 源码时,理解这把"内置锁"的边界才能明白 JUC 为什么另起炉灶。下一篇:volatile 可见性、有序性与使用边界(下篇更新后可从系列目录进入)。
参考
参考:HotSpot 源码
src/hotspot/share/runtime/objectMonitor.cpp、synchronizer.cpp;JEP 374: Deprecate and Disable Biased Locking;OpenJDK JOL(github.com/openjdk/jol)