从MESI缓存一致性协议讲透synchronized的底层

为什么说 synchronized 会慢一些?

很多人会说:"因为它是重量级锁,涉及内核态切换。"

对,但只对了一半。真正的答案藏在比JVM更深的地方------藏在你CPU的缓存里,藏在那条看不见的总线上,藏在MESI协议的每一次嗅探里。

今天从CPU缓存一致性协议MESI讲起,一路向上穿过总线锁、缓存锁、volatile、偏向锁、轻量级锁,最终抵达synchronized的重量级锁。

一条线,串起来。看完之后,你对并发的理解会彻底不同。


一、一切问题的起点:CPU在说谎

先看一组数据:

层级 速度 容量
L1 Cache ~1ns 32KB
L2 Cache ~3ns 256KB
L3 Cache ~10ns 12MB
主内存 ~100ns GB级

CPU和主内存之间的速度差,高达100倍

为了不让CPU干等,计算机架构师在CPU和主内存之间塞了三级缓存。CPU先查L1,没命中查L2,再没命中查L3,最后才去主内存捞数据。

这就是局部性原理------时间局部性(刚用过的数据很可能马上再用)和空间局部性(用了A,旁边的B大概率也会用)。

一切看起来很美好,直到多线程出现。


二、缓存一致性灾难:两个线程,两份副本

想象这个场景:

复制代码
线程A在CPU核心1上运行,把变量 i 从 1 改成 2,数据写在了核心1的L1缓存里。
线程B在CPU核心2上运行,从自己的L1缓存里读取 i,得到的还是 1。

线程A的修改,线程B根本看不到。

这就是缓存一致性问题。每个CPU核心都有自己的私有缓存,主内存只是"最终真相",但没有任何机制强制各个核心的缓存保持同步。

怎么办?


三、MESI协议:CPU之间的群聊机制

Intel给出的答案是MESI缓存一致性协议------基于Invalidate的高速缓存一致性协议,也是目前使用最广泛的方案。

每个缓存行(Cache Line,通常64字节)有四种状态:

状态 含义
M (Modified) 已修改,只有我有,和主内存不一致
E (Exclusive) 独占,只有我有,和主内存一致
S (Shared) 共享,多个核心都有,和主内存一致
I (Invalid) 无效,我这份是脏数据,得重新拉

核心思想只有一句话:当一个CPU修改了共享数据,它会通过总线"广播"------其他CPU,把你们手里那份缓存行给我标成无效!

这个广播机制叫总线嗅探(Bus Snooping)

比如线程A修改了变量i:

  1. CPU核心1发出嗅探信号:"我要改i了!"
  2. CPU核心2收到信号,把自己缓存里i所在的行标记为 I(无效)
  3. 线程B下次读i时,发现缓存行是I,被迫去主内存重新拉取最新值

MESI,就是CPU层面的"可见性保证"。


四、从总线锁到缓存锁:两种"霸道"方案

MESI解决了可见性,但如果我要做一个原子操作 (比如 i++,它其实是读→改→写三步),光靠MESI还不够。

CPU提供了两种锁机制:

方案一:总线锁(Bus Lock)------简单粗暴

处理器在总线上输出 LOCK# 信号,其他所有CPU核心全部禁止访问内存

复制代码
优点:绝对安全,强原子性
缺点:粒度极大,阻塞所有线程,CPU空转,性能极低
现状:现在几乎不用了

方案二:缓存锁(Cache Lock)------精准打击

不锁整条总线,只锁当前变量所在的缓存行

实现方式:在指令前加 LOCK 前缀(如 LOCK CMPXCHG),触发MESI协议:

  • 当前核心的缓存数据立即写回主内存

  • 其他核心的对应缓存行标记为无效

    优点:粒度极小,不影响无关数据,性能极高
    缺点:数据量过大或跨缓存行时,会自动降级为总线锁
    现状:这就是Java并发底层的主流依赖

volatile的底层实现,就是这条路。


五、volatile:一条 LOCK 前缀的轻量级同步

volatile 关键字的底层,是内存屏障(Memory Barrier) + LOCK前缀指令

在x86架构上:

操作 插入的屏障 作用
volatile写之前 StoreStore 保证之前的普通写先刷新到主内存
volatile写之后 StoreLoad 避免volatile写与后续读写重排序
volatile读之后 LoadLoad + LoadStore 保证读到最新值,禁止重排

写语义 :JMM强制把该线程本地内存中的共享变量刷新到主内存。

读语义:JMM强制把该线程本地内存置为无效,从主内存重新拉取。

volatile能保证可见性有序性 ,但不保证原子性

i++ 是三步操作:读→改→写。volatile只能保证读和写的可见性,但中间那个"改"是在本地缓存里完成的,多线程同时执行就会踩坑。

volatile是轻量级同步,不加锁,不阻塞,不切换线程。代价是功能有限。

那如果我既要原子性,又要可见性,还要有序性呢?

上重量级选手:synchronized。


六、synchronized:从字节码到Monitor的完整链路

Java虚拟机根本不认识 synchronized 关键字。它只是语法糖。

编译成class字节码后:

用法 字节码指令
同步代码块 monitorenter + monitorexit
同步方法 ACC_SYNCHRONIZED 标志位

6.1 重量级锁的本质:Monitor

每个Java对象都关联一个 Monitor(管程) ,底层是C++的 ObjectMonitor 结构体:

复制代码
_owner     → 持有锁的线程
_recursions → 锁的重入计数
_EntryList  → 阻塞队列(等锁的线程)
_WaitSet    → 等待队列(wait()的线程)

同步过程:

  1. 线程执行 monitorenter,尝试获取对象的Monitor
  2. 获取成功 → recursions + 1,线程成为Owner,执行同步代码
  3. 其他线程到达 monitorenter → 发现Monitor已被占用 → 进入 _EntryList 阻塞
  4. 线程执行 monitorexitrecursions - 1,归零则释放Monitor
  5. 释放后,_EntryList 中的线程被唤醒,竞争锁

Monitor依赖操作系统的Mutex Lock实现,涉及用户态→内核态切换,这就是"重量级"的由来。

但JVM觉得这个代价太大了,于是在JDK 1.6引入了一套锁升级机制------从无锁一路升级到重量级锁,能省则省。


七、锁升级:一条精心设计的" escalation ladder"

升级路线:无锁 → 偏向锁 → 轻量级锁 → 重量级锁

只升不降,不可逆。

所有锁的状态,都记录在对象头的 Mark Word 里:

复制代码
┌──────────────────────────────────────────┐
│              Java 对象头                   │
├──────────────────┬───────────────────────┤
│   Mark Word      │   Klass Word (类指针)  │
│  (锁状态/线程ID   │                       │
│   /HashCode/年龄) │                       │
└──────────────────┴───────────────────────┘

第一级:无锁态

对象刚创建,Mark Word存储HashCode、GC分代年龄,无任何锁标记。

所有线程自由访问,零开销。


第二级:偏向锁------这把锁是我的

设计思想 :大多数情况下,锁不存在多线程竞争,总是同一个线程反复获取。那干脆------第一次获取时把线程ID写进Mark Word,以后这个线程来了,检查一下是自己,直接放行,连CAS都不用。

获取流程:

  1. 检查Mark Word是否为可偏向状态(锁标志位=01)
  2. 检查Thread ID是否指向当前线程
  3. 是 → 直接执行同步代码,零同步操作
  4. 否 → CAS竞争,成功则替换Thread ID,失败则触发偏向锁撤销

偏向锁的撤销代价极高:需要等到全局安全点(Safepoint),暂停持有偏向锁的线程,检查它是否还活着,然后才能撤销。

⚠️ 重要更新:JDK 15中偏向锁已被标记废弃(JEP 374),JDK 18+基本退出历史舞台。 原因是现代高并发场景下,偏向锁的撤销成本(需要STW)已经超过了它节省的那点CAS开销。


第三级:轻量级锁------"自旋,别睡"

当第二个线程来竞争时,偏向锁撤销,升级为轻量级锁。

核心思想 :不想阻塞线程(用户态→内核态切换太贵),那就让线程自旋------在用户态循环尝试获取锁,短时间内能拿到就不用切换。

获取流程:

  1. JVM在当前线程的栈帧 中创建一个 Lock Record(锁记录),保存对象Mark Word的副本
  2. 线程用 CAS 操作,尝试把对象的Mark Word替换为指向Lock Record的指针
  3. CAS成功 → 获得轻量级锁,执行同步代码
  4. CAS失败 → 说明有其他线程在抢,自旋等待(默认10次)

释放流程:

  • CAS把Lock Record中的Displaced Mark Word写回对象头
  • 成功 → 释放锁
  • 失败 → 说明有竞争,膨胀为重量级锁

自旋不是傻等。JDK 1.6引入了自适应自旋

  • 上次自旋成功 → 这次多转几圈
  • 上次自旋失败 → 这次少转甚至不转,直接阻塞

但如果自旋超过阈值(或自旋线程数超过CPU核数一半),轻量级锁就会膨胀为重量级锁


第四级:重量级锁------睡吧,等通知

竞争激烈,自旋也拿不到锁,升级为重量级锁。

Mark Word被替换为指向操作系统 Mutex(互斥量) 的指针。

复制代码
获取失败 → 线程进入阻塞状态(BLOCKED)
            ↓
        加入 _EntryList 等待队列
            ↓
        释放锁时,从队列中唤醒一个线程

涉及用户态与内核态的上下文切换,开销最大,但也最公平。


八、一张图串起整条链路

复制代码
            ┌─────────────────────────────────────────┐
            │          多线程访问共享变量 i              │
            └──────────────────┬──────────────────────┘
                               ▼
                    ┌─────────────────────┐
                    │   CPU各核心私有缓存   │
                    │  L1 / L2 / L3       │
                    └──────────┬──────────┘
                               ▼
                    ┌─────────────────────┐
                    │    MESI 协议嗅探     │
                    │  修改→广播→其他失效  │
                    └──────────┬──────────┘
                               ▼
              ┌────────────────┼────────────────┐
              ▼                ▼                ▼
        ┌──────────┐    ┌──────────┐    ┌──────────────┐
        │ 总线锁    │    │ 缓存锁    │    │  volatile    │
        │ LOCK#    │    │ LOCK前缀  │    │ 内存屏障      │
        │ 阻塞全部  │    │ 精准锁行  │    │ 轻量可见性    │
        └──────────┘    └──────────┘    └──────────────┘
                                              │
                                              ▼
                                    ┌──────────────────┐
                                    │    synchronized   │
                                    │  monitorenter/exit│
                                    └────────┬─────────┘
                                             ▼
                                   ┌───────────────────┐
                                   │  锁升级 escalation │
                                   │                   │
                                   │  无锁 → 偏向锁     │
                                   │   ↓                │
                                   │  轻量级锁(自旋CAS) │
                                   │   ↓ 竞争激烈       │
                                   │  重量级锁(Mutex)   │
                                   └───────────────────┘

九、最后说几句

很多人背 synchronized 的特性:原子性、可见性、有序性。

但如果你不理解MESI,不理解为什么需要内存屏障,不理解锁为什么要升级------你背的只是答案,不是理解。

真正的理解是:

  • MESI 解决了"看不看得到"的问题(可见性)
  • 内存屏障 解决了"先执行谁"的问题(有序性)
  • Monitor + Mutex 解决了"能不能同时进"的问题(原子性)
  • 锁升级 解决了"怎么让代价最小"的问题(性能)

它们不是孤立的知识点,而是一条从硬件到软件、从物理到逻辑的完整链路。

下次再有人问你 synchronized 底层是什么,别再只说"Monitor"了。

从MESI讲起,一路讲到偏向锁的撤销、轻量级锁的自旋、重量级锁的阻塞------这才叫真正懂了。


关注技术号获取更多技术干货 !

相关推荐
zhenlai20121 小时前
Vue3 + SpringBoot + AI:我做了一个股票分析工具(第1周复盘)
人工智能·spring boot·后端
Devin~Y1 小时前
大厂 Java 面试实录:从音视频内容社区到 AI RAG 的全链路技术设计
java·spring boot·redis·spring cloud·微服务·kafka·音视频
CoderYanger1 小时前
A.每日一题:3612. 用特殊操作处理字符串 I
java·程序人生·leetcode·面试·职场和发展·学习方法·改行学it
承渊政道2 小时前
飞算JavaAI 智能引导背后的多 Agent 协作机制解析:从老旧 Java 后台升级到可运行工程
java·开发语言·spring boot·安全·intellij-idea·软件工程·ai编程
唐青枫3 小时前
Java Flyway 实战指南:用 SQL 脚本管理数据库版本
java
huangdong_10 小时前
电商平台图片URL原图转换技术深度解析:从缩略图到高清原图的完整方案
java·后端·spring
記億揺晃着的那天10 小时前
Java 调用外部 Go 程序的实践:ProcessBuilder 在生产环境中的应用
java·golang·processbuilder
JAVA面经实录91710 小时前
Java 数据结构与算法 (终极完整学习文档)
java·数据结构·算法
llz_11210 小时前
web-第四次课后作业
前端·spring boot·web