一把锁的两种承诺:synchronized如何同时保证互斥与内存可见性?
一把锁的两种承诺:synchronized如何同时保证互斥与内存可见性?
在并发编程的世界里,synchronized 是 Java 中最基础的同步机制,但它却承载着两个核心承诺:互斥(Mutual Exclusion)和内存可见性(Memory Visibility)。这两个承诺看似简单,但背后涉及 Java 内存模型(JMM)、操作系统底层指令以及编译器优化等复杂原理。本文将从底层原理出发,结合代码示例,深入剖析 synchronized 如何同时实现这两种保证。### 1. 互斥:锁的本质与实现互斥是指同一时刻只有一个线程能访问共享资源。synchronized 通过对象监视器(Monitor)来实现这一点。#### 1.1 对象监视器的工作原理每个 Java 对象头中都包含一个 Mark Word,它存储了锁的状态信息。当线程进入 synchronized 代码块时,JVM 会尝试获取对象的监视器锁:- 如果锁未被持有,线程会通过 CAS(Compare-And-Swap)操作将 Mark Word 修改为指向当前线程的指针,并进入偏向锁模式。- 如果锁已被其他线程持有,则当前线程会进入阻塞状态,直到锁释放。以下是一个演示互斥的代码示例:javapublic class MutexExample { private static int counter = 0; private static final Object lock = new Object(); public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { for (int i = 0; i < 10000; i++) { synchronized (lock) { // 获取锁 counter++; } // 释放锁 } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 10000; i++) { synchronized (lock) { // 获取锁 counter++; } // 释放锁 } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("Final counter: " + counter); // 输出 20000 }}原理分析: - 第 7 行和第 14 行的 synchronized (lock) 保证了 counter++ 操作不会被两个线程同时执行。- 如果没有 synchronized,counter++(非原子操作)会导致数据竞争,结果可能小于 20000。### 2. 内存可见性:从缓存一致性到 happens-before互斥只解决了原子性问题,但线程的本地缓存可能导致一个线程的修改不被其他线程看到。synchronized 通过 happens-before 规则 来保证内存可见性。#### 2.1 硬件层面的缓存一致性问题现代 CPU 使用多级缓存(L1/L2/L3)来加速数据访问。线程可能将共享变量缓存在自己的核心缓存中,导致:- 线程 A 修改变量后,写回主内存的延迟。- 线程 B 读取时,仍然使用过期的缓存值。#### 2.2 JMM 的 happens-before 规则Java 内存模型定义了 synchronized 的 管程锁定规则:- 解锁操作(monitorexit)必须 happens-before 后续对同一锁的加锁操作(monitorenter)。- 这意味着:线程 A 释放锁之前的所有写入操作,对线程 B 获取同一锁后都是可见的。底层实现依赖于内存屏障:- 释放屏障:在释放锁时,强制刷新 CPU 写缓冲区到主内存。- 获取屏障:在获取锁时,使 CPU 缓存行失效,强制从主内存重新读取。以下代码演示可见性问题:javapublic class VisibilityExample { private static boolean flag = false; private static int number = 0; private static final Object lock = new Object(); public static void main(String[] args) throws InterruptedException { Thread writer = new Thread(() -> { synchronized (lock) { // 加锁 number = 42; // 写操作 flag = true; // 写操作 } // 释放锁,触发释放屏障 }); Thread reader = new Thread(() -> { synchronized (lock) { // 加锁,触发获取屏障 if (flag) { // 读操作 System.out.println("number = " + number); // 保证输出 42 } } // 释放锁 }); writer.start(); Thread.sleep(10); // 保证 writer 先执行 reader.start(); writer.join(); reader.join(); }}原理分析: - 第 9 行的 flag = true 和 number = 42 在释放锁之前完成。- 第 14 行的获取锁操作确保了 reader 线程能看到 writer 线程的所有写入。- 如果去掉 synchronized,reader 可能看到 flag = true 但 number = 0(指令重排序导致)。### 3. 双重保证:互斥与可见性的协同synchronized 的两个承诺并非独立工作,而是相互依赖:- 互斥 确保了临界区内代码的原子性,防止指令交错。- 可见性 确保了临界区外的线程能看到最新的修改。#### 3.1 经典问题:双重检查锁定(DCL)DCL 是单例模式中的常见技巧,但需要 volatile 或 synchronized 来保证可见性:javapublic class Singleton { private static volatile Singleton instance; // volatile 保证可见性 private Singleton() {} public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { // 互斥 if (instance == null) { instance = new Singleton(); // 可能发生指令重排序 } } } return instance; }}为什么需要 volatile? - instance = new Singleton() 不是原子操作,可能被分解为:分配内存、初始化对象、赋值引用。- 如果没有 volatile,JVM 可能重排序为:分配内存、赋值引用(此时 instance != null)、初始化对象。- 另一个线程在 if (instance == null) 判断时,会看到非空的引用,但访问未初始化的对象导致错误。synchronized 保证了创建实例时的互斥,但 volatile 负责禁止指令重排序和保证可见性。### 4. 性能权衡:从偏向锁到重量级锁synchronized 在 JDK 6 后进行了大量优化:- 偏向锁:无竞争时,偏向某个线程,避免 CAS 开销。- 轻量级锁:轻度竞争时,通过 CAS 自旋,避免线程阻塞。- 重量级锁:严重竞争时,依赖操作系统互斥量(mutex),线程阻塞。这种优化使得 synchronized 在大多数场景下性能优于 ReentrantLock。### 5. 总结synchronized 的一把锁,同时承载了互斥和可见性两种承诺:- 互斥 通过对象监视器实现,依赖 CPU 的 CAS 指令和操作系统的线程调度,确保同一时刻只有一个线程访问临界区。- 可见性 通过 happens-before 规则和内存屏障实现,确保锁释放前的写入对后续锁获取线程可见。理解这两个承诺的底层原理,有助于我们编写正确的并发程序。在实际开发中,synchronized 是最易用的同步工具,但在需要更精细控制(如超时、可中断)时,可以考虑 Lock 接口。无论选择哪种机制,核心都是保证 原子性、可见性、有序性 这三要素。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)