Java synchronized 锁升级:无锁到重量级

在 Java 并发编程中,synchronized 锁升级是最容易被误解、却又最影响性能的细节。synchronized 是 Java 最古老也最常用的内置锁,但很多人仍以为它“一用就重、性能差”。事实上,自 JDK 1.6 起,HotSpot 给 synchronized 设计了一套精巧的锁升级机制:对象锁并不是一上来就交给操作系统互斥量,而是沿着“无锁 → 偏向锁 → 轻量级锁 → 重量级锁”逐级膨胀。理解 synchronized 锁升级的四个状态与触发条件,能帮你在高并发场景下做出更优的并发设计,避免把本可以无开销的同步变成内核态阻塞。本文从对象头 Mark Word 入手,配合 JOL 实测代码,把这套机制讲透。

一、为什么需要锁升级

早期版本的 synchronized 直接依赖操作系统的互斥量(mutex),每次加锁、释放都要在用户态与内核态之间切换,开销不可忽视。但 JVM 团队通过大量统计发现:现实里“大多数锁在生命周期内都没有竞争”——同一个线程反复进入同步块(例如单线程复用、可重入),如果每次都走内核锁,纯属浪费。

因此 JDK 1.6 引入了偏向锁与轻量级锁,目标只有一个:在无线程竞争时,让加锁近乎零成本;只有真正出现竞争,才逐步“升级”到更重的实现。这就是 synchronized 锁升级思想的由来——用一层渐进式的代价,换取低竞争场景下的高性能。

二、锁存在哪:对象头与 Mark Word

每个 Java 对象在堆里都有一个对象头(Object Header),其中最重要的部分是 Mark Word,它保存了哈希码、分代年龄、是否偏向,以及锁标志位(lock bits)。在 64 位 JVM 下,Mark Word 占 8 字节,它的含义会随锁状态切换。理解了 Mark Word,就理解了 synchronized 锁升级的“舞台”。

锁状态锁标志位Mark Word 主要字段加锁开销
无锁01(偏向位 0)hashcode / 分代年龄无
偏向锁01(偏向位 1)偏向线程 ID + epoch极低
轻量级锁00指向栈帧 Lock Record 的指针低(CAS + 自旋)
重量级锁10指向 ObjectMonitor 的指针高(内核阻塞)

我们可以借助 OpenJDK 的 JOL(Java Object Layout)工具,把对象在加锁前后的布局打印出来,亲眼看到 Mark Word 的变化:

import org.openjdk.jol.info.ClassLayout;

public class MarkWordDemo {
    public static void main(String[] args) throws Exception {
        Object lock = new Object();
        // 未加锁:无锁状态,Mark Word 存 hashcode / 分代年龄
        System.out.println("before: " + ClassLayout.parseInstance(lock).toPrintable());

        synchronized (lock) {
            // 加锁后:偏向锁或轻量级锁,Mark Word 含义改变
            System.out.println("inside: " + ClassLayout.parseInstance(lock).toPrintable());
        }
        // 释放后:回到无锁(若已膨胀为重量级则不会退化)
        System.out.println("after:  " + ClassLayout.parseInstance(lock).toPrintable());
    }
}

三、四种锁状态与升级路径

synchronized 锁升级完整覆盖四种状态,各自的适用场景与实现方式如下:

状态适用场景实现方式竞争处理
无锁尚未有任何线程加锁普通对象头—
偏向锁同一线程反复进入同步块Mark Word 记录偏向线程 ID比对线程 ID,免 CAS
轻量级锁多线程交替进入、无同时竞争栈帧 Lock Record + CASCAS 失败则自旋
重量级锁真实高并发竞争ObjectMonitor 互斥量未抢到锁的线程 park 阻塞

锁升级是单向的:无锁 →(第一个线程进入)偏向锁 →(有别的线程来竞争)轻量级锁 →(自旋失败 / 竞争激烈)重量级锁。个别情况下偏向锁被撤销后会短暂回退到无锁,但重量级锁一旦进入就不会再降级。

四、偏向锁:单线程的“免检通道”

偏向锁的核心思想是:如果一段同步代码一直只被同一个线程执行,那这把锁就成了该线程的“专属通道”。后续进入时,JVM 只需比对 Mark Word 里的偏向线程 ID 是否等于自己,连一次 CAS 都不用做,单线程复用的开销几乎为零。

但偏向锁也有代价:一旦有第二个线程尝试进入,JVM 必须在全局安全点(Stop-The-World)撤销偏向,把锁恢复到可竞争状态。这个撤销成本在多线程频繁竞争时反而拖累性能。也正因如此,从 JDK 15 开始,偏向锁默认被禁用(-XX:+UseBiasedLocking 已废弃并在后续版本移除)。理解它,有助于看懂“为什么现在默认没有偏向锁”,以及历史代码里残留的调优参数为何失效。

五、轻量级锁:用自旋避免内核切换

当偏向锁被撤销、或对象一开始就未偏向,但有线程要进入同步块时,JVM 会尝试轻量级锁:在当前线程的栈帧里创建一个 Lock Record,把对象 Mark Word 复制到其中(Displaced Mark Word),然后用 CAS 把对象头的 Mark Word 替换为“指向该 Lock Record 的指针”。

CAS 成功则本线程获得轻量级锁;CAS 失败说明有别的线程已经在竞争,JVM 会让当前线程自旋(原地忙等)少量次数,期待对方很快释放;若自旋仍失败、或自旋次数耗尽,锁就会膨胀为重量级锁。轻量级锁的优势是:竞争不激烈时,线程没有进入内核阻塞,只是原地自旋,避免了上下文切换——前提是你得保证临界区足够短、释放够快。

六、重量级锁:操作系统互斥量

一旦升级到重量级锁,JVM 会在堆外创建一个 ObjectMonitor(监视器),其中包含等待队列(cxq、EntryList)与所有者线程指针(_owner)。未抢到锁的线程会被 park() 挂起,进入内核态阻塞,等待被唤醒;释放锁时再通过 unpark() 唤醒等待者。

这也是为什么“长临界区 + 高竞争”下 synchronized 会变慢:大量线程在用户态与内核态之间被挂起、唤醒,上下文切换成本陡增。可用 jstack 观察线程状态为 BLOCKED (on object monitor),或用 async-profiler 火焰图查看 monitorenter / monitorexit 占比。

"t-1" #13 prio=5 os_prio=0 cpu=12.30ms
   java.lang.Thread.State: BLOCKED (on object monitor)
        at EscalationDemo.lambda$main$0(EscalationDemo.java:9)
        - waiting to lock <0x000000076b6a3c20> (a java.lang.Object)
        - locked   <0x000000076b6a3c20> (a java.lang.Object)

上面这段 jstack 输出正是重量级锁的特征:线程被挂起在内核态,等待监视器所有权,这正是 synchronized 锁升级到最重一档时的真实表现。

七、代码实测:看 Mark Word 如何变化

下面用 JOL 在多个线程同时竞争同一把锁时打印对象布局,可以观察到 Mark Word 从偏向 / 轻量态跳到重量级(标志位从 01 / 00 变为 10,并指向 ObjectMonitor):

import org.openjdk.jol.info.ClassLayout;

public class EscalationDemo {
    static final Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 4; i++) {
            new Thread(() -> {
                synchronized (lock) {
                    // 多个线程同时抢同一把锁,触发膨胀为重量级
                    System.out.println(
                        Thread.currentThread().getName() + " holds:
" +
                        ClassLayout.parseInstance(lock).toPrintable());
                }
            }, "t-" + i).start();
        }
    }
}

提示:稳定的重量级观察需要制造“同时进入”的竞争(可用 CountDownLatch 让线程在同一时刻抢锁),单次运行中偏向 / 轻量级切换也可能出现,多跑几次即可看到完整升级链路。

八、性能建议与常见误区

常见误区事实
synchronized 一定慢低竞争下经过锁升级优化,与 ReentrantLock 差距极小,JIT 还能做锁消除、锁粗化
偏向锁永远好JDK 15+ 默认禁用,现代多核竞争激烈时反而有害
加锁范围越大越安全同步块越大竞争窗口越长,越容易升级为重量级锁

基于 synchronized 锁升级的机制,给出几条可落地的优化建议:

  • 缩小同步块:只包住真正共享可变状态的几行,减少竞争窗口。
  • 锁分解 / 锁分段:把一把大锁拆成多把(参考 ConcurrentHashMap 的分段思想),降低单锁竞争。
  • 读写分离:读多写少用 StampedLock 或 ReentrantReadWriteLock,避免读之间互斥。
  • 计数类场景:用 LongAdder 替代 synchronized++ 或 AtomicLong,内部分段减少争用。
  • 绕开锁:若临界区极短且无真实共享,可借助局部变量、ThreadLocal,或用 CompletableFuture 异步编程 编排任务,从设计上消除竞争。

九、小结

synchronized 锁升级是 JVM 在“正确性”与“性能”之间做的精细权衡:无竞争时几乎免费,有竞争时再付出代价。掌握对象头 Mark Word、偏向 / 轻量级 / 重量级三种实现,以及它们各自的适用场景,你就能在并发代码里做出更理性的取舍——该缩小同步块就缩小,该换无锁结构就换。想进一步理解 Java 并发的可见性基础,可以复习 Java 内存模型 JMM 与 volatile;高并发吞吐则可结合 Java 虚拟线程 重新思考线程模型。

上一篇 布隆过滤器实战:海量去重与缓存穿透防护
下一篇 FastAPI 实战:构建流式大模型对话 API
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]