为什么说 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:
- CPU核心1发出嗅探信号:"我要改i了!"
- CPU核心2收到信号,把自己缓存里i所在的行标记为 I(无效)
- 线程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()的线程)
同步过程:
- 线程执行
monitorenter,尝试获取对象的Monitor - 获取成功 →
recursions + 1,线程成为Owner,执行同步代码 - 其他线程到达
monitorenter→ 发现Monitor已被占用 → 进入_EntryList阻塞 - 线程执行
monitorexit→recursions - 1,归零则释放Monitor - 释放后,
_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都不用。
获取流程:
- 检查Mark Word是否为可偏向状态(锁标志位=01)
- 检查Thread ID是否指向当前线程
- 是 → 直接执行同步代码,零同步操作
- 否 → CAS竞争,成功则替换Thread ID,失败则触发偏向锁撤销
偏向锁的撤销代价极高:需要等到全局安全点(Safepoint),暂停持有偏向锁的线程,检查它是否还活着,然后才能撤销。
⚠️ 重要更新:JDK 15中偏向锁已被标记废弃(JEP 374),JDK 18+基本退出历史舞台。 原因是现代高并发场景下,偏向锁的撤销成本(需要STW)已经超过了它节省的那点CAS开销。
第三级:轻量级锁------"自旋,别睡"
当第二个线程来竞争时,偏向锁撤销,升级为轻量级锁。
核心思想 :不想阻塞线程(用户态→内核态切换太贵),那就让线程自旋------在用户态循环尝试获取锁,短时间内能拿到就不用切换。
获取流程:
- JVM在当前线程的栈帧 中创建一个 Lock Record(锁记录),保存对象Mark Word的副本
- 线程用 CAS 操作,尝试把对象的Mark Word替换为指向Lock Record的指针
- CAS成功 → 获得轻量级锁,执行同步代码
- 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讲起,一路讲到偏向锁的撤销、轻量级锁的自旋、重量级锁的阻塞------这才叫真正懂了。
关注技术号获取更多技术干货 !
