可重入读写锁(ReentrantReadWriteLock)

40 阅读4分钟

核心概念

  • 读锁(ReadLock, 共享) :允许多个线程同时持有,互不阻塞;与写锁互斥。

  • 写锁(WriteLock, 独占) :同一时刻只允许一个线程持有;与任何读/写互斥。

  • 可重入:同一线程可多次获取自己已持有的锁(计数+1 / -1 成对出现)。

  • 公平/非公平:默认非公平(吞吐高),可 new ReentrantReadWriteLock(true) 选择公平(减少饥饿,吞吐下降)。

  • 可中断/可超时:支持 lockInterruptibly()、tryLock(timeout)。

  • 条件队列:只支持 写锁 上的 newCondition();读锁不支持条件变量(调用会抛异常)。

使用场景

  • 读多写少的共享数据结构:配置、缓存、映射表、白名单等。

  • 写入时要求强一致,读取要无锁并发尽量高。

正确用法模板(Kotlin 版)

Kotlin 标准库已提供扩展(kotlin.concurrent):

import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.read
import kotlin.concurrent.write

class SafeCache<K, V> {
    private val lock = ReentrantReadWriteLock()
    private val map = HashMap<K, V>()

    fun get(key: K): V? = lock.read { map[key] }

    fun put(key: K, value: V) = lock.write { map[key] = value }

    // 典型“读多写少”的双查+降级模式
    fun getOrCompute(key: K, loader: (K) -> V): V {
        // 第1次读
        lock.read {
            map[key]?.let { return it }
        }
        // 升级不可直接做(会死锁),改为释放读->获取写
        lock.write {
            // 第2次查,避免“写期间被他人填充”的竞态
            map[key]?.let { return it }
            val v = loader(key)
            map[key] = v
            // 可选择“降级”为读锁:
            // lock.readLock().lock();  // 已持有写锁时可以获取读锁
            // try { ... 使用只读视图 ... } finally { lock.readLock().unlock() }
            return v
        }
    }
}

升级与降级

  • 锁降级(支持) :写锁 →(先获取读锁)→ 释放写锁。作用:在写入后,继续以读锁保护稳定视图。
lock.write {
    // mutate
    lock.readLock().lock()
}
lock.writeLock().unlock()
try {
    // read under read-lock
} finally {
    lock.readLock().unlock()
}
  • 锁升级(不支持) :持有读锁的线程再去获取写锁,会 潜在死锁。正确做法是:释放读锁 → tryWrite 获取写锁(可带超时) → 成功则继续,否则回退。

公平性与饥饿

  • 非公平(默认) :写锁可能“插队”,吞吐高;极端情况下读线程可能被写线程频繁打断。

  • 公平:严格按队列顺序,避免饥饿;代价是上下文切换多、吞吐低。

    选择建议:首选非公平,只有在确实观察到饥饿时再切公平。

中断与超时

  • IO/用户取消/关闭时常用:
lock.readLock().lockInterruptibly()
// 或者
if (lock.writeLock().tryLock(50, TimeUnit.MILLISECONDS)) { ... }

条件变量

  • 只在 写锁 上可用:
val cond = lock.writeLock().newCondition()
lock.write {
    while (!ready) cond.await()     // await()/signal() 必须在写锁内部
}
  • 读锁上 newCondition() 会抛 UnsupportedOperationException。

与synchronized的对比

  • 优势:读多写少时,读并发 → 更高吞吐;可中断/可超时/可选公平。

  • 劣势:实现复杂;在写多或竞争激烈时可能不如 synchronized 简洁且性能优势不明显;错误用法(升级)易死锁。

源码/实现原理要点(AQS)

  • 基于 AQS(AbstractQueuedSynchronizer)

  • state 为 32 位整型:高 16 位记录读锁计数,低 16 位记录写锁重入计数

    • exclusiveCount = state & 0xFFFF
    • sharedCount = state >>> 16
  • 读锁用 共享模式 入队;写锁用 独占模式 入队。

  • 为降低读侧线程本地计数开销,JDK内部有 firstReader/firstReaderHoldCount 的小优化和 ThreadLocal 的 HoldCounter。

常见坑位清单

  1. 读锁升级写锁:直接 read→write 会互等,出现死锁风险。务必释放读锁后再 tryWrite,必要时加超时。

  2. 在主线程阻塞(Android):lock() 会阻塞线程,不要放在主线程或可能导致 ANR 的路径。用 Dispatchers.IO 或业务层拆分。

  3. 误用条件变量:只在写锁上用 Condition;读锁不支持。

  4. 读-写混乱:逻辑上有写入副作用的操作必须用写锁(例如懒加载时的 put),不要在读锁里偷偷改数据。

  5. 没成对解锁:由于可重入计数,漏解一次会“永远持锁”。务必使用 try/finally。

与协程的配合(Android/Kotlin)

  • ReentrantReadWriteLock 是阻塞式锁,不是挂起原语。与协程配合时:

    • 在 withContext(Dispatchers.IO) 中执行阻塞临界区;
    • 或改用协程原语(Mutex、Atomic*、Channel)尽量避免阻塞线程。
  • 如果必须用读写锁,可提供 挂起友好 包装:

suspend fun <T> ReentrantReadWriteLock.readSuspend(block: () -> T): T =
    withContext(Dispatchers.IO) { read(block) }

suspend fun <T> ReentrantReadWriteLock.writeSuspend(block: () -> T): T =
    withContext(Dispatchers.IO) { write(block) }

何时考虑StampedLock

  • 读密集且对可重入无要求,或希望“乐观读”(几乎无阻塞读取→失败再回退加重锁):

    • StampedLock 更快,但 不可重入、不支持条件变量、API 更复杂,使用不当也更容易出错。
  • 典型模式:

    • 先 tryOptimisticRead() 拿戳并读 → validate() 成功则返回;失败则 readLock() 重试。

调参与诊断建议

  • 先从简单的 synchronized/Mutex 开始,用性能/Trace 证明读争用是瓶颈,再切换到读写锁。
  • 观察指标:等待时间、锁竞争次数、长尾请求(P99+)、是否存在写饥饿。
  • Android 上用 Perfetto/Systrace 或业务埋点统计锁等待。