一行关键字背后发生了什么
public synchronized void inc() 只比普通方法多了一个关键字,但它同时改变了两件事:
- 方法被调用时,线程必须先取得对象监视器,返回时释放;
- 监视器的进入和退出建立 happens-before 关系,保证临界区内的写对下一个进入的线程可见。
第 3 章讲了第 2 点的规则,这一节讲第 1 点在字节码层面的实现。理解实现的价值不在于背指令名,而在于能回答几个实际问题:
- 为什么同步方法编译后看不到
monitorenter? - 为什么同步块会生成两段
monitorexit? - 锁对象到底存在哪里?
- 锁「升级」升的是什么?
同步方法与同步块不是同一种实现
先准备一个最小类,两种写法都放进去:
public class SyncDemo {
private int count;
public synchronized void incMethod() {
count++;
}
public void incBlock() {
synchronized (this) {
count++;
}
}
public static synchronized void staticInc() {
}
}
编译后执行 javap -c -p SyncDemo,输出里有一个很值得注意的差异:
public synchronized void incMethod();
Code:
0: aload_0
1: dup
2: getfield #7 // Field count:I
5: iconst_1
6: iadd
7: putfield #7 // Field count:I
10: return
incMethod 的字节码里没有 monitorenter,也没有 monitorexit,只有普通的读、加、写。同步信息不在这段指令里,而在方法的访问标志上。用 javap -v 看:
public synchronized void incMethod();
descriptor: ()V
flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=1, args_size=1
ACC_SYNCHRONIZED 是一个方法级标志。JVM 在方法调用和返回时检查它,自动完成监视器的进入和退出。异常路径也由 JVM 处理,不需要编译器生成 try/finally。
静态同步方法多一个 ACC_STATIC:
public static synchronized void staticInc();
descriptor: ()V
flags: (0x0029) ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=0, args_size=0
ACC_STATIC 意味着锁对象不是 this,而是 SyncDemo.class 的 Class 对象。这一点在后面讲「锁错对象」时会再出现。
同步块则是另一种实现。incBlock 的字节码:
public void incBlock();
Code:
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: aload_0
5: dup
6: getfield #7 // Field count:I
9: iconst_1
10: iadd
11: putfield #7 // Field count:I
14: aload_1
15: monitorexit
16: goto 24
19: astore_2
20: aload_1
21: monitorexit
22: aload_2
23: athrow
24: return
Exception table:
from to target type
4 16 19 any
19 22 19 any
这段字节码有几个细节:
monitorenter在偏移 3,monitorexit在 15 和 21,出现了两次。- 正常路径走 14–16,释放锁后跳到 24 返回。
- 异常路径从 19 开始,先
astore_2保存异常,再monitorexit,最后athrow重新抛出。 - 异常表覆盖了 4–16 和 19–22 两段,保证无论正常结束还是异常,锁都会被释放。
也就是说,同步块的「异常也能解锁」不是 JVM 的魔法,而是编译器显式生成了 try/finally 的字节码。同步方法不需要,因为 JVM 在方法退出时统一处理。
这个差异有一个实际后果:同步方法里抛异常,锁由 JVM 释放;同步块里如果手动 return 或 break,编译器也会在每条出口补 monitorexit。反过来说,任何绕过编译器的跳转都会绕过解锁,这就是为什么 synchronized 块不能跨方法边界,也不能在块里调用 Thread.stop() 这类会强行终止线程的方法。
锁对象存在哪里
监视器的逻辑状态记录在对象头里。64 位 HotSpot 的对象头由两部分组成:
| 部分 | 宽度 | 内容 |
|---|---|---|
| Mark Word | 64 位 | 哈希码、GC 分代年龄、锁状态、偏向线程 ID 等 |
| Klass Pointer | 32 位(开启压缩指针) | 指向类元数据 |
Mark Word 是复用的:同一个 64 位区域,在不同状态下存不同的东西。
| 状态 | Mark Word 的关键位 | 含义 |
|---|---|---|
| 无锁 | 001 | 普通对象,存哈希与分代年龄 |
| 轻量级锁 | 00 | 指向线程栈上的锁记录(Lock Record) |
| 重量级锁 | 10 | 指向 ObjectMonitor |
| GC 标记 | 11 | 标记阶段使用 |
JDK 25 里没有偏向锁状态了。偏向锁在 JDK 15 被默认禁用,之后的版本移除;-XX:+UseBiasedLocking 现在会直接报错:
Unrecognized VM option 'UseBiasedLocking'
Error: Could not create the Java Virtual Machine.
这是实测结果。很多旧文章还在讲「偏向锁 → 轻量级锁 → 重量级锁」的三段式升级,在 JDK 25 上已经不成立。现在的路径是:无锁 → 轻量级锁 → 重量级锁。
轻量级锁的核心是「CAS 抢占 + 自旋」。线程进入同步块时,在自己栈上分配一个 Lock Record,用 CAS 把对象头的 Mark Word 替换成指向该记录的指针。成功就拿到锁;失败说明有竞争,膨胀为重量级锁,进入 ObjectMonitor 的等待队列。
重量级锁的 ObjectMonitor 里有几个关键字段:
_owner 持有锁的线程
_recursions 重入次数
_cxq 竞争队列(cxq)
_EntryList 入口队列
_WaitSet 调用 wait() 的线程集合
_recursions 解释了一个常见问题:为什么同一个线程可以重复进入同步块。每次重入加一,退出减一,减到零才真正释放。这个计数也解释了为什么「可重入」不等于「可以跨线程共享」——重入只对持有锁的线程有效。
可重入的边界
可重入的典型写法是递归调用:
public synchronized void outer() {
inner();
}
public synchronized void inner() {
// 同一线程再次进入,计数 +1
}
如果锁不可重入,outer 调用 inner 会自己把自己锁死。Java 的监视器是可重入的,所以这段代码没问题。
但可重入有一个容易被忽略的边界:重入不改变锁的作用域。下面这段代码看起来是「两个方法各自同步」,实际是同一把锁:
public class Account {
private long balance;
public synchronized void deposit(long amount) {
balance += amount;
}
public synchronized void withdraw(long amount) {
if (balance < amount) {
throw new IllegalStateException("余额不足");
}
balance -= amount;
}
public synchronized void transfer(Account target, long amount) {
this.withdraw(amount);
target.deposit(amount);
}
}
transfer 持有 this 的锁,调用 this.withdraw 时重入成功;调用 target.deposit 时需要 target 的锁。两个线程互相转账时,一个持有 A 等 B,另一个持有 B 等 A,就是死锁。
这段代码的正确性依赖调用方按固定顺序加锁,或者使用 tryLock 超时。第 9 章会展开。这里只说明:可重入解决的是「自己锁自己」,不解决「互相锁对方」。
锁对象的四个常见错误
synchronized 的语义依赖「所有竞争线程锁的是同一个对象」。一旦锁对象不一致,同步就失效。下面四类是实际代码里最常见的:
错误一:锁局部变量。
public void wrong() {
Object lock = new Object();
synchronized (lock) {
// 每个线程的 lock 都是新对象,等于没锁
}
}
错误二:锁字符串常量或包装类型。
private static final String LOCK = "lock";
private static final Integer ID = 1;
字符串常量在常量池里唯一,看起来能锁住,但任何第三方代码只要用同一个字面量,就可能锁到同一个对象,造成无关的阻塞。Integer 的 -128~127 有缓存,valueOf 返回同一对象;超出范围又是新对象。这两种「有时候是同一把锁」的行为最难排查。
错误三:锁可变引用。
private Object lock = new Object();
public void reset() {
lock = new Object(); // 换锁了,等旧锁的线程还在旧对象上
}
public void work() {
synchronized (lock) { }
}
work 每次读 lock 字段,reset 一改,两个线程可能锁在不同对象上。锁对象必须是 final 的。
错误四:锁 this 却暴露了对象。
public synchronized void update() { }
// 外部代码
synchronized (account) {
account.update(); // 外部持锁调用,可能死锁
}
锁 this 等于把锁的控制权交给所有能拿到这个对象的代码。库代码通常改用私有 final 锁对象,把锁封装在内部。
一条实用规则:锁对象用 private final 的专用对象,不用 this、不用字符串、不用包装类型、不用可重新赋值的字段。
在 libraryd-conc 里落地
把这条规则用到借阅服务上。SynchronizedLoanService 的第一版直接锁方法:
public final class SynchronizedLoanService implements LoanService {
private final BookRepository books;
private final LoanRepository loans;
public SynchronizedLoanService(BookRepository books, LoanRepository loans) {
this.books = books;
this.loans = loans;
}
@Override
public synchronized Reservation reserve(String isbn, String memberId) {
Book book = books.findByIsbn(isbn)
.orElseThrow(() -> new LoanException(ErrorCode.BOOK_NOT_FOUND, isbn));
if (book.stock() <= 0) {
throw new LoanException(ErrorCode.OUT_OF_STOCK, isbn);
}
books.save(new Book(book.isbn(), book.title(), book.stock() - 1));
Reservation reservation = new Reservation(
UUID.randomUUID().toString(), isbn, memberId, Instant.now().plusSeconds(86400));
loans.save(new Loan(reservation.loanId(), isbn, memberId, reservation.dueAt()));
return reservation;
}
@Override
public synchronized boolean returnBook(String loanId) {
return loans.findById(loanId).map(loan -> {
loans.save(loan);
return true;
}).orElse(false);
}
}
这版能防超卖,但把「查库存」和「写借阅」都放进临界区,所有 ISBN 的请求串行化。压测时 QPS 掉得厉害。
第二版把锁缩小到「同一本书」:
public final class SynchronizedLoanService implements LoanService {
private final BookRepository books;
private final LoanRepository loans;
private final ConcurrentMap<String, Object> bookLocks = new ConcurrentHashMap<>();
private Object lockFor(String isbn) {
return bookLocks.computeIfAbsent(isbn, k -> new Object());
}
@Override
public Reservation reserve(String isbn, String memberId) {
synchronized (lockFor(isbn)) {
// 查库存、判断、扣减、写借阅
}
}
}
锁对象来自 ConcurrentHashMap,同一个 ISBN 拿到同一个对象。不同 ISBN 互不阻塞。
但这版有两个新问题:bookLocks 只增不减,长期运行会积累大量锁对象;computeIfAbsent 本身虽然是原子的,但返回的对象在并发下仍可能被替换(如果有人 remove)。第 9 章会给出完整的分段锁实现,第 10 章会用线程池分片替代。
这里先记住结论:缩小锁粒度不是把 synchronized 从方法上挪到方法里就完事,锁对象的生命周期和一致性同样要设计。
小结
- 同步方法用
ACC_SYNCHRONIZED标志,字节码里没有monitorenter;同步块由编译器生成monitorenter/monitorexit和异常表。 - 同步块的两次
monitorexit分别覆盖正常路径和异常路径,保证锁一定释放。 - 锁状态存在对象头 Mark Word 里;JDK 25 只有无锁、轻量级锁、重量级锁,偏向锁已移除。
- 可重入靠
ObjectMonitor._recursions计数,只对持有锁的线程有效。 - 锁对象必须是所有竞争线程共享的同一个
final对象;锁局部变量、字符串常量、包装类型、可变字段都会让同步失效。
阅读导航:上一节 4.1 volatile 的语义与边界 | 下一节 4.3 锁消除、锁粗化与偏向锁的移除
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。