Java 基础体系 · 第 64/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java Lock、ReadWriteLock、StampedLock 与 Condition:语义和陷阱

在 Java 并发中,synchronizedLockReadWriteLockStampedLockCondition 都与“线程安全”有关,但它们解决的问题并不相同:

  • Lock 定义了显式加锁与解锁的协议;
  • ReadWriteLock 将访问区分为读和写;
  • StampedLock 进一步提供乐观读;
  • Condition 将“等待某个状态成立”从锁本身中分离出来。

真正容易出错的地方,不是记住某个 API 的名字,而是理解以下因果关系:

  1. 什么操作建立了互斥;
  2. 什么操作建立了内存可见性;
  3. 线程等待的是哪个条件;
  4. 等待期间是否释放了正确的锁;
  5. 被唤醒后条件是否真的成立;
  6. 当前锁是否允许重入、升级、降级或跨线程解锁。

1. 先建立三个基础概念

1.1 互斥不等于可见性

互斥表示同一时刻最多只有一个线程进入某个临界区。

例如,两个线程执行:

counter++;

这不是一个原子动作,而是大致包含:

读取 counter
计算 counter + 1
写回 counter

如果两个线程同时读取到 0,都计算出 1,最后结果可能是 1 而不是 2

可见性表示一个线程对共享变量的写入,之后能够被另一个线程观察到。

互斥通常也需要解决可见性,否则即使访问顺序看起来没有重叠,线程仍可能读到旧值。

Java 内存模型中的常见同步边:

线程 A 解锁
    happens-before
线程 B 随后成功加锁

因此,线程 A 在解锁前对共享数据的写入,对线程 B 成功获得同一把锁后的读取可见。

这里的“同一把锁”是关键。下面的操作不能因为名字相似就自动建立同步关系:

lockA.unlock();
lockB.lock();

除非 lockAlockB 实际上是同一个同步对象,否则不能据此推导可见性。


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 Locksynchronized 的共同点和差异

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;输出顺序不是该示例的同步契约。真正保证的是:

  1. take() 在空队列时等待;
  2. put() 修改队列后发出 notEmpty 通知;
  3. take() 被唤醒后重新获得锁;
  4. while 再次检查队列;
  5. 只有非空时才执行移除。

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();
}

只有在所有写入 ab 的代码都使用同一套读写锁协议时,读锁才具备保护作用。

如果写入代码直接修改:

a = 1;
b = 2;

而不持有写锁,那么读锁无法阻止写入,也不能自动修复由数据竞争导致的问题。

读写锁保护的是“遵守该锁协议的访问者”,不是变量本身的魔法属性。


4.5 读锁的 Condition

对于 ReentrantReadWriteLock

readLock.newCondition();

不支持创建条件队列,通常会抛出 UnsupportedOperationException

写锁可以创建:

Condition condition = writeLock.newCondition();

这是合理的,因为 Condition.await() 要求释放关联锁并在恢复时重新获得它,而读锁是共享锁,不适合直接作为这种条件等待协议的独占状态保护锁。

如果一个状态需要“读者等待、写者修改、再唤醒读者”,通常应使用写锁关联的 Condition,并在条件检查及状态修改时持有写锁。


5. StampedLock:读写锁加上乐观读

StampedLock 提供三种模式:

  1. 写锁;
  2. 悲观读锁;
  3. 乐观读。
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);
    }
}

这段代码的核心是:

  1. 初始状态是读锁;
  2. 尝试直接转换为写锁;
  3. 转换失败时,先释放旧读锁;
  4. 再获得写锁;
  5. 获得写锁后重新检查条件;
  6. 最终只解锁当前仍持有的那种锁。

不能这样写:

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 等待线程没有继续执行

应检查:

  1. 是否在 while 中等待;
  2. 修改谓词的代码是否持有关联锁;
  3. 修改状态后是否发送了 signalsignalAll
  4. 等待线程是否可能被中断;
  5. 是否错误地在读锁上创建 Condition
  6. 是否存在一个永远不会满足的谓词。

条件等待的正确诊断对象不是“有没有调用 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、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。