亲爱的宿主,欢迎你即将进入 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 又都在等待获取写锁,无法继续执行释放读锁的代码。
这样就可能形成死锁。
如果确实需要从读取切换到更新,通常需要先释放读锁,再尝试获取写锁,并在获得写锁后重新检查数据是否仍然满足更新条件。
原因很简单:释放读锁到获取写锁之间,其他线程可能已经修改了数据。
七、独占锁与读写锁:实战对比
宿主大大,前面的知识点已经讲了不少,现在我们把它们整理成一张表,方便复习。
| 对比维度 | ReentrantLock | ReentrantReadWriteLock |
|---|---|---|
| 锁模式 | 独占锁 | 读锁共享,写锁独占 |
| 读读是否互斥 | 如果使用同一把锁,则互斥 | 通常不互斥 |
| 读写是否互斥 | 同一把锁下互斥 | 互斥 |
| 写写是否互斥 | 互斥 | 互斥 |
| 重入能力 | 支持 | 读锁和写锁均支持相应的重入 |
| 公平策略 | 支持公平与非公平模式 | 支持公平与非公平模式 |
| 状态管理 | 通过 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 就结束了。真正重要的是理解共享资源为什么会产生竞争、同步机制如何保证正确性,以及我们应该在什么场景下选择什么工具。
今天就先探索到这里啦!ヾ(๑╹◡╹)ノ"