Skip to content

ReentrantLocksynchronized 有个共同点:同一时刻只允许一个线程进。可大量场景里,读和读之间根本不冲突——十个线程同时读一份配置、一个缓存被反复查询,互不干扰,凭什么排队?读写锁就是为"读多写少"准备的:读和读共享,只要有写就互斥。这篇梳理读写锁的规则、锁降级,以及 JDK 8 加的进阶版 StampedLock(乐观读,连读锁都可以不加)。

锁的全景地图在《锁的分类与实现原理》,ReentrantLock 的四个进阶能力在《ReentrantLock 详解》,底层原理看《AQS 原理详解》。


一、解决什么问题:读读也互斥太浪费

先看问题的量级。假设一个系统 99% 的操作是读配置,1% 是改配置,用 ReentrantLock

线程1 读 ──┐
线程2 读 ──┼── 全部排队,一个一个进 —— 其实它们谁也不碍着谁
线程3 读 ──┘

读操作不改数据,100 个线程同时读也不会出任何问题,但排队的代价全付了。读写锁的解法是把锁拆成两把:

规则读锁写锁
读锁✅ 共享(读读不互斥)❌ 互斥
写锁❌ 互斥❌ 互斥

一句话:只有读和读能共享,其余组合全部互斥

二、基本用法

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

public class Config {
    private String value;
    private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

    public String get() {
        rwl.readLock().lock();       // 读锁:大家共享
        try {
            return value;
        } finally {
            rwl.readLock().unlock();
        }
    }

    public void set(String newValue) {
        rwl.writeLock().lock();      // 写锁:独占
        try {
            value = newValue;
        } finally {
            rwl.writeLock().unlock();
        }
    }
}

注意几个点:

  • 读锁/写锁不是两把独立的锁,是同一把锁的两个视图,内部状态统一管理(写线程数 + 读线程数)。
  • ReentrantLock 一样是显式锁,unlock 必须放 finally
  • 可重入:读线程可以重复拿读锁,写线程可以重复拿写锁。
  • 读写锁也有公平/非公平模式,默认非公平。

这些行为都实跑验证过:两个读线程 readLock 后各自 sleep 300ms,实际并发数是 2(读读不互斥);读锁持有期间 writeLock.tryLock 超时失败(读写互斥)。

三、锁降级可以,锁升级不行

这是读写锁最容易被追问的一个知识点。

锁降级(写 → 读):持有写锁时,可以直接拿读锁,然后释放写锁——从"独占"平滑过渡到"共享":

java
rwl.writeLock().lock();     // 1. 先拿写锁(独占,改数据)
try {
    data = newData;          // 2. 改完数据
    rwl.readLock().lock();   // 3. 写锁内直接拿读锁(这步允许)
} finally {
    rwl.writeLock().unlock(); // 4. 释放写锁,此刻手里还剩读锁
}
// 5. 之后读自己的数据,不会被其他写线程打断

为什么要降级?典型场景是缓存刷新:写完最新数据后,接下来还要连续读一段时间。如果不降级、先把写锁放掉再拿读锁,中间有个"无锁空隙",别的写线程可能趁机改数据,你后面读到的就不是自己刚写的那份了。降级保证了"我写完之后、读到的一定是我写的"(这就是安全发布)。

锁升级(读 → 读+写):反过来不行。持有读锁时去拿写锁,会永远阻塞——你拿着读锁等写锁,别的读线程(也包括你自己)占着读锁不撒手,写锁永远等不到,直接死锁。实跑验证过:读锁内 writeLock.tryLock(200ms) 返回 false(本线程自己挡自己)。需要写,先把读锁放掉再拿写锁。

四、StampedLock:乐观读,读连锁都不加

ReentrantReadWriteLock 已经比普通锁高效了,但它读的时候还是要真加锁(CAS 改读计数、登记线程)。JDK 8 的 StampedLock 走得更远:读的时候可以完全不加锁,读之前拿个"戳"(stamp),读完验一下"这期间没人写过",没写过就白嫖成功,写过就重试或升级悲观读。

三种模式:

java
import java.util.concurrent.locks.StampedLock;

public class Point {
    private double x, y;
    private final StampedLock sl = new StampedLock();

    // 写:独占(和写锁一样)
    public void move(double deltaX, double deltaY) {
        long stamp = sl.writeLock();
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            sl.unlockWrite(stamp);
        }
    }

    // 乐观读:不加锁,读完验证
    public double distanceFromOrigin() {
        long stamp = sl.tryOptimisticRead();   // 1. 拿个戳,不阻塞任何人
        double curX = x, curY = y;             // 2. 脆弱读:此刻读到的值
        if (!sl.validate(stamp)) {             // 3. 验戳:读的时候有人写过吗?
            // 4a. 验证失败 → 升级悲观读(老老实实拿读锁重读一遍)
            stamp = sl.readLock();
            try {
                curX = x;
                curY = y;
            } finally {
                sl.unlockRead(stamp);
            }
        }
        // 4b. 验证成功 → curX/curY 就是一致的数据,直接用
        return Math.sqrt(curX * curX + curY * curY);
    }
}

乐观读的流程画成图:

StampedLock 乐观读流程

理解乐观读的关键:它没有"锁"的概念,只有"版本验证"tryOptimisticRead 返回一个基于当前写状态算出的戳;读操作期间不管有没有别的线程来,validate 只检查"写状态变过没有"。没变过,说明读的这份数据和写状态是同一版本,虽然中途理论上有人可能开始写,但只要写锁还没真正释放前你完成了 validate,读到的就是完整一致的。验证失败也不慌,退回普通读锁重读——所以它最坏情况等于读写锁,最好情况零开销

实跑验证过两个场景:无写入时 validate 通过,拿到原值;写线程持写锁期间乐观读 validate 失败,降级悲观读后拿到新值。

StampedLock 的两个坑

1. 不可重入ReentrantLock/ReentrantReadWriteLock 同线程重复加锁没问题,StampedLock 不行——同一线程重复 readLock() 会直接死锁。这也是它换取高性能的代价之一。

2. 不能用 ConditionStampedLock 没有条件队列,需要 await/signal 配合的场景用不了它。

另外它不带"公平"概念,写少的时候读线程理论上可能一直乐观读成功、写线程插不进队(JDK 文档也承认这点),写压力大时要留意。

五、怎么选

场景用什么
读写都不少,或需要 ConditionReentrantReadWriteLock
读多写多、需要条件等待ReentrantReadWriteLock(读锁 + Condition)
读极多写极少(缓存、配置、坐标点这类),读路径是热点StampedLock 乐观读
同一线程要重入排除 StampedLock

一句话记忆:先想 ReentrantReadWriteLock,读路径被测出是热点、且写极少时,才上 StampedLock 它们的底层都是 AQS(StampedLock 是自己实现的类似逻辑),排队挂起那套机制在《AQS 原理详解》。

小结

  • 读写锁规则:读读共享,读写互斥,写写互斥
  • 锁降级(写→读)可以:写锁内拿读锁、再放写锁,保证读到的是自己刚写的(安全发布)
  • 锁升级(读→写)不行:拿着读锁等写锁,自己挡自己,死锁
  • StampedLock 乐观读:读不加锁,tryOptimisticRead → 读 → validate,失败降级悲观读
  • StampedLock 不可重入、无 Condition、无公平模式——高性能的代价
  • 选择:默认 ReentrantReadWriteLock;读路径实测是热点且写极少才用 StampedLock

这些锁的排队、挂起、唤醒是怎么实现的?看《AQS 原理详解》。单线程互斥之外,多台机器之间的互斥看《Redis 分布式锁详解》。