核心概念
-
读锁(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。
常见坑位清单
-
读锁升级写锁:直接 read→write 会互等,出现死锁风险。务必释放读锁后再 tryWrite,必要时加超时。
-
在主线程阻塞(Android):lock() 会阻塞线程,不要放在主线程或可能导致 ANR 的路径。用 Dispatchers.IO 或业务层拆分。
-
误用条件变量:只在写锁上用 Condition;读锁不支持。
-
读-写混乱:逻辑上有写入副作用的操作必须用写锁(例如懒加载时的 put),不要在读锁里偷偷改数据。
-
没成对解锁:由于可重入计数,漏解一次会“永远持锁”。务必使用 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 或业务埋点统计锁等待。