JVM 对 Synchronized 关键字的实现是怎样的?


一则或许对你有用的小广告

欢迎加入小哈的星球,你将获得:专属的实战项目(4个项目都能学) / 1v1 提问 / 简历修改 / Java 学习路线 / 社群讨论 / 学习打卡 / 每月赠书

  • 《Spring AI 项目实战(问答机器人、RAG 智能客服、联网搜索)》已完结,基于 Spring AI + Spring Boot 3.x + JDK 21...,查看介绍

  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...,查看介绍;演示链接:http://116.62.199.48:7070/

  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接:http://116.62.199.48/

  • 新开坑项目:《从零手撸:秒杀系统高并发优化实战》 正在更新中...,查看介绍

截止目前,星球内专栏累计输出 150w+ 字,讲解图 5110+ 张,还在持续爆肝中.. 后续还会上新更多项目,已有 4700+ 小伙伴加入学习,欢迎点击围观

面试考察点

  1. 字节码层面理解:面试官想知道你是否清楚 synchronized 在字节码层面是怎么体现的——monitorenter / monitorexit 指令、ACC_SYNCHRONIZED 标志位,这些是最基本的。

  2. 底层机制深度:synchronized 的加锁解锁本质上依赖 Monitor(管程),而 Monitor 又和 Java 对象头的 Mark Word 紧密关联。这条线能不能串起来,是区分 "背答案" 和 "真懂" 的关键。

  3. 锁升级机制:JDK 6 引入了偏向锁 → 轻量级锁 → 重量级锁的升级过程。这是面试的高频追问点,也是很多人答不清楚的地方。

核心答案

JVM 对 synchronized 的实现,从上到下可以分三层来看:

层面 实现方式
字节码层 同步代码块用 monitorenter / monitorexit 指令;同步方法用 ACC_SYNCHRONIZED 标志位
对象头层 利用对象头 Mark Word 存储锁状态、线程 ID、锁记录指针等信息
Monitor 层 最终依赖 ObjectMonitor(C++ 实现)完成真正的加锁解锁

核心一句话:synchronized 基于 Monitor(管程) 实现,而每个 Java 对象天生就关联着一个 Monitor。

深度解析

一、字节码层面的实现

synchronized 有两种用法,对应的字节码指令不一样:

1. 同步代码块

public void method() {
    synchronized (this) {
        // 临界区
    }
}

编译后的字节码:

monitorenter      // 进入 Monitor,获取锁
// 临界区代码
monitorexit       // 退出 Monitor,释放锁
monitorexit       // 异常路径的退出(编译器自动生成)
  • monitorenter:尝试获取对象的 Monitor。如果 Monitor 的计数器为 0,说明没人加锁,当前线程获取成功,计数器 +1。如果当前线程已经持有该 Monitor(可重入),计数器再 +1。如果被其他线程持有,当前线程阻塞等待。
  • monitorexit:将计数器 -1,减到 0 时释放 Monitor。注意编译器会自动生成一个异常路径的 monitorexit,确保即使同步块抛异常也能释放锁。

2. 同步方法

public synchronized void method() {
    // 临界区
}

同步方法不需要 monitorenter / monitorexit 指令,而是在方法表(method_info)的访问标志(access_flags)中设置 ACC_SYNCHRONIZED(0x0020)。JVM 调用方法时检查到这个标志位,就会自动做加锁解锁。

二、Java 对象头与 Mark Word

每个 Java 对象在 JVM 中都有一段 "对象头"(Object Header),这是 synchronized 能够实现锁机制的基础。

上图展示了 Java 对象在内存中的三部分布局。其中对象头里的 Mark Word 是锁机制的核心,它在不同锁状态下存储的内容不同(以 64 位 JVM 为例):

锁状态 Mark Word 存储内容 标志位
无锁 对象哈希码、GC 分代年龄 001
偏向锁 线程 ID、Epoch、GC 分代年龄 101
轻量级锁 指向栈中锁记录的指针 00
重量级锁 指向 Monitor(ObjectMonitor)的指针 10
GC 标记 空 11

Mark Word 最后 2-3 位是锁标志位,JVM 通过读取这几位就知道当前对象处于什么锁状态。

三、Monitor:真正的锁实现

Monitor 可以理解为一种 同步工具,每个 Java 对象天生就关联着一个 Monitor。在 HotSpot 中,Monitor 由 C++ 类 ObjectMonitor 实现,核心字段:

  • _owner:指向当前获得锁的线程。线程拿到锁,就把自己的引用写到这里。
  • _count:记录锁被重入的次数。每次 monitorenter +1,monitorexit -1,减到 0 释放锁。
  • _EntryList:当锁被占用时,其他尝试获取锁的线程会进入这个队列阻塞等待。锁释放时,JVM 从这个队列中唤醒一个线程。
  • _WaitSet:当线程调用 Object.wait() 时,会释放锁并进入这个集合等待。被 notify() / notifyAll() 唤醒后,线程从 _WaitSet 移到 _EntryList,重新竞争锁。

线程加锁解锁的交互过程:

  1. 线程进入 _EntryList 排队
  2. 获取到锁,_owner 设为当前线程,_count +1
  3. 如果调用 wait(),线程进入 _WaitSet 等待,释放锁
  4. 被唤醒后重新回到 _EntryList 竞争锁
  5. 同步代码执行完毕,_count -1,减到 0 释放锁,_owner 置空

四、锁升级(JDK 6+)

JDK 6 之前,synchronized 只有一种锁——重量级锁,每次加锁都要走 Monitor,涉及用户态到内核态的切换,性能很拉。JDK 6 引入了 锁升级机制,根据竞争程度自动选择最优的锁策略:

锁升级是单向的(通常不能降级),随着竞争越来越激烈,锁会从轻量逐步升级到重量级。

第一级:偏向锁

大多数情况下,锁不仅不存在多线程竞争,而且总是由 同一个线程 反复获取。偏向锁的设计思路就是:既然总是你来,那我连 CAS 都省了,直接把你的线程 ID 写到 Mark Word 里,下次你来,我看一眼 ID 是你,直接放行。

  • 获取锁时,检查 Mark Word 中的线程 ID 是否是当前线程
  • 如果是,直接进入同步块(几乎零开销)
  • 如果不是,通过 CAS 尝试将线程 ID 替换为当前线程
  • CAS 成功,获得偏向锁;失败,说明存在竞争,撤销偏向锁,升级为轻量级锁

第二级:轻量级锁

当有第二个线程来竞争锁时,偏向锁撤销,升级为轻量级锁。轻量级锁的核心是 CAS + 自旋:

  • 在当前线程栈帧中创建一个 "锁记录"(Lock Record)
  • CAS 尝试将对象头 Mark Word 替换为指向锁记录的指针
  • 成功,获得轻量级锁
  • 失败,说明有竞争,线程开始自旋(循环重试 CAS)
  • 自旋一定次数后还是拿不到,升级为重量级锁

轻量级锁适用于线程交替执行同步块、持有锁时间很短的场景。自旋的代价是消耗 CPU,所以如果锁持有时间长,自旋就是浪费。

第三级:重量级锁

自旋失败(或者 JVM 判断当前竞争过于激烈),锁升级为重量级锁。此时 Mark Word 中存储的就是指向 ObjectMonitor 的指针,加锁解锁都要走 Monitor,涉及线程阻塞和唤醒(操作系统层面的互斥量),开销最大。

锁升级总结对比:

锁类型 适用场景 优点 缺点
偏向锁 单线程反复获取 几乎零开销 有竞争时撤销成本高
轻量级锁 短时间轻度竞争 CAS 自旋,避免阻塞 自旋消耗 CPU
重量级锁 激烈竞争、长持有时间 不消耗 CPU 自旋 线程阻塞,上下文切换开销大

五、编译器层面的优化

除了锁升级,JIT 编译器还做了两个很重要的优化:

锁消除(Lock Elimination)

public void method() {
    // sb 是局部变量,不可能被其他线程访问
    // 但 StringBuffer 的方法都用了 synchronized
    StringBuffer sb = new StringBuffer();
    sb.append("a");
    sb.append("b");
}

JIT 通过 逃逸分析 发现 sb 不会逃逸出方法(其他线程拿不到),那加锁毫无意义,直接在编译阶段把 synchronized 去掉。这就是锁消除。

锁粗化(Lock Coarsening)

public void method() {
    // 连续对同一个对象加锁解锁,JIT 会把锁粗化到外面
    synchronized (lock) { doSomething1(); }
    synchronized (lock) { doSomething2(); }
    synchronized (lock) { doSomething3(); }

    // 优化后等价于:
    synchronized (lock) {
        doSomething1();
        doSomething2();
        doSomething3();
    }
}

连续对同一个对象反复加锁解锁,JIT 会把多次锁操作合并成一次,减少加锁解锁的开销。

面试高频追问

  1. synchronized 和 ReentrantLock 的区别?

    • synchronized 是 JVM 层面的关键字,ReentrantLock 是 API 层面的类
    • synchronized 不需要手动释放锁,ReentrantLock 必须在 finally 中 unlock()
    • ReentrantLock 支持公平锁、可中断锁、多条件变量(Condition)
    • synchronized 支持锁升级优化,ReentrantLock 依赖 AQS
    • 两者都是可重入锁
  2. synchronized 是可重入的吗?怎么实现的?

    是的。重量级锁通过 ObjectMonitor 的 _count 计数器实现,每次 monitorenter 计数器 +1,monitorexit 计数器 -1。偏向锁通过 Mark Word 中的线程 ID 判断,轻量级锁通过栈帧中的 Lock Record 数量计算重入次数。

  3. 偏向锁在 JDK 15 被废弃了?

    是的。JDK 15 起,偏向锁默认关闭(-XX:-UseBiasedLocking),JDK 18 正式移除。原因是现代应用中无竞争的单线程场景越来越少,偏向锁撤销的成本(需要等到安全点 Safepoint)反而成了负担。

常见面试变体

  • "synchronized 的底层实现原理是什么?"
  • "Java 对象头包含哪些信息?和锁有什么关系?"
  • "什么是锁升级?偏向锁、轻量级锁、重量级锁分别是什么?"
  • "synchronized 和 volatile 的区别?"

记忆口诀

锁升级:偏向(单线程贴 ID)→ 轻量(CAS 自旋)→ 重量(Monitor 阻塞),只升不降。

实现三层面:字节码有指令(monitorenter / monitorexit),对象头有 Mark Word,底层有 ObjectMonitor。

总结

JVM 对 synchronized 的实现分三层:字节码层面用 monitorenter / monitorexit 指令,对象头层面利用 Mark Word 存储锁状态,底层依赖 ObjectMonitor 完成加锁解锁。JDK 6 引入的锁升级机制(偏向锁 → 轻量级锁 → 重量级锁)加上锁消除、锁粗化等 JIT 优化,让 synchronized 的性能在大多数场景下已经不逊于 ReentrantLock。