☰
​​​​​​​Java 并发锁底层原理:从 CPU 缓存一致性、MESI 到 AQS 与读写锁
2026/10/14 3:33:42 网站建设 项目流程

亲爱的宿主,欢迎你即将进入 Java 并发锁的底层世界!ヾ(@^▽^@)ノ

小系统已经准备好啦!今天我们不只学习ReentrantLock和ReentrantReadWriteLock的使用方法,还要从 CPU 缓存一致性出发,一步步揭开 Java 并发锁背后的实现原理。

本篇文章将按照以下路线展开:

为什么多线程共享数据会出现问题?

CPU 缓存一致性与 MESI 协议究竟是什么?

Java 的volatile与底层硬件有什么关系?

ReentrantLock如何借助 AQS 实现独占锁?

ReentrantReadWriteLock如何实现读共享、写独占?

独占锁与读写锁分别适合什么场景?

宿主大大,系好安全带,我们正式出发!(๑•̀ㅂ•́)و✧

一、前言:为什么并发编程需要锁?

1.1 从一张白纸说起

宿主大大,我们先想象一个简单的场景。

桌子上只有一张白纸,宿主想在上面写硬笔书法,小系统想把它折成纸飞机。

如果两个人同时操作这张纸,会发生什么?

宿主刚准备落笔,小系统就把纸折起来了;小系统刚准备折纸,宿主又把纸展开。

两个人都想操作同一个资源,却没有任何协调机制,最终可能谁也无法得到预期的结果。

在计算机中,这种问题被称为共享资源竞争。

当多个线程同时访问共享数据,并且至少有一个线程会修改数据时,如果缺少适当的同步机制,就可能出现数据竞争或其他并发问题。

例如:

Java

public class Counter { private static int count = 0; public static void increment() { count++; } }

假设两个线程各自执行 10000 次increment(),我们是否一定能得到 20000?

答案是:不能保证。

因为count++并不是一个不可分割的原子操作。它通常包含读取变量、计算新值、写回结果等步骤。

这就是典型的丢失更新问题。

当然,实际运行时两个线程不一定按照这个顺序执行,但这个例子说明了:只要操作不是原子的,多线程交错执行就可能破坏预期结果。

1.2 为什么 CPU 缓存也会影响并发?

宿主可能会问:

“既然变量存放在内存中,为什么线程不能直接读取最新数据呢?”

小系统告诉你一个重要知识点:现代 CPU 为了减少访问主存的延迟,通常会使用多级缓存,例如 L1、L2,以及部分处理器上的共享 L3 缓存。

不同 CPU 核心可能拥有各自的私有缓存。当多个核心处理同一内存地址时,就需要维护缓存副本之间的一致性。

例如:

Core A 缓存了变量x = 0。

Core B 也缓存了变量x = 0。

Core A 修改x。

Core B 之后也需要读取这个变量。

那么问题来了:Core B 如何知道自己的缓存副本是否仍然有效?

这就引出了CPU 缓存一致性协议。

但宿主要注意:缓存一致性并不等于线程同步。即使硬件能够维护缓存一致性,也不能自动保证count++这样的复合操作是原子的。

所以,我们需要分清三个层次:

层次主要解决的问题
CPU 缓存一致性不同核心对同一内存位置的缓存副本如何保持一致
Java 内存模型(JMM)Java 程序中线程之间的可见性、有序性等语义
Java 锁机制如何协调线程对共享资源的访问,提供互斥和同步保障

接下来,我们先进入 CPU 缓存一致性的世界。

二、总线嗅探:CPU 如何感知其他核心的操作?

2.1 什么是总线嗅探?

总线嗅探(Bus Snooping)是一种缓存一致性实现思路。

可以把它理解为:多个处理器核心的缓存控制器会关注相关的总线事务,当某个核心对内存地址发起特定操作时,其他核心能够据此判断自己的缓存副本是否需要调整状态。

例如:

1.Core A 持有地址 X 对应的缓存行。

2.Core B 也持有地址 X 对应的缓存行。

3.Core A 需要修改该缓存行。

4.一致性协议要求 Core A 获得相应的写入权限。

5.Core B 根据观察到的一致性事务,将自己的副本标记为无效,或按照协议执行其他状态转换。

之后,Core B 再次访问 X 时,如果缓存行已经失效,就需要重新获取有效的数据。

需要强调的是,现代处理器不一定使用传统的共享总线完成所有一致性通信,也可能使用点对点互连、目录结构等机制。因此,总线嗅探是理解缓存一致性的经典模型,但不能代表所有现代 CPU 的具体实现。

2.2 写失效与写更新

缓存一致性中有两种经典思路。

机制核心思想特点
写失效(Write-Invalidate)修改数据前,让其他缓存中的相关副本失效后续访问失效副本时需要重新获取数据
写更新(Write-Update)将新数据传播给其他缓存副本可能产生较大的数据传输开销

以写失效为例,当 Core A 需要独占某个缓存行的写权限时,协议可能要求它使其他核心中的副本失效。

这样,其他核心就不能继续把过期的缓存副本当成有效数据使用。

MESI 就是基于缓存行状态管理的一种经典缓存一致性协议。

不过,缓存一致性协议的具体消息和状态转换取决于处理器实现,不能把所有 CPU 的操作都简化为一条统一的广播指令。

三、MESI 协议:给缓存行定义四种状态

3.1 MESI 的四种状态

MESI 是四种缓存行状态的首字母缩写:

3.2 MESI 的一次典型状态变化

假设变量x = 0,Core A 和 Core B 都要读取它。

这就是 MESI 的重要价值:通过状态管理与一致性通信,让不同核心对同一缓存行的访问遵循相应的协议规则。

但是,宿主大大,这里一定要记住一个区别:

MESI 能够帮助处理器维护缓存一致性,却不能代替 Java 锁来保护整个业务操作。

3.3volatile与 MESI 有什么关系?

Java 的volatile变量具有特定的可见性和有序性语义。

当一个线程对volatile变量进行写入,另一个线程随后读取该变量时,在符合 Java 内存模型规定的同步关系下,后续读取能够观察到相应的写入。

例如:

Java

public class VolatileDemo { private static volatile boolean ready = false; private static int value = 0; public static void main(String[] args) throws InterruptedException { Thread writer = new Thread(() -> { value = 42; ready = true; }); Thread reader = new Thread(() -> { while (!ready) { // 等待 ready 变为 true } System.out.println(value); }); reader.start(); writer.start(); writer.join(); reader.join(); } }

在这段代码中,对ready的volatile写入与随后观察到该写入的读取建立了相应的 happens-before 关系。因此,读取线程观察到ready == true后,也能够看到写线程在此前完成的value = 42。

那么,volatile是不是直接等于 MESI?

当然不是!

MESI 是处理器缓存一致性的一种协议,而 Java 内存模型规定的是 Java 程序应该具备怎样的并发语义。JVM 会根据目标处理器架构,利用适当的指令、内存屏障以及硬件机制来实现这些语义。

此外,volatile并不能让count++自动变成原子操作。

所以,当多个线程需要对同一个共享变量执行复合更新时,通常还需要使用锁或原子类等机制。

到这里,我们已经理解了硬件层面的基础。

接下来,真正进入 Java 并发锁的核心!

四、独占锁:ReentrantLock 的底层原理

4.1 什么是独占锁?

宿主大大,我们先回到那张白纸。

如果宿主正在写书法,小系统就必须等待;只有宿主结束使用并释放白纸,小系统才能继续折纸。

这就是独占锁的基本思想:同一时刻,只允许一个线程持有锁并进入受该锁保护的临界区。

Java 中常见的独占同步机制包括:

synchronized

ReentrantLock

两者都可以用于实现互斥访问,但它们的实现方式、功能接口和使用特点有所不同。

这里我们重点学习ReentrantLock。

4.2 为什么叫 ReentrantLock?

ReentrantLock可以拆分成两个部分理解:

Lock:锁。

Reentrant:可重入。

所谓可重入,是指已经持有某把锁的线程,可以再次获取同一把锁,而不会因为重复获取而把自己阻塞住。

例如:

Java

import java.util.concurrent.locks.ReentrantLock; public class ReentrantDemo { private static final ReentrantLock lock = new ReentrantLock(); public static void outer() { lock.lock(); try { inner(); } finally { lock.unlock(); } } public static void inner() { lock.lock(); try { System.out.println("当前线程再次获取同一把锁"); } finally { lock.unlock(); } } public static void main(String[] args) { outer(); } }

当线程调用outer()时,它首先获得锁。

随后,outer()调用inner(),而inner()又尝试获取同一把锁。

因为当前线程已经持有这把锁,所以它可以再次获取锁,并增加重入计数。

当inner()返回时,内部的unlock()只会减少一次重入计数;等outer()最终执行自己的unlock(),锁才真正释放。

这就是可重入的含义。

4.3 AQS 是什么?

ReentrantLock的核心实现依赖于 AQS,也就是AbstractQueuedSynchronizer,中文通常称为抽象队列同步器。

AQS 是 Java 并发包中构建同步器的重要基础设施。它通过同步状态、原子操作以及等待队列等机制,帮助实现线程之间的竞争、等待与唤醒。

可以把它理解为一个通用的同步器框架:

同步状态:表示同步资源当前的状态。

原子操作:通过 CAS 等机制尝试安全地更新状态。

等待队列:竞争失败的线程可以排队等待。

获取与释放:同步器定义线程如何获取资源、何时释放资源。

ReentrantLock在此基础上实现了自己的独占锁语义。

4.4state变量究竟有什么用?

在ReentrantLock的 AQS 实现中,state是一个int类型的同步状态。

对于这把锁,可以这样理解:

state的值含义
0当前没有线程持有锁
1锁被持有一次
2当前持有锁的线程已经重入一次
3当前持有锁的线程已经重入两次

注意:这里的重入次数是相对于首次加锁而言的。state的具体值代表持有次数,而不是所有线程的竞争次数。

当一个线程首次获取锁时,通常会尝试通过 CAS 将state从 0 改为 1,并记录当前持有锁的线程。

如果同一个线程再次获取锁,则会增加state。

如果其他线程尝试获取锁,而锁已经被占用,它就不能直接获取这把锁,需要进入相应的竞争与等待流程。

当持有锁的线程调用unlock()时,state会相应减少。只有当重入计数降到 0,锁才会真正释放。

4.5 AQS 获取锁的流程

需要说明的是,AQS 的具体队列管理、线程阻塞和唤醒过程比图中复杂,实际实现也可能随 JDK 版本演进。上图展示的是帮助理解的简化流程。

4.6 公平锁与非公平锁

宿主大大,抢锁也不一定要讲究先来后到!

ReentrantLock提供公平和非公平两种获取策略。

Java

// 非公平锁 ReentrantLock lock1 = new ReentrantLock(); // 公平锁 ReentrantLock lock2 = new ReentrantLock(true);

非公平锁

新来的线程在某些情况下可以直接尝试抢锁,而不必严格等待队列中的所有线程依次获得锁。

优点:通常有利于提高吞吐量。缺点:等待线程可能长期无法获得锁。

公平锁

获取锁时会考虑等待队列中的线程,尽量遵循先等待者先获得锁的策略。

优点:能够减少部分线程长期等待的风险。缺点:通常会降低吞吐量。

公平锁并不意味着绝对公平,也不保证每个线程都能在固定时间内获得锁。

此外,公平性并不能简单归因于缓存行弹跳。实际性能会受到线程调度、竞争程度、临界区长度以及处理器架构等多种因素影响。

4.7 使用 ReentrantLock 的正确姿势

Java

import java.util.concurrent.locks.ReentrantLock; public class CounterDemo { private static int count = 0; private static final ReentrantLock lock = new ReentrantLock(); public static void increment() { lock.lock(); try { count++; } finally { lock.unlock(); } } public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { for (int i = 0; i < 10000; i++) { increment(); } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 10000; i++) { increment(); } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count); // 20000 } }

这里最重要的一点是:unlock()必须放在finally中。

因为如果临界区内发生异常,而代码没有释放锁,其他线程就可能一直无法继续执行。

宿主记住这句话:

获取锁之后,无论正常返回还是发生异常,都应该确保锁能够被正确释放。

五、读写锁:ReentrantReadWriteLock 的底层原理

5.1 为什么需要读写锁?

宿主大大,我们现在思考一个问题。

假设一个系统中有 100 个线程:

95 个线程只读取配置数据。

5 个线程负责更新配置数据。

如果所有线程都使用独占锁,那么即使两个线程只是读取同一份数据,也必须互相等待。

这会不会有点浪费?

对于适合并发读取的数据,我们可以考虑使用读写锁。

ReentrantReadWriteLock的核心思想是:

读读可以并发,读写互斥,写写互斥。

也就是说:

当前持有的锁新线程申请读锁新线程申请写锁
无锁可以可以
读锁通常可以不可以
写锁不可以不可以

这里的“可以”还需要结合锁的公平策略、当前线程身份和重入情况理解,表格主要展示一般的线程间互斥规则。

5.2 它内部真的有两把锁吗?

从使用方式上看,ReentrantReadWriteLock提供了两把锁:

Java

ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock(); ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();

但从实现角度看,它们共享同一个同步器和同步状态。

读锁与写锁分别实现不同的获取和释放规则,并不是两把完全独立、互不相关的锁。

这也引出了一个重要问题:

既然读锁和写锁共享同一个state,那怎么区分当前有多少读线程、写锁重入了多少次呢?

5.3 一个int如何存储读写状态?

在 OpenJDK 的经典实现中,ReentrantReadWriteLock使用一个 32 位的int来存储同步状态。

它将这个状态划分为两个部分:

从数学上讲,可以将state理解为:

这里的<< 16表示左移 16 位。

例如:

没有读锁,也没有写锁:state = 0。

只有一个写锁持有计数:state = 1。

有一个读锁持有计数,没有写锁:state = 65536。

注意:读锁计数表示当前共享持有计数,不是简单地记录有多少个不同的线程对象。读锁的可重入行为也会影响计数。

5.4 写锁是如何获取的?

写锁属于独占锁。

一般情况下,线程想要获取写锁,需要满足以下条件:

1.当前没有其他线程持有读锁。

2.当前没有其他线程持有写锁。

3.如果当前线程已经持有写锁,则可以按照重入规则再次获取。

获取成功后,写锁计数增加。

例如:

Java

writeLock.lock(); try { // 修改共享数据 } finally { writeLock.unlock(); }

如果其他线程正在持有读锁,那么申请写锁的线程通常需要等待。

如果写锁已经被其他线程持有,新的写线程同样不能直接获取锁。

只有在满足获取条件时,写线程才能进入临界区。

5.5 读锁是如何获取的?

读锁属于共享锁。

只要没有其他线程持有写锁,并且符合当前同步器的获取规则,多个线程就可以同时获得读锁。

Java

readLock.lock(); try { // 读取共享数据 } finally { readLock.unlock(); }

假设线程 A 和线程 B 都在读取同一份数据,那么它们可以同时持有读锁,而不需要像独占锁那样互相等待。

不过,读锁并不是完全没有成本。

读锁获取和释放时,仍然需要维护同步状态、执行原子操作,并处理线程自己的重入计数等信息。

当读取操作极短、竞争程度不高时,读写锁不一定比synchronized或其他同步方式更快。

5.6 为什么读锁还要记录线程自己的重入次数?

宿主可能会问:

“既然多个线程可以同时拿读锁,为什么还要记录每个线程的重入次数呢?”

原因在于,每个线程仍然需要正确管理自己的锁持有情况。

例如:

Java

readLock.lock(); try { readLock.lock(); try { // 同一线程重入读锁 } finally { readLock.unlock(); } } finally { readLock.unlock(); }

在这个例子中,线程连续获取读锁两次,也需要对应释放两次。

在经典的ReentrantReadWriteLock实现中,除了共享的读锁计数,还会维护线程级别的读锁持有信息,例如线程局部计数以及对最近读线程的缓存等实现细节。

这样才能正确处理读锁重入和释放。

六、读写锁的公平性、锁降级与锁升级

6.1 公平策略

ReentrantReadWriteLock默认使用非公平模式。

Java

// 默认非公平读写锁 ReentrantReadWriteLock rw1 = new ReentrantReadWriteLock(); // 公平读写锁 ReentrantReadWriteLock rw2 = new ReentrantReadWriteLock(true);

公平模式会考虑等待队列中的线程顺序,尽量避免新来的线程持续插队。

非公平模式则允许在符合实现规则的情况下进行竞争,通常更有利于提高吞吐量,但持续竞争时可能出现某些线程等待时间过长的情况。

因此,选择公平锁还是非公平锁,应根据业务对吞吐量和等待公平性的要求决定。

6.2 什么是锁降级?

锁降级指的是:

先持有写锁,再获取读锁,最后释放写锁,从而保留读锁。

例如:

Java

import java.util.concurrent.locks.ReentrantReadWriteLock; public class LockDowngradeDemo { private static final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); private static int value = 0; public static void updateAndRead() { rwLock.writeLock().lock(); try { value = 100; // 写锁仍然持有时获取读锁 rwLock.readLock().lock(); } finally { rwLock.writeLock().unlock(); } try { System.out.println(value); } finally { rwLock.readLock().unlock(); } } }

为什么要这么做?

假设线程 A 更新数据后还需要继续读取它。如果线程 A 先释放写锁,再重新获取读锁,那么在这段时间内,其他线程可能获取写锁并修改数据。

但如果线程 A 在释放写锁之前已经成功获取读锁,那么其他线程就不能在它持有读锁期间获取写锁。

因此,线程 A 可以在完成更新后继续读取数据,而不必担心其他线程在写锁释放与读锁获取之间插入一次写操作。

这里要注意:锁降级不会让线程同时拥有写锁和读锁的独占权限。它只是先在持有写锁时获取读锁,然后释放写锁,最终只保留读锁。

6.3 为什么不支持直接锁升级?

所谓锁升级,是指线程已经持有读锁,又尝试直接获取写锁。

在ReentrantReadWriteLock中,普通读锁不能直接升级为写锁。

假设:

线程 A 持有读锁。

线程 B 也持有读锁。

A 尝试获取写锁。

B 尝试获取写锁。

由于 A 和 B 都持有读锁,写锁必须等待所有不兼容的读锁释放。

但 A 和 B 又都在等待获取写锁,无法继续执行释放读锁的代码。

这样就可能形成死锁。

如果确实需要从读取切换到更新,通常需要先释放读锁,再尝试获取写锁,并在获得写锁后重新检查数据是否仍然满足更新条件。

原因很简单:释放读锁到获取写锁之间,其他线程可能已经修改了数据。

七、独占锁与读写锁:实战对比

宿主大大,前面的知识点已经讲了不少,现在我们把它们整理成一张表,方便复习。

对比维度ReentrantLockReentrantReadWriteLock
锁模式独占锁读锁共享,写锁独占
读读是否互斥如果使用同一把锁,则互斥通常不互斥
读写是否互斥同一把锁下互斥互斥
写写是否互斥互斥互斥
重入能力支持读锁和写锁均支持相应的重入
公平策略支持公平与非公平模式支持公平与非公平模式
状态管理通过 AQS 的state管理独占持有状态通过共享状态区分读锁计数和写锁计数
锁降级没有读写锁意义上的降级机制支持先写后读,再释放写锁
锁升级不适用不支持普通读锁直接升级为写锁
典型场景需要独占保护的临界区读多写少且读操作具有并发收益的场景
使用成本相对简单计数与协调机制更复杂

7.1 到底该选择哪一种?

小系统给宿主准备了一份简单的选择建议:

优先考虑ReentrantLock的场景

1.读写都需要严格互斥。

2.临界区比较简单。

3.希望使用tryLock()、可中断获取锁等功能。

4.需要根据业务明确控制锁的获取与释放。

可以考虑ReentrantReadWriteLock的场景

1.共享数据被频繁读取。

2.写操作相对较少。

3.读操作足够长,或者并发读取能够带来实际收益。

4.业务允许多个读线程同时访问同一份数据。

不过,宿主大大要记住:读写锁并不一定比独占锁快。

如果临界区很短,或者读写竞争并不激烈,那么维护读锁计数、处理共享状态和线程协调的成本,可能抵消读并发带来的收益。

因此,真正的选择应当建立在正确性分析和实际性能测试的基础上。

八、总结:从 CPU 缓存到 Java 并发锁

这条链路帮助我们从硬件层逐步理解 Java 并发机制,但要注意:它们并不是一层层简单调用的单向实现链。JMM 定义程序语义,JVM 负责实现这些语义,底层可能利用缓存一致性协议和其他硬件机制,而 AQS 则是 Java 并发包中的同步器框架。

今天最重要的知识点有四个:

1.缓存一致性不等于原子性。 即使缓存副本保持一致,count++仍可能出现丢失更新。

2.volatile不等于锁。 它能够提供规定的可见性与有序性语义,但不能单独保证复合操作的原子性。

3.ReentrantLock通过 AQS 实现独占同步。 理解state、CAS、等待队列和重入机制,是理解它的关键。

4.ReentrantReadWriteLock用共享读与独占写提升部分场景的并发能力。 但它有额外开销,只有在合适的工作负载下才能体现优势。

最后,小系统想告诉宿主:

学习并发编程,不是记住某个锁有哪些 API 就结束了。真正重要的是理解共享资源为什么会产生竞争、同步机制如何保证正确性,以及我们应该在什么场景下选择什么工具。

今天就先探索到这里啦!ヾ(๑╹◡╹)ノ"

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询