《Java 并发编程·线程与内存模型》4.2 synchronized 的字节码与对象头

用 javap 拆开 synchronized:同步方法靠 ACC_SYNCHRONIZED 标志,同步块生成 monitorenter/monitorexit 与异常表;再讲对象头 Mark Word 的锁状态、可重入计数,以及锁错对象、锁字符串常量这几类真实错误。

一行关键字背后发生了什么

public synchronized void inc() 只比普通方法多了一个关键字,但它同时改变了两件事:

  1. 方法被调用时,线程必须先取得对象监视器,返回时释放;
  2. 监视器的进入和退出建立 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 Word64 位哈希码、GC 分代年龄、锁状态、偏向线程 ID 等
Klass Pointer32 位(开启压缩指针)指向类元数据

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 锁消除、锁粗化与偏向锁的移除

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收