Java 基础体系 · 第 64/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java Lock、ReadWriteLock、StampedLock 与 Condition:语义和陷阱
在 Java 并发中,synchronized、Lock、ReadWriteLock、StampedLock 和 Condition 都与“线程安全”有关,但它们解决的问题并不相同:
Lock定义了显式加锁与解锁的协议;ReadWriteLock将访问区分为读和写;StampedLock进一步提供乐观读;Condition将“等待某个状态成立”从锁本身中分离出来。
真正容易出错的地方,不是记住某个 API 的名字,而是理解以下因果关系:
- 什么操作建立了互斥;
- 什么操作建立了内存可见性;
- 线程等待的是哪个条件;
- 等待期间是否释放了正确的锁;
- 被唤醒后条件是否真的成立;
- 当前锁是否允许重入、升级、降级或跨线程解锁。
1. 先建立三个基础概念
1.1 互斥不等于可见性
互斥表示同一时刻最多只有一个线程进入某个临界区。
例如,两个线程执行:
counter++;
这不是一个原子动作,而是大致包含:
读取 counter
计算 counter + 1
写回 counter
如果两个线程同时读取到 0,都计算出 1,最后结果可能是 1 而不是 2。
可见性表示一个线程对共享变量的写入,之后能够被另一个线程观察到。
互斥通常也需要解决可见性,否则即使访问顺序看起来没有重叠,线程仍可能读到旧值。
Java 内存模型中的常见同步边:
线程 A 解锁
happens-before
线程 B 随后成功加锁
因此,线程 A 在解锁前对共享数据的写入,对线程 B 成功获得同一把锁后的读取可见。
这里的“同一把锁”是关键。下面的操作不能因为名字相似就自动建立同步关系:
lockA.unlock();
lockB.lock();
除非 lockA 和 lockB 实际上是同一个同步对象,否则不能据此推导可见性。
1.2 临界区的正确条件
假设共享状态为 S,某个操作需要满足不变式 I(S)。
一个正确的临界区至少应满足:
进入临界区前:I(S) 成立
临界区内:只有当前线程可以修改受保护状态
退出临界区后:I(S') 仍然成立
例如队列的容量限制:
0 <= size <= capacity
生产者增加元素前必须等待:
size < capacity
消费者移除元素前必须等待:
size > 0
注意,“线程被唤醒”不等于“条件成立”。其他线程可能在当前线程重新获得锁之前改变了状态,所以条件必须重新检查。
1.3 加锁成功与加锁失败必须分开处理
显式锁最重要的代码结构是:
lock.lock();
try {
// 临界区
} finally {
lock.unlock();
}
对于可能失败的加锁操作,不能无条件解锁:
boolean acquired = false;
try {
acquired = lock.tryLock(1, TimeUnit.SECONDS);
if (!acquired) {
return;
}
// 只有 acquired == true 时才能访问受保护状态
} finally {
if (acquired) {
lock.unlock();
}
}
否则可能出现:
- 当前线程没有获得锁,却调用
unlock(); - 抛出
IllegalMonitorStateException; - 原本的业务异常被锁协议异常覆盖;
- 在复杂控制流中错误释放了属于外层调用的锁。
2. Lock:显式锁的语义
java.util.concurrent.locks.Lock 是一个接口,不是具体锁算法。它定义了显式锁的基本操作:
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
常用实现是 ReentrantLock。
2.1 lock()、lockInterruptibly() 和 tryLock()
lock()
lock.lock();
try {
// 临界区
} finally {
lock.unlock();
}
lock() 会等待直到获得锁。它不会因为线程在等待期间被中断而放弃加锁,但中断状态可能被保留。
这意味着,如果业务要求“取消任务时立即停止等待”,只使用 lock() 不够。
lockInterruptibly()
try {
lock.lockInterruptibly();
try {
// 临界区
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
// 取消当前操作
}
该方法允许线程在等待锁时响应中断。
典型使用场景是:
- 请求取消;
- 应用关闭;
- 线程池停止;
- 上层调用者要求终止等待。
恢复中断状态通常很重要,因为捕获 InterruptedException 后,中断状态已被清除;如果不恢复,上层代码可能无法继续感知取消信号。
tryLock()
if (lock.tryLock()) {
try {
// 临界区
} finally {
lock.unlock();
}
} else {
// 当前没有立即获得锁
}
tryLock() 不等待,可以用于:
- 避免两个锁之间的永久死锁;
- 在锁竞争严重时降级;
- 实现有限等待或快速失败。
带超时的版本:
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
try {
// 临界区
} finally {
lock.unlock();
}
}
超时不是“锁最终一定会获得”的保证,而是当前调用最多等待指定时间。
2.2 ReentrantLock 的“可重入”是什么
可重入表示同一个线程已经持有锁时,可以再次获得同一把锁:
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
lock.lock();
try {
// 同一个线程再次进入
} finally {
lock.unlock();
}
} finally {
lock.unlock();
}
内部可以把它理解为:
第一次加锁:持有线程 = T,重入次数 = 1
第二次加锁:持有线程 = T,重入次数 = 2
第一次解锁:重入次数 = 1
第二次解锁:重入次数 = 0,锁完全释放
因此,必须让每次成功加锁都对应一次解锁。
下面的代码只解锁一次:
lock.lock();
lock.lock();
try {
// ...
} finally {
lock.unlock();
}
结果是锁仍被当前线程持有,其他线程会持续阻塞。这类错误往往不会立即抛异常,而是表现为后续请求超时或线程池耗尽。
2.3 公平锁不是严格的性能或调度保证
ReentrantLock fairLock = new ReentrantLock(true);
ReentrantLock nonFairLock = new ReentrantLock(false);
公平模式通常倾向于让等待时间更长的线程先获得锁;非公平模式允许新到达的线程直接竞争,可能减少线程切换并提高吞吐量。
但公平锁不代表:
- 所有线程严格按创建顺序执行;
- 不存在饥饿;
tryLock()一定遵循等待队列顺序;- 整个业务系统自动公平。
特别是无超时的 tryLock() 可以直接尝试获取锁,即使公平锁有排队线程。因此,公平性是具体实现和具体方法的行为属性,不能抽象成“所有加锁操作都严格排队”。
除非业务确实需要近似 FIFO 的等待行为,否则不应仅凭直觉选择公平锁。公平锁通常会增加调度和上下文切换成本,但具体代价取决于临界区、线程数和竞争模式,不能用固定数字概括。
2.4 Lock 与 synchronized 的共同点和差异
Lock 实现必须提供与内置监视器锁相同的基本内存同步语义:成功加锁与成功解锁都参与线程间的同步。
二者都可以实现互斥和可见性,但显式锁额外提供:
- 可中断地等待锁;
- 超时获取;
- 非阻塞获取;
- 多个
Condition; - 可选择公平策略;
- 显式控制锁的生命周期。
Lock 同时也带来更多责任:
lock.lock();
// 如果这里在 try 之前抛出异常,可能导致无法进入 finally
try {
// ...
} finally {
lock.unlock();
}
正确结构应让 lock() 成功后立即进入保护范围:
lock.lock();
try {
// ...
} finally {
lock.unlock();
}
3. Condition:等待的是谓词,不是通知
Condition 是与 Lock 关联的等待队列。它可以看作显式锁版本的 wait() / notify() 机制。
创建方式:
ReentrantLock lock = new ReentrantLock();
Condition notEmpty = lock.newCondition();
Condition notFull = lock.newCondition();
一个 Condition 必须属于某个 Lock。线程调用 await()、signal() 和 signalAll() 时,必须遵循该锁的持有规则。
3.1 await() 的原子过程
线程执行:
condition.await();
其逻辑不是简单的“睡眠”,而是一个原子协议:
1. 当前线程必须持有关联锁
2. 将自己加入 Condition 等待队列
3. 原子地释放关联锁
4. 线程进入等待
5. 被 signal、signalAll、中断或伪唤醒
6. 重新竞争并获得关联锁
7. await() 返回或抛出 InterruptedException
第 3 步和第 4 步之间必须具有原子性。否则会发生丢失通知:
线程 A 检查条件:条件不成立
线程 B 修改条件并发送通知
线程 A 此时才进入等待
如果“检查条件”和“加入等待”不是正确同步的,线程 A 可能永远错过线程 B 的通知。
3.2 为什么必须使用 while,而不是 if
错误写法:
lock.lock();
try {
if (queue.isEmpty()) {
notEmpty.await();
}
return queue.removeFirst();
} finally {
lock.unlock();
}
正确写法:
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await();
}
return queue.removeFirst();
} finally {
lock.unlock();
}
原因有三层。
第一层:伪唤醒
等待线程可能在没有对应业务通知的情况下返回。并发 API 通常允许这种行为,因此调用者必须重新检查条件。
第二层:多个等待者竞争同一资源
假设队列从空变为只有一个元素:
消费者 C1 等待
消费者 C2 等待
生产者放入一个元素
生产者 signalAll()
C1 和 C2 都被唤醒
C1 先获得锁并取走元素
C2 随后获得锁
C2 被唤醒时,queue.isEmpty() 已经重新变为 true。如果使用 if,C2 会错误地继续执行 removeFirst()。
第三层:条件可能在业务代码中再次失效
被唤醒只表示“有理由重新检查”,不表示“当前线程已经获得资源”。
可以用逻辑形式表示:
await 返回 => 重新获得锁
await 返回 ≠ 谓词 P 一定成立
正确协议是:
持有锁
while (!P) {
await
}
执行依赖 P 的操作
释放锁
3.3 signal() 不会立即把执行权交给等待线程
lock.lock();
try {
state = READY;
condition.signal();
} finally {
lock.unlock();
}
signal() 的作用是让一个等待线程具备重新竞争锁的机会。当前线程仍然持有锁,因此被通知线程不能立刻进入临界区。
完整时序如下:
线程 A:持有锁,发现条件不成立
线程 A:await(),释放锁并等待
线程 B:获得锁,修改状态使条件可能成立
线程 B:signal()
线程 B:仍持有锁,继续执行
线程 B:unlock()
线程 A:重新竞争锁
线程 A:获得锁
线程 A:while 条件检查
线程 A:条件成立后继续
如果状态修改发生在 signal() 之后,等待线程仍可能醒来,但它重新检查时看到的状态未必正确。因此,通常先修改受保护状态,再通知等待者。
3.4 signal() 还是 signalAll()
signal() 只唤醒一个等待线程,适用于确定只有一个等待者能够继续的场景。
signalAll() 唤醒所有等待线程,所有线程之后仍需重新竞争锁并检查条件。它可能带来“惊群”,但更不容易因错误选择唤醒对象而留下永久等待。
例如有两个条件:
Condition notEmpty = lock.newCondition();
Condition notFull = lock.newCondition();
生产者只应通知 notEmpty,消费者只应通知 notFull:
// 生产者成功放入元素
notEmpty.signal();
// 消费者成功取出元素
notFull.signal();
如果把所有线程都放在同一个 Condition 上,虽然也可以通过 signalAll() 保证正确性,但会唤醒大量暂时无法继续的线程。
3.5 一个可运行的 Condition 示例
下面是一个有界阻塞队列。它维护两个条件:
notEmpty:队列中至少有一个元素;notFull:队列中至少有一个空位。
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class ConditionQueueDemo {
static final class BoundedQueue<T> {
private final int capacity;
private final Deque<T> deque = new ArrayDeque<>();
private final ReentrantLock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition();
private final Condition notFull = lock.newCondition();
BoundedQueue(int capacity) {
if (capacity <= 0) {
throw new IllegalArgumentException("capacity must be positive");
}
this.capacity = capacity;
}
void put(T value) throws InterruptedException {
lock.lockInterruptibly();
try {
while (deque.size() == capacity) {
notFull.await();
}
deque.addLast(value);
notEmpty.signal();
} finally {
lock.unlock();
}
}
T take() throws InterruptedException {
lock.lockInterruptibly();
try {
while (deque.isEmpty()) {
notEmpty.await();
}
T value = deque.removeFirst();
notFull.signal();
return value;
} finally {
lock.unlock();
}
}
T poll(long timeout, TimeUnit unit) throws InterruptedException {
long nanos = unit.toNanos(timeout);
lock.lockInterruptibly();
try {
while (deque.isEmpty()) {
if (nanos <= 0) {
return null;
}
nanos = notEmpty.awaitNanos(nanos);
}
T value = deque.removeFirst();
notFull.signal();
return value;
} finally {
lock.unlock();
}
}
}
public static void main(String[] args) throws Exception {
BoundedQueue<Integer> queue = new BoundedQueue<>(1);
Thread consumer = new Thread(() -> {
try {
System.out.println("take = " + queue.take());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread producer = new Thread(() -> {
try {
queue.put(42);
System.out.println("put = 42");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
consumer.start();
Thread.sleep(100);
producer.start();
producer.join();
consumer.join();
}
}
一种可能输出为:
put = 42
take = 42
也可能由于调度原因先看到 take = 42,再看到 put = 42;输出顺序不是该示例的同步契约。真正保证的是:
take()在空队列时等待;put()修改队列后发出notEmpty通知;take()被唤醒后重新获得锁;while再次检查队列;- 只有非空时才执行移除。
awaitNanos 返回剩余等待时间,因此必须在循环中更新 nanos。如果只等待一次,伪唤醒或提前返回可能使总等待时间小于调用者要求的超时时间。
3.6 Condition 的常见错误
未持有锁就调用
condition.signal();
如果当前线程没有持有关联锁,通常会抛出 IllegalMonitorStateException。
在错误的锁上检查条件
synchronized (otherLock) {
while (queue.isEmpty()) {
condition.await();
}
}
条件、状态和锁必须组成同一个协议。检查 queue.isEmpty() 时必须持有保护 queue 的那把锁。
忘记处理中断
try {
condition.await();
} catch (InterruptedException ignored) {
}
这会吞掉取消信号。除非业务明确决定终止中断语义,否则应恢复中断状态或继续向上抛出。
4. ReadWriteLock:把共享访问分成读和写
ReadWriteLock 的抽象包含两把关联的锁:
Lock readLock();
Lock writeLock();
它表达的访问规则通常是:
多个读者可以并发
写者独占
读者与写者互斥
可以用状态模型表示:
读锁计数 = 0,写锁未持有:可获得读锁或写锁
读锁计数 > 0,写锁未持有:其他读者可进入,写者必须等待
写锁由线程 W 持有:读者和其他写者都必须等待
但 ReadWriteLock 接口本身并不规定:
- 是否可重入;
- 是否公平;
- 读者或写者的优先级;
- 是否允许锁降级;
- 是否支持读锁的
Condition。
这些是具体实现的属性。
4.1 ReentrantReadWriteLock
常用实现是:
ReentrantReadWriteLock rwLock =
new ReentrantReadWriteLock();
Lock readLock = rwLock.readLock();
Lock writeLock = rwLock.writeLock();
读操作:
readLock.lock();
try {
return value;
} finally {
readLock.unlock();
}
写操作:
writeLock.lock();
try {
value = newValue;
} finally {
writeLock.unlock();
}
它允许同一线程重复获得读锁,也允许写线程重新获得写锁。
4.2 写锁降级是允许的
锁降级表示:
先获得写锁
在仍持有写锁时获得读锁
释放写锁
继续持有读锁
典型代码:
writeLock.lock();
try {
refreshCache();
readLock.lock();
try {
// 现在仍然持有读锁
} finally {
readLock.unlock();
}
} finally {
writeLock.unlock();
}
更常见的形式是更新完成后保留读锁:
writeLock.lock();
try {
if (needsRefresh()) {
refreshCache();
}
readLock.lock();
} finally {
writeLock.unlock();
}
try {
useCache();
} finally {
readLock.unlock();
}
时序为:
获得写锁
修改共享状态
获得读锁
释放写锁
其他读者可以进入
当前线程继续以读者身份读取
最终释放读锁
为什么必须先获得读锁再释放写锁?
如果顺序反过来:
释放写锁
准备获得读锁
此时其他写线程可能插入并修改状态,当前线程获得读锁后读到的已经不是自己刚刚更新的连续状态。
4.3 读锁升级通常会死锁
锁升级表示:
先持有读锁,再尝试获得写锁
示例:
readLock.lock();
try {
if (needsRefresh()) {
writeLock.lock(); // 危险
try {
refreshCache();
} finally {
writeLock.unlock();
}
}
} finally {
readLock.unlock();
}
假设线程 T1 和 T2 都持有读锁:
T1 持有读锁,等待写锁
T2 持有读锁,等待写锁
写锁要求所有读锁释放,但 T1 必须获得写锁后才会释放自己的读锁,T2 也一样,于是形成循环等待。
形式化地说:
写锁可获得条件:activeReaders == 0
T1 的释放条件:T1 获得写锁
T1 的获得条件:activeReaders == 0
而 activeReaders 至少包含 T1 自己,因此条件无法成立。
安全做法通常是先释放读锁,再重新竞争写锁,并重新检查条件:
readLock.lock();
try {
if (!needsRefresh()) {
return;
}
} finally {
readLock.unlock();
}
writeLock.lock();
try {
if (needsRefresh()) {
refreshCache();
}
} finally {
writeLock.unlock();
}
第二次检查不能省略,因为从释放读锁到获得写锁之间,其他线程可能已经完成了更新。
4.4 读锁不是“无锁读取”
以下代码并不安全:
readLock.lock();
try {
int x = a;
int y = b;
return x + y;
} finally {
readLock.unlock();
}
只有在所有写入 a 和 b 的代码都使用同一套读写锁协议时,读锁才具备保护作用。
如果写入代码直接修改:
a = 1;
b = 2;
而不持有写锁,那么读锁无法阻止写入,也不能自动修复由数据竞争导致的问题。
读写锁保护的是“遵守该锁协议的访问者”,不是变量本身的魔法属性。
4.5 读锁的 Condition
对于 ReentrantReadWriteLock:
readLock.newCondition();
不支持创建条件队列,通常会抛出 UnsupportedOperationException。
写锁可以创建:
Condition condition = writeLock.newCondition();
这是合理的,因为 Condition.await() 要求释放关联锁并在恢复时重新获得它,而读锁是共享锁,不适合直接作为这种条件等待协议的独占状态保护锁。
如果一个状态需要“读者等待、写者修改、再唤醒读者”,通常应使用写锁关联的 Condition,并在条件检查及状态修改时持有写锁。
5. StampedLock:读写锁加上乐观读
StampedLock 提供三种模式:
- 写锁;
- 悲观读锁;
- 乐观读。
import java.util.concurrent.locks.StampedLock;
每次获得锁或启动乐观读,都会返回一个 long stamp。这个 stamp 是后续验证或解锁所需的凭证。
5.1 写锁和悲观读锁
写锁:
long stamp = lock.writeLock();
try {
// 独占修改
} finally {
lock.unlockWrite(stamp);
}
悲观读锁:
long stamp = lock.readLock();
try {
// 读期间阻止写者
} finally {
lock.unlockRead(stamp);
}
与 ReentrantReadWriteLock 的读锁类似,多个悲观读者可以并发,写者与读者互斥。
但是 StampedLock 不是可重入锁。同一线程持有写锁后再次调用 writeLock(),不能按照 ReentrantLock 的规则安全地嵌套。
因此下面的结构可能永久等待:
long first = lock.writeLock();
try {
long second = lock.writeLock(); // 不能假设可重入
} finally {
lock.unlockWrite(first);
}
如果调用链中多个方法都需要锁,必须明确锁的所有权边界,不能把 StampedLock 当作 ReentrantLock 替换品。
5.2 乐观读不是读锁
乐观读通过:
long stamp = lock.tryOptimisticRead();
立即返回一个 stamp。它不阻塞写者,也不保证读取期间状态不会变化。
正确模式是:
long stamp = lock.tryOptimisticRead();
int x = state.x;
int y = state.y;
if (!lock.validate(stamp)) {
stamp = lock.readLock();
try {
x = state.x;
y = state.y;
} finally {
lock.unlockRead(stamp);
}
}
return x + y;
完整因果如下:
1. tryOptimisticRead() 得到版本凭证
2. 读取所需字段到局部变量
3. validate(stamp)
4. 验证成功:读取期间没有冲突写入,使用局部快照
5. 验证失败:重新获得悲观读锁并重读
关键点是:validate 必须发生在所有相关字段读取之后。
错误顺序:
long stamp = lock.tryOptimisticRead();
if (lock.validate(stamp)) {
int x = state.x;
int y = state.y;
return x + y;
}
这里验证发生在读取之前,不能证明后面的读取期间没有写入。
5.3 乐观读的完整算例
假设写线程更新两个相关字段:
state.x = 10;
state.y = 20;
读取线程执行:
t0:读线程获得乐观 stamp
t1:读线程读取 x = 10
t2:写线程获得写锁,修改 x 和 y
t3:写线程释放写锁
t4:读线程读取 y
t5:读线程 validate(stamp) 失败
虽然读线程可能已经读取到了混合状态,但它不能使用这个结果,因为验证失败。
如果使用悲观读锁:
t0:读线程获得读锁
t1:写线程等待
t2:读线程读取 x 和 y
t3:读线程释放读锁
t4:写线程获得写锁
则读取期间写线程不能介入。
乐观读适合以下性质的数据:
- 读取很短;
- 写入相对少;
- 读取失败后可以重新读取;
- 读取结果允许在验证失败时丢弃。
它不适合直接执行具有外部副作用的操作:
long stamp = lock.tryOptimisticRead();
sendNetworkRequest(state.x); // 错误风险
if (!lock.validate(stamp)) {
// 请求已经发送,无法撤销
}
乐观读失败后只能安全地丢弃局部读取结果,不能撤销已经执行的业务动作。
5.4 StampedLock 的锁转换
StampedLock 提供转换方法,例如:
long writeStamp = lock.tryConvertToWriteLock(stamp);
转换结果有两种:
非 0:转换成功,返回新的有效 write stamp
0:转换失败,原有锁状态仍需按原规则处理
正确处理必须区分成功和失败:
long stamp = lock.readLock();
try {
if (needsUpdate()) {
long writeStamp = lock.tryConvertToWriteLock(stamp);
if (writeStamp != 0L) {
stamp = writeStamp;
update();
} else {
lock.unlockRead(stamp);
stamp = lock.writeLock();
try {
if (needsUpdate()) {
update();
}
} finally {
lock.unlockWrite(stamp);
stamp = 0L;
}
}
}
} finally {
if (stamp != 0L) {
lock.unlockRead(stamp);
}
}
这段代码的核心是:
- 初始状态是读锁;
- 尝试直接转换为写锁;
- 转换失败时,先释放旧读锁;
- 再获得写锁;
- 获得写锁后重新检查条件;
- 最终只解锁当前仍持有的那种锁。
不能这样写:
long writeStamp = lock.tryConvertToWriteLock(readStamp);
lock.unlockWrite(writeStamp);
因为转换可能失败并返回 0。失败结果不是一个可用的写锁凭证。
5.5 StampedLock 的 stamp 不是线程所有权
ReentrantLock 通常要求“持有锁的线程”执行解锁;StampedLock 的解锁操作主要依赖正确的 stamp,而不是可重入所有权模型。
这不表示可以随意把 stamp 当作普通数据传递。错误使用 stamp 仍会导致:
- 解锁错误的模式;
- 重复解锁;
- 使用过期 stamp;
- 状态破坏或抛出异常。
stamp 应当与一次具体的加锁生命周期绑定,并在 finally 中按对应模式释放。
5.6 StampedLock 不提供 Condition
StampedLock 本身不提供 newCondition()。它也没有直接对应 Condition 的等待队列。
如果一个状态既需要:
- 读写访问控制;
- 又需要等待“队列非空”“任务完成”等谓词;
通常需要额外使用:
ReentrantLock + Condition;synchronized + wait/notify;- 或其他专门的并发工具。
不要因为 StampedLock 有“读锁”就假设它能够直接替代 ReadWriteLock 的全部能力。
6. 三类锁的选择逻辑
可以将选择过程还原成几个问题。
6.1 只需要互斥,是否可以用 synchronized
如果需求只是:
一个临界区
不需要超时
不需要可中断等待
不需要多个条件队列
synchronized 往往更直接。
如果需要显式锁的附加能力,再考虑 ReentrantLock。
6.2 读操作很多,写操作很少,是否应该使用 ReadWriteLock
不能仅因为“读多写少”就自动使用读写锁。
读写锁增加了:
- 锁状态管理;
- 读者计数;
- 写者排队;
- 读写策略;
- 可能的降级和升级复杂度。
如果读操作非常短,普通互斥锁可能更简单,实际吞吐也可能更好。
ReadWriteLock 的基本收益来自:
多个真正独立的读操作可以并发
写操作足够少且足够重
如果读操作本身会调用写入方法、触发回调或执行外部副作用,那么“读锁共享”可能并不符合实际语义。
6.3 是否适合 StampedLock 的乐观读
需要同时满足:
读取短
写入相对少
读取结果可以被验证
验证失败可以重试
读取期间不执行不可撤销副作用
否则使用悲观读锁或普通锁更容易证明正确性。
乐观读的本质不是“无锁且安全”,而是:
先允许冲突发生
再检测这次读取是否可能受冲突影响
失败则丢弃并重试
这是验证式并发,而不是传统互斥。
7. 一个对比示例:保护一个可变点
下面的类展示了三种读取方式。两个坐标必须作为一个一致快照读取。
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.concurrent.locks.StampedLock;
public class PointLocks {
private int x;
private int y;
private final ReentrantReadWriteLock rw =
new ReentrantReadWriteLock();
private final StampedLock stamped =
new StampedLock();
public void moveWithReadWriteLock(int dx, int dy) {
rw.writeLock().lock();
try {
x += dx;
y += dy;
} finally {
rw.writeLock().unlock();
}
}
public int distanceSquaredWithReadWriteLock() {
rw.readLock().lock();
try {
return x * x + y * y;
} finally {
rw.readLock().unlock();
}
}
public void moveWithStampedLock(int dx, int dy) {
long stamp = stamped.writeLock();
try {
x += dx;
y += dy;
} finally {
stamped.unlockWrite(stamp);
}
}
public int distanceSquaredOptimistic() {
long stamp = stamped.tryOptimisticRead();
int localX = x;
int localY = y;
if (!stamped.validate(stamp)) {
stamp = stamped.readLock();
try {
localX = x;
localY = y;
} finally {
stamped.unlockRead(stamp);
}
}
return localX * localX + localY * localY;
}
}
这个例子成立的前提是:同一组字段的所有写入都遵守同一个 StampedLock 协议,所有需要一致快照的读取也遵守对应协议。
如果另一个方法直接写:
x += dx;
y += dy;
那么上述锁保护就被破坏了。锁只对遵守协议的代码有效。
8. 失败表现与诊断方法
8.1 线程长期阻塞
常见原因:
- 某条异常路径没有执行
unlock(); - 可重入锁加锁两次但只解锁一次;
- 读锁升级导致循环等待;
- 在持锁期间调用了等待当前线程完成的操作;
Condition的等待条件永远不会再次成立。
诊断时应查看线程转储中的状态:
BLOCKED
WAITING
TIMED_WAITING
但状态本身不能直接说明是哪个锁的问题。需要结合线程栈查看:
- 阻塞在哪个
lock(); - 等待在哪个
Condition.await(); - 当前线程是否仍持有其他锁;
- 是否形成 A 等待 B、B 等待 A 的环。
8.2 IllegalMonitorStateException
常见原因包括:
lock.unlock(); // 当前线程没有成功获得锁
readWriteLock.writeLock().unlock(); // 实际持有的是读锁
condition.signal(); // 未持有关联锁
对于 StampedLock,还可能是:
long stamp = lock.readLock();
lock.unlockWrite(stamp); // 用读锁 stamp 解锁写锁
每种锁都必须使用与获得操作匹配的释放操作:
ReentrantLock.lock() -> unlock()
readLock() -> unlockRead(stamp)
writeLock() -> unlockWrite(stamp)
tryOptimisticRead() -> validate(stamp),不是 unlock
8.3 读到了不一致的数据
可能原因:
- 使用
StampedLock乐观读时没有调用validate; - 先验证再读取;
- 只保护了部分字段;
- 某些写入路径绕过了锁;
- 把多个独立读取误认为一个原子快照。
如果两个字段必须保持关系:
x 和 y 必须来自同一次更新
就必须让它们位于同一个锁协议和同一个验证区间内。
8.4 等待线程没有继续执行
应检查:
- 是否在
while中等待; - 修改谓词的代码是否持有关联锁;
- 修改状态后是否发送了
signal或signalAll; - 等待线程是否可能被中断;
- 是否错误地在读锁上创建
Condition; - 是否存在一个永远不会满足的谓词。
条件等待的正确诊断对象不是“有没有调用 signal”,而是完整的状态转移:
等待前条件是什么?
哪个线程会改变它?
改变是否发生在正确的锁保护下?
改变后哪个等待队列会被通知?
被通知线程重新检查时条件是否仍可能成立?
9. 规范保证、实现行为和经验判断要分开
9.1 规范层面的保证
Java 并发锁 API 明确规定或要求的核心语义包括:
- 成功加锁与解锁参与内存同步;
Condition.await()释放关联锁并在恢复前重新获得;- 条件等待可能发生伪唤醒,因此应循环检查条件;
StampedLock.validate()用于判断乐观读取期间是否发生了使 stamp 失效的写入;StampedLock不提供可重入语义。
这些是编写正确程序时可以依赖的契约。
9.2 实现层面的行为
以下行为不应随意当成所有实现的共同保证:
- 具体锁的排队顺序;
- 读者和写者谁优先;
- 公平模式的精确调度顺序;
- 某个锁在特定竞争强度下的吞吐量;
- 线程被唤醒后的实际运行顺序。
例如,ReadWriteLock 只定义读锁和写锁的抽象关系,具体实现可以选择不同的排队策略。
9.3 经验层面的建议
以下属于工程取舍,而非语言或 API 的绝对规则:
- 临界区尽量短;
- 不要在持锁时执行不受控的外部调用;
- 使用
try/finally管理释放; - 对可取消操作优先考虑可中断加锁;
- 使用读写锁前确认读操作确实可以并发;
- 使用乐观读前确认读取可以重试且没有不可撤销副作用。
这些建议的共同目标是减少锁协议的状态空间,使程序更容易证明和诊断。
10. 最容易混淆的结论
Lock 不是自动释放的锁
离开代码块不会自动调用 unlock()。必须显式使用 finally。
Condition.signal() 不是“把锁交给对方”
它只是使等待线程有机会重新竞争锁。当前线程释放锁前,被通知线程不能继续执行临界区。
被唤醒不等于条件成立
必须使用:
while (!predicate()) {
condition.await();
}
而不是:
if (!predicate()) {
condition.await();
}
读锁不能升级为写锁
在 ReentrantReadWriteLock 中,读锁升级可能造成死锁。安全方案是释放读锁、获得写锁、重新检查条件。
写锁可以降级为读锁,但顺序不能反
应先获得读锁,再释放写锁。
StampedLock 不是可重入锁
不能把它当作 ReentrantLock 使用,也不能假设同一线程重复获得同一种锁会成功。
乐观读不是普通读锁
它允许写入并发发生,必须在读取结束后验证;验证失败时只能丢弃读取结果并重试。
锁只保护遵守协议的访问
任何绕过锁的读取或写入,都可能破坏互斥、可见性或数据一致性。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java synchronized 与 Monitor:锁升级、等待通知、可见性和死锁
- 下一篇:Java Atomic 与 VarHandle:CAS、ABA、内存顺序和无锁边界
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论