synchronized 是怎么实现的?

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

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

  • 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于 Spring AI + Spring Boot3.x + JDK 21...点击查看;
  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot3.x + JDK 17...点击查看项目介绍; 演示链接: http://116.62.199.48:7070/;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/

面试考察点

面试官提出这个问题,通常旨在考察以下几个由浅入深的层面:

  1. 对基础同步机制的理解:你是否清楚 synchronized 是实现线程同步的基础关键字。
  2. 对 JVM 底层实现的掌握:面试官不仅仅想知道 “它能加锁”,更是想考察你是否了解它在 字节码层面JVM 运行时层面 是如何具体实现的,这反映了你的知识深度。
  3. 对锁优化历史的了解:是否了解从早期 “重量级锁” 到现代 JVM(尤其是 JDK 6 及以后)中一系列 锁升级优化(如偏向锁、轻量级锁)的过程及其意义,这关系到你对性能优化的理解。
  4. 对比与选型的能力:通过底层实现的讨论,间接考察你与 ReentrantLock 等显式锁的对比理解,以及在何种场景下如何选择。
  5. 实践中的注意事项:是否了解如何正确、高效地使用 synchronized,避免死锁、性能瓶颈等常见问题。

核心答案

synchronized 的实现分为 语法层面运行时层面

  1. 语法层面:它通过 monitorentermonitorexit 这一对字节码指令来实现同步代码块的进入和退出;对于同步方法,则通过方法常量池中的 ACC_SYNCHRONIZED 标志位来标识。
  2. 运行时层面(核心):JVM 将每个 Java 对象都关联一个 监视器锁(Monitor)。线程通过竞争对象头中的 Mark Word 来获取这个 Monitor。在 JDK 6 之后,为了减少性能开销,引入了 锁升级机制:锁状态会根据竞争情况,从 无锁 -> 偏向锁 -> 轻量级锁(自旋锁) -> 重量级锁 逐步升级。

深度解析

原理/机制

1. 对象头与 Monitor

  • 每个 Java 对象在内存中分为三部分:对象头、实例数据、对齐填充
  • 对象头 中的 Mark Word(标记字)是实现锁的关键。它存储了对象的哈希码、分代年龄和 锁状态标志
  • 当锁升级为重量级锁时,Mark Word 中会存储一个指向 操作系统层级互斥量(mutex)等待队列 的指针,这个结构就是 ObjectMonitor(即 Monitor 的具体实现)。

2. 锁升级过程(JDK 6+ 优化后) 这是理解现代 synchronized 性能的关键。

  • 偏向锁:假设锁总是由同一线程获得。当一个线程首次进入同步块时,会在对象头和栈帧锁记录中存储偏向的线程 ID。以后该线程再进入时,只需简单检查 ID,无需任何原子操作。适用于近乎无竞争的单线程场景
  • 轻量级锁:当有第二个线程尝试获取锁(发生轻微竞争),偏向锁会升级为轻量级锁。线程会在自己的栈帧中创建锁记录(Lock Record),并尝试通过 CAS 操作 将对象头的 Mark Word 复制到自己的锁记录中,并替换为指向锁记录的指针。如果成功,则获取锁;如果失败,说明存在竞争,会 自旋(循环尝试)一定次数。
  • 重量级锁:如果轻量级锁自旋失败(或自旋超过阈值),锁会膨胀为重量级锁。此时,未竞争到锁的线程会被 挂起(Park),进入阻塞状态,等待操作系统调度唤醒。这涉及到用户态到内核态的切换,开销最大

代码示例

从字节码视角看一个同步块:

// Java 源码
public void syncMethod() {
    synchronized(this) {
        System.out.println("hello");
    }
}

编译后的关键字节码指令如下(可通过 javap -c 查看):

public void syncMethod();
  Code:
     0: aload_0         // 将对象引用(this)压入操作数栈
     1: dup             // 复制栈顶值
     2: astore_1        // 存储一个副本到局部变量表(用于后续 monitorexit)
     5: monitorenter    // 尝试获取对象的 Monitor
     6: getstatic     #2 // 开始执行同步块内的代码
     9: ldc           #3
    11: invokevirtual #4
    14: aload_1
    15: monitorexit     // 正常退出,释放 Monitor
    16: goto          24
    19: astore_2        // 异常处理开始...
    20: aload_1
    21: monitorexit     // 异常退出,也必须释放 Monitor
    22: aload_2
    23: athrow
    24: return

可以看到,编译器会自动生成配对且确保一定执行的 monitorentermonitorexit 指令,这正是 synchronized 能在发生异常时也释放锁的原因。

对比分析:synchronized vs ReentrantLock

特性synchronized (隐式锁)ReentrantLock (显式锁)
实现层面JVM 层面实现,由 C++ 代码控制JDK 层面实现,Java 代码(基于 AQS)
锁的获取自动加锁与释放锁必须手动 lock()unlock(),通常配合 try-finally
灵活性有限。不可中断、非公平(可设公平但不灵活)很高。可尝试非阻塞获取(tryLock)、可中断、可设置公平/非公平
条件队列单一等待队列 (wait/notify)可绑定多个 Condition 对象,实现精细的线程等待/唤醒
性能JDK 6 后优化出色,在一般竞争下与 ReentrantLock 持平在高竞争场景下,其可配置性可能带来优势

最佳实践与注意事项

  • 锁对象选择:应选择 不可变且所有竞争线程都可见 的对象作为锁。通常使用私有静态 final 对象或 this(但需谨慎)。
  • 减小锁粒度:尽量只锁必要的代码段(同步块优于同步方法),缩短锁的持有时间。
  • 避免死锁:按固定顺序获取多个锁。
  • 现代 Java 开发:在竞争不激烈的常规业务代码中,优先使用简洁的 synchronized。只有在需要其不具备的高级特性(如可中断、超时、公平锁、多个条件变量)时,才使用 ReentrantLock

常见误区

  • 误区一synchronized 性能一定很差。纠正:在 JDK 6 引入锁升级后,它在无竞争或低竞争场景下开销极小,性能很好。
  • 误区二:锁升级是双向的。纠正:锁升级(膨胀)是 单向 的,一旦升级为重量级锁,就不会再降级回轻量级锁(在某些 JVM 实现中,重量级锁释放后可回到无锁状态,但不会回到偏向/轻量级状态)。
  • 误区三synchronized 锁的是代码。纠正:它锁的是 对象,确切地说是对象的 Monitor。

总结

synchronized 是通过 JVM 层面的 monitorenter/monitorexit 指令和每个对象关联的 Monitor 实现的,并在现代 JVM 中通过 偏向锁 -> 轻量级锁 -> 重量级锁 的升级策略来平衡开销与性能,使其成为多数并发场景下可靠且高效的选择。