基于本文回答

播面 播面

文图音视,全方位拆解八股文
0
评论

ReentrantReadWriteLock:读写分离与AQS实现

知识点图片

ReentrantReadWriteLock通过AQS实现读写分离:读锁共享,写锁排他。它专为“读多写少”场景设计以提升性能,并支持锁降级。本文深入剖析其状态位设计、公平性及实现原理。

我们来深入剖析一下 ReentrantReadWriteLock 的工作原理。这部分内容会稍微有点硬核,但理解后会对 Java 并发编程有更深的认识。

ReentrantReadWriteLock(可重入读写锁)是 Java 并java.util.concurrent.locks包下的一个核心工具,它解决了ReentrantLock这种排他锁在“读多写少”场景下性能低下的问题。

1. 核心思想:读写分离与锁降级

ReentrantReadWriteLock的核心思想是读写分离。它内部维护了两个锁:一个读锁 (Read Lock) 和一个写锁 (Write Lock)。这两个锁共享同一个等待队列,并遵循以下规则:

  1. 读锁是共享的 (Shared):多个线程可以同时持有读锁。只要没有线程持有写锁,任何线程都可以成功获取读锁。
  2. 写锁是排他的 (Exclusive):同一时间只能有一个线程持有写锁。当一个线程持有写锁时,其他任何线程(无论是想获取读锁还是写锁)都必须等待。
  3. 互斥关系
    • 读锁和写锁是互斥的。
    • 写锁和写锁是互斥的。

这个设计极大地提高了在读多写少环境下的并发性能。想象一个缓存系统,绝大多数操作是读取数据,只有少数操作是更新数据。如果使用ReentrantLock,那么即使是并发的读操作也必须串行执行,严重影响效率。而使用ReentrantReadWriteLock,多个读操作可以同时进行,只有在写操作时才需要互斥。

2. 底层实现:AQS 与 “状态” 的精妙设计

ReentrantLock一样,ReentrantReadWriteLock也是基于AbstractQueuedSynchronizer (AQS) 实现的。但它的实现更为巧妙,因为它需要在一个int类型的state变量中同时表示读锁和写锁的状态。

AQS 中的 state 是一个32位的int类型。ReentrantReadWriteLock将这32位“劈”成了两半:

  • 高16位:用来表示读锁的持有数量。
  • 低16位:用来表示写锁的重入次数。
java
// ReentrantReadWriteLock.Sync (AQS的子类)
// 伪代码,方便理解
int state; // AQS中的状态变量

// 获取读锁的数量
int readCount = state >>> 16; 

// 获取写锁的重入次数
int writeCount = state & 0x0000FFFF; // 0xFFFF 是 16个1

这个设计是整个实现的核心。通过位运算,它能高效地在一个整型变量中管理两种不同锁的状态。


3. 原理详解

我们分别来看读锁和写锁的获取与释放过程。

A. 写锁的获取与释放 (Exclusive)

写锁是一个完全的排他锁,其逻辑相对简单,和ReentrantLock类似。

获取写锁 (writeLock.lock()):

  1. 检查状态:首先读取state变量。
  2. 判断锁是否空闲
    • 如果state为0,表示没有任何锁被持有。当前线程可以尝试通过 CAS (Compare-And-Set) 操作将state的低16位(写锁计数)设置为1。如果成功,表示获取锁成功,并记录当前线程为锁的持有者。
    • 如果state不为0,说明已经有锁被持有了。此时需要进一步判断:
      • 检查读锁:如果高16位(读锁计数)不为0,意味着有线程持有读锁。当前线程获取写锁失败,进入AQS的等待队列。
      • 检查写锁:如果低16位(写锁计数)不为0,意味着有线程持有写锁。
        • 判断是否重入:检查持有写锁的线程是否是当前线程。如果是,则将state的低16位加1(写锁重入次数+1),获取锁成功。
        • 如果不是当前线程,则获取锁失败,进入AQS的等待队列。

释放写锁 (writeLock.unlock()):

  1. state的低16位减1(写锁重入次数-1)。
  2. 如果减1后,写锁计数变为0,说明锁已完全释放。
  3. 唤醒在AQS队列中等待的下一个线程(可能是读线程,也可能是写线程)。

B. 读锁的获取与释放 (Shared)

读锁是一个共享锁,逻辑比写锁复杂。

获取读锁 (readLock.lock()):

  1. 检查写锁:首先检查state的低16位(写锁计数)。
    • 如果写锁计数大于0,并且持有写锁的线程不是当前线程,那么获取读锁失败,当前线程进入AQS等待队列。
    • 例外情况(锁降级):如果持有写锁的线程当前线程,那么可以成功获取读锁。这是锁降级的基础。
  2. 尝试获取:如果不存在写锁(或者写锁被当前线程持有),当前线程就可以尝试获取读锁。它会通过 CAS 操作将state的高16位加1(读锁计数+1)。
  3. 处理并发:由于多个线程可以同时获取读锁,所以CAS操作可能会失败(因为其他线程也在修改state)。如果失败,会进入一个循环,不断重试,直到成功获取读锁。

释放读锁 (readLock.unlock()):

  1. state的高16位减1(读锁计数-1)。
  2. 这个过程也需要通过 CAS 循环来保证线程安全。
  3. 当读锁计数变为0时,如果AQS队列中有等待的写线程,会将其唤醒。

4. 关键特性

A. 可重入性 (Reentrancy)

  • 写锁可重入:持有写锁的线程可以再次获取写锁。
  • 读锁可重入:持有读锁的线程可以再次获取读锁。
  • 写锁可以重入读锁:持有写锁的线程可以获取读锁。

B. 锁升降级 (Lock Upgrading/Downgrading)

  • 锁降级 (支持):一个线程在持有写锁的情况下,可以继续获取读锁,然后释放写锁。这个过程是安全的。

    • 场景:当你需要更新一块数据,更新完后,需要读取这块数据进行其他操作,并且希望在读取时允许其他读线程进入。
    • 过程getWriteLock -> getReadLock -> releaseWriteLock
  • 锁升级 (不支持):一个线程在持有读锁的情况下,不能直接获取写锁。如果尝试这样做,会导致死锁

    • 原因:假设线程A和线程B都持有读锁,然后都想升级为写锁。它们都会等待对方释放读锁,但对方也在等待,于是形成死锁。因此,ReentrantReadWriteLock不允许这种行为。
    • 正确做法:必须先释放所有读锁,再去竞争写锁。

C. 公平与非公平策略

ReentrantLock一样,ReentrantReadWriteLock也支持公平和非公平两种模式。

  • 非公平锁 (默认):允许插队。无论等待队列中是否有线程,新来的线程都可以尝试获取锁。吞吐量高,但可能导致某些线程“饥饿”。
  • 公平锁:严格按照线程在AQS队列中的顺序来分配锁。可以防止饥饿,但上下文切换开销大,吞吐量较低。
    • 在公平模式下,如果等待队列的头部是一个写线程,那么后来的读线程请求会被阻塞,即使当前没有写锁。这是为了防止写锁饥饿。

5. 潜在问题:写锁饥饿

在“读多写少”且读操作非常频繁的场景下,特别是使用非公平策略时,可能会出现写锁饥饿问题。

原因:当一个写线程正在等待获取锁时,如果不断有新的读线程请求进来,并且它们能够成功获取读锁(因为读锁是共享的),那么写线程就可能一直等待,无法获取到执行机会。

解决方案

  1. 使用公平锁策略。公平锁会保证等待时间最长的线程(无论是读还是写)优先获得锁。
  2. 如果性能要求极高,可以考虑使用 Java 8 引入的 StampedLock,它提供了更复杂的“乐观读”模式,性能更好,但使用也更复杂,且不是可重入的。

6. 代码示例

java
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReentrantReadWriteLock;

class Cache<K, V> {
    private final Map<K, V> map = new HashMap<>();
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    private final ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
    private final ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();

    // 读操作,使用读锁,可以并发执行
    public V get(K key) {
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + " 正在读取...");
            Thread.sleep(100); // 模拟耗时
            return map.get(key);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return null;
        } finally {
            System.out.println(Thread.currentThread().getName() + " 读取完毕。");
            readLock.unlock();
        }
    }

    // 写操作,使用写锁,独占执行
    public V put(K key, V value) {
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + " 正在写入...");
            Thread.sleep(100); // 模拟耗时
            return map.put(key, value);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return null;
        } finally {
            System.out.println(Thread.currentThread().getName() + " 写入完毕。");
            writeLock.unlock();
        }
    }
}

总结

特性 描述
核心原理 基于 AQS,通过将 int state 变量拆分为高16位(读锁)和低16位(写锁)来管理两种锁。
锁类型 读锁(共享)、写锁(排他)。
并发规则 读-读可并发,读-写互斥,写-写互斥。
适用场景 读多写少的高并发场景,如缓存、配置中心等。
关键特性 可重入、支持锁降级、不支持锁升级(会死锁)、支持公平/非公平策略。
潜在问题 在高并发读场景下,可能导致写锁饥饿(尤其是在非公平模式下)。
00:00
00:00