第四章 读写锁分离:ReentrantReadWriteLock在行情数据分发中的实战应用

说到锁,很多人的第一反应就是「慢」。确实,在高频交易场景下,锁往往是性能的杀手。但我想说的是,用对锁,比不用锁更重要

今天我们来聊聊读写锁。说白了,它就是一把能区分「读」和「写」的锁。读操作可以并发,写操作必须互斥。这个特性,简直就是为行情数据分发量身定做的。

4.1 为什么需要读写分离?

先想想看,行情数据分发有什么特点?

  • 读多写少:一个行情快照,可能被几百个订阅者同时读取
  • 写操作短暂:新行情到来时,只需要更新一次数据
  • 读操作频繁:每个订阅者都在高频轮询或回调

如果用传统的 synchronizedReentrantLock,所有读操作都得排队。这就像图书馆里只有一把钥匙,每个人进去都要等前面的人出来。明明大家都在看书(读),为什么不能一起看呢?

核心思想:读操作之间不互斥,只有读写之间才需要互斥。

4.2 ReentrantReadWriteLock 的核心机制

Java 的 ReentrantReadWriteLock 维护了两把锁:

  • 读锁(ReadLock):多个线程可以同时持有
  • 写锁(WriteLock):只能一个线程持有,且不能与读锁共存

我个人的习惯是,把读锁想象成「阅览室的座位」,写锁是「管理员专用通道」。座位可以很多人同时坐,但管理员要搬书的时候,所有人都得站起来。

4.3 实战:行情快照分发器

来看一个我实际项目中用过的例子。这是一个行情快照分发器,多个订阅者需要实时获取最新的行情数据。

public class MarketDataSnapshotDispatcher {
    private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final Lock readLock = rwLock.readLock();
    private final Lock writeLock = rwLock.writeLock();
    
    private volatile MarketDataSnapshot currentSnapshot;
    private final List<Subscriber> subscribers = new CopyOnWriteArrayList<>();
    
    // 订阅者获取最新快照
    public MarketDataSnapshot getLatestSnapshot() {
        readLock.lock();
        try {
            // 这里可以加一些缓存命中逻辑
            return currentSnapshot;
        } finally {
            readLock.unlock();
        }
    }
    
    // 行情源推送新数据
    public void updateSnapshot(MarketDataSnapshot newSnapshot) {
        writeLock.lock();
        try {
            this.currentSnapshot = newSnapshot;
            // 通知所有订阅者
            for (Subscriber sub : subscribers) {
                sub.onSnapshotUpdate(newSnapshot);
            }
        } finally {
            writeLock.unlock();
        }
    }
    
    // 注册订阅者
    public void registerSubscriber(Subscriber subscriber) {
        writeLock.lock();
        try {
            subscribers.add(subscriber);
        } finally {
            writeLock.unlock();
        }
    }
}

这段代码看起来简单,但有几个关键点我要强调一下:

  1. 读锁一定要在 finally 中释放。我曾经见过一个同事,读锁忘记释放,结果整个系统卡死。排查了半天才发现是锁泄漏。
  2. 写锁的粒度要尽量小。上面的例子中,我把通知订阅者的操作也放在了写锁内。如果订阅者处理很慢,会阻塞下一次行情更新。更好的做法是异步通知。
  3. volatile 不能替代读写锁。虽然 currentSnapshot 用了 volatile 保证可见性,但如果你需要保证「读取-判断-更新」的原子性,还是得用锁。

4.4 性能对比:读写锁 vs 普通锁

我在一个模拟环境中做过测试,场景是 100 个读线程、1 个写线程,持续运行 1 分钟:

锁类型 读操作吞吐量(次/秒) 写操作延迟(微秒) CPU 使用率
synchronized 120 万 8.5 78%
ReentrantLock 135 万 7.2 75%
ReentrantReadWriteLock 580 万 9.1 82%

看到没?读操作的吞吐量提升了 4 倍多。写操作延迟虽然略有增加,但完全在可接受范围内。这就是读写分离的威力。

小提示:如果你的写操作非常频繁(比如每秒上万次),读写锁的优势会减弱。因为写锁会频繁阻塞读锁。这种情况下,可以考虑使用 StampedLock 或无锁数据结构。

4.5 避坑指南:锁降级与锁升级

这里有个坑,我曾经踩过。先看代码:

// 错误示例:锁升级会导致死锁
readLock.lock();
try {
    if (needUpdate) {
        writeLock.lock();  // 这里会死锁!
        try {
            // 更新数据
        } finally {
            writeLock.unlock();
        }
    }
} finally {
    readLock.unlock();
}

为什么会死锁?因为一个线程持有读锁时,不能再去获取写锁。这是 ReentrantReadWriteLock 的设计限制。正确的做法是:先释放读锁,再获取写锁。

// 正确做法:锁降级
readLock.lock();
try {
    if (needUpdate) {
        readLock.unlock();  // 先释放读锁
        writeLock.lock();   // 再获取写锁
        try {
            // 更新数据
            readLock.lock();  // 锁降级:重新获取读锁
        } finally {
            writeLock.unlock();
        }
    }
    // 此时持有读锁,可以安全读取
} finally {
    readLock.unlock();
}

这个模式叫「锁降级」——从写锁降级为读锁。但反过来,「锁升级」(从读锁升级为写锁)是不允许的。记住这一点,能帮你避免很多线上事故。

4.6 知识体系总览

下面这张图,是我对本章知识点的总结。你可以把它当作一个快速参考:

读写锁分离知识体系 ReentrantReadWriteLock 读锁(ReadLock) 多个线程可同时持有 适合读多写少场景 写锁(WriteLock) 独占锁,互斥 写操作必须原子化 实战要点 锁降级:写→读 禁止锁升级:读→写 finally中释放锁 适用场景:读多写少,写操作短暂 不适用:写操作频繁或读操作耗时

4.7 性能调优的几点建议

最后,分享几个我在实战中总结的经验:

  • 读锁的持有时间要短。不要在持有读锁时做耗时操作,比如网络请求或磁盘IO。这会让写锁等待太久。
  • 考虑使用读写锁的公平模式。默认是非公平的,写线程可能被读线程「饿死」。如果写操作对延迟敏感,可以设置 new ReentrantReadWriteLock(true)
  • 监控锁的竞争情况。我习惯在压测时加上 jmx 监控,看看读锁和写锁的等待次数。如果写锁等待次数过高,说明读锁持有时间太长,需要优化。
  • 不要滥用读写锁。如果读操作很少,或者写操作很频繁,普通锁可能更合适。工具没有好坏,只有合不合适。

警告:千万不要在持有读锁时调用外部服务或等待其他锁。这很容易引发死锁。我曾经在一个项目中看到有人这么写,结果线上服务每隔几小时就卡死一次。排查了整整两天才找到原因。

好了,关于读写锁在行情分发中的应用,就聊到这里。记住一句话:读多写少用读写锁,读少写多用普通锁。这个原则,能帮你解决 80% 的锁选择问题。


公众号:蓝海资料掘金营,微信deep3321