Java 内存模型(JMM)- synchronized 与内存屏障
本文深入 synchronized 的底层原理,主要关于 JMM、内存屏障、happens-before 的讨论。
一、synchronized 提供了什么?
synchronized 是 Java 内置的重量级锁(但 JDK 1.6 后做了锁升级优化,下文会说),它保证三个特性:
| 特性 | 是否保证 | 说明 |
|---|---|---|
| 原子性 | ✅ | 被 synchronized 包裹的代码块(或方法)在同一时刻只能有一个线程执行 |
| 可见性 | ✅ | 锁释放时会强制将工作内存中的修改刷新到主内存;锁获取时会强制重新从主内存加载最新值 |
| 有序性 | ✅ | 锁内的代码不会被重排序到锁外(但仍可在锁内重排序,不影响可见性) |
二、synchronized 的 JMM 语义——happens-before 规则
JMM 定义了锁规则:
- 解锁(unlock)操作 happens-before 后续对同一个锁的加锁(lock)操作。
结合程序顺序规则和传递性,可以推出:
- 线程 A 在锁内写操作 happens-before 线程 A 释放锁;
- 线程 A 释放锁 happens-before 线程 B 获得同一个锁;
- 因此线程 A 锁内的所有写操作对线程 B 锁内的读操作可见。
这与 volatile 的写-读规则本质上是一致的,只是 synchronized 可以包围多个操作,而 volatile 只针对单个变量。
三、底层实现:内存屏障与锁指令
synchronized 在 JVM 层面的实现依赖 monitor 机制(每个对象关联一个 monitor),字节码层面有 monitorenter / monitorexit 指令。在底层(x86 为例)则涉及 lock 前缀的指令和 MESI 协议。
3.1 获取锁时的内存语义
当线程获取锁时(进入 synchronized 块),JVM 会插入一个 LoadLoad 和 LoadStore 屏障(类似 volatile 读的效果),以保证:
- 从主内存重新加载共享变量,清除工作内存中的旧值。
- 后续的读/写操作不会被重排到获取锁之前。
实际上,获取锁的动作本身会触发缓存一致性操作,保证进入临界区前看到其他线程释放锁前的最新值。
3.2 释放锁时的内存语义
当线程释放锁时(退出 synchronized 块),JVM 会插入一个 StoreStore 和 StoreLoad 屏障(类似 volatile 写的效果),以保证:
- 临界区内的所有写操作先于锁释放而被刷入主内存。
- 锁释放后的任何操作(例如后续的普通读)不会重排到锁释放之前。
在 x86 上,monitorexit 通常对应 lock 前缀的指令(例如 lock cmpxchg 或 lock addl $0, (%rsp)),实现全屏障效果。
四、对象头与锁的状态(锁升级)
Java 对象的内存布局包含对象头(Mark Word),用于存储锁信息。JDK 1.6 以后引入了锁升级机制,从无锁到偏向锁、轻量级锁、重量级锁,以优化性能。
| 锁状态 | 适用场景 | Mark Word 内容 | 获取/释放开销 |
|---|---|---|---|
| 无锁 | 对象未被锁定 | 哈希码、分代年龄 | - |
| 偏向锁 | 单线程反复进入同步块 | 线程 ID(偏向的线程) | 极低(仅一次 CAS) |
| 轻量级锁 | 多线程交替进入,无竞争 | 指向线程栈中 Lock Record 的指针 | CAS 操作(自旋) |
| 重量级锁 | 竞争激烈 | 指向 monitor 对象的指针 | 操作系统互斥量(用户态→内核态) |
锁升级过程(不可逆,除了偏向锁可撤销):
- 初始无锁 → 偏向锁(如果 JVM 启动偏向锁延迟后,第一个线程进入时设置偏向线程 ID)。
- 偏向锁遇到竞争(另一线程请求同一对象的锁)→ 撤销偏向锁 → 升级为轻量级锁。
- 轻量级锁自旋失败(或者自旋次数超过阈值)→ 膨胀为重量级锁。
无论哪种锁,最终都需要保证 JMM 的内存语义(可见性、有序性)。
五、synchronized 与 volatile 的对比总结
| 特性 | synchronized |
volatile |
|---|---|---|
| 原子性 | 保证代码块内操作原子 | 不保证(仅单次读写原子) |
| 可见性 | 释放锁时刷新内存;获取锁时重载 | 写后立即刷新;读前重新加载 |
| 有序性 | 锁内代码不会与锁外重排 | 禁止特定重排序 |
| 使用粒度 | 方法或代码块(可多个变量) | 单个变量(字段) |
| 是否阻塞 | 可能阻塞(重量级锁时) | 不阻塞,无上下文切换 |
| 性能 | 有锁升级优化,但竞争时仍较重 | 非常轻量(仅加入内存屏障) |
| 适用场景 | 复合操作、需要原子性 | 状态标志、一次性安全发布(如 DCL) |
六、典型错误:synchronized 不能保证不可变对象的可见性?
有人误以为 synchronized 不能保证 final 字段的可见性,实际上只要正确使用了 synchronized(获取/释放同一个锁),其内存屏障和 happens-before 规则会使得所有字段(无论是否 final)的更新对其他线程可见。final 的特殊性在于不依赖同步也能安全发布,但 synchronized 依然是全功能的保证。
七、代码示例:synchronized 的内存效果
public class SynchVisibility {
private int x = 0;
private boolean ready = false;
public synchronized void writer() {
x = 42; // 写普通变量
ready = true; // 写普通变量
// 释放锁时:StoreStore + StoreLoad 屏障
}
public synchronized void reader() {
// 获取锁时:LoadLoad + LoadStore 屏障(重新从主存加载)
if (ready) {
System.out.println(x); // 保证看到 42
}
}
}
如果两个线程分别调用 writer() 和 reader()(使用同一对象实例),则 reader() 看到 ready==true 时,必然看到 x=42。这里没有用 volatile 或 final,全靠 synchronized 的内存语义保证。
八、底层实现的简化模型(JVM 抽象)
JVM 在实现 synchronized 时,可以认为在 monitorenter 和 monitorexit 前后插入了类似于下列的屏障:
monitorenter
[LoadLoad] // 保证从内存读取最新值,且不会与下面的读重排
[LoadStore] // 保证读取不会与下面的写重排
// 临界区代码(可能包含读、写操作)
[StoreStore] // 保证临界区内的写不会与释放锁重排
[StoreLoad] // 保证释放锁时所有写已刷入主存,且后续读不会重排到释放锁之前
monitorexit
不同 CPU 架构对这些屏障的实现不同(x86 会折叠许多屏障为 lock 前缀指令,ARM 需要显式 dmb 等)。
九、与 volatile 的性能比较
volatile只影响单个变量,且没有锁竞争,开销恒定且很小。synchronized在无竞争时(偏向锁或轻量级锁)开销也很低,但一旦有竞争就可能进入重量级锁,涉及操作系统内核态切换,开销变大。- 因此,尽量选择
volatile如果满足需求;否则使用synchronized(或Lock)。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)