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. synchronizedReentrantLock 的区别?

    • synchronized 是 JVM 层面的关键字,ReentrantLock 是 API 层面的类
    • synchronized 不需要手动释放锁,ReentrantLock 必须在 finallyunlock()
    • 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 对象头包含哪些信息?和锁有什么关系?"
  • "什么是锁升级?偏向锁、轻量级锁、重量级锁分别是什么?"
  • "synchronizedvolatile 的区别?"

记忆口诀

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

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

总结

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