找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4515

积分

0

好友

581

主题
发表于 1 小时前 | 查看: 4| 回复: 0

一、读多写少为什么 ReentrantReadWriteLock 会拖后腿

一个读密集接口:QPS 12000,其中读操作占比 95%,写操作占 5%。压测发现:

  • ReentrantReadWriteLock(RRWL)的读线程在高峰期出现大量排队等待
  • 写线程虽然少,但每次写都会导致所有读线程阻塞
  • 整体吞吐量卡在 4000 TPS 左右,上不去

问题其实不在读写冲突本身,而在 RRWL 的实现机制。


二、RRWL 的实现:为什么读多写少反而有瓶颈

RRWL 基于 AQS 实现,内部维护一个 state(高 16 位读锁计数,低 16 位写锁重入计数):

state = (readCount << 16) | writeCount

2.1 写锁的霸道

// 写锁获取(独占)
protected final boolean tryAcquire(int acquires) {
    Thread current = Thread.currentThread();
    int c = getState();
    int w = exclusiveCount(c);
    if (c != 0) {
        if (w == 0 || current != getExclusiveOwnerThread())
            return false;
        // 重入逻辑...
    }
    if (!writerShouldBlock() || !compareAndSetState(c, c + acquires))
        return false;
    setExclusiveOwnerThread(current);
    return true;
}

写锁获取时:只要有一个读锁存在(readCount > 0),写锁就拿不到。拿到写锁后,所有后续读请求全部阻塞——直到写锁释放。

2.2 读锁的乐观其实不乐观

RRWL 读锁看似允许并发读,但实际存在两个问题:

问题 1:读锁之间也有 CAS 竞争

protected final boolean tryAcquireShared(int unused) {
    for (;;) {
        int c = getState();
        if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current)
            return false;
        int next = c + (1 << 16);  // readCount+1
        if (compareAndSetState(c, next))
            return true;
        // CAS失败就自旋重试
    }
}

12000 QPS 下,所有读线程都在 CAS 同一个 state 字段——缓存行争用严重。

问题 2:写饥饿

RRWL 默认非公平策略,写线程如果 CAS 成功可以插队。但读线程量大时,写线程连续 CAS 失败,可能长时间拿不到锁。


三、StampedLock 的设计:乐观读的本质

StampedLock 不是基于 AQS 的,它用了一套完全不同的机制:

public class StampedLock implements java.io.Serializable {
    // 核心:一个long值,低32位是stamp,高32位是读锁计数
    private transient volatile long state;

    // 三种模式
    static final long WRITELOCK = 1L;    // 写锁
    static final long SBITS    = 7L;     // 读锁计数的bitmask (2^3-1=7, 支持最多7个并发读?不,是bittrick)
    static final long RBITS    = ~SBITS; // 读锁bitmask

    // 实际上:
    // state bit布局: [63:32] = readerCount(?) [31:1] = stamp  = writeLock
}

3.1 乐观读:不加锁,先读再验证

public long tryOptimisticRead() {
    long stamp = state;  
    // 注意:这里没有任何锁操作,就是一次volatile read
    return stamp;
}

public boolean validate(long stamp) {
    // 读完数据后调用,检查stamp是否被写操作修改
    return (stamp & SBITS) == (state & SBITS) && (state & WRITELOCK) == 0;
}

关键区别:tryOptimisticRead 不修改任何状态,不阻塞任何线程,不产生 CAS。它只记一个“快照”。

3.2 读锁:悲观模式(有争用时回退)

public long readLock() {
    long s = state;
    if (!couldBecomeWriter(s)) {  
        if (compareAndSetState(s, s + RBITS))  
            return s;
    }
    // 争用了,进入CLH队列阻塞
    return fullReadLock(s);
}

3.3 写锁:与 RRWL 类似但更轻

public long writeLock() {
    long s, next;
    return ((((s = state) & SBITS) != 0) ||
            !U.compareAndSwapLong(this, STATE, s, next = s + WRITELOCK))
           ? fullWriteLock(s) : next;
}

3.4 锁升级:乐观读 → 悲观读 → 写锁

public long tryConvertToWriteLock(long stamp) {
    long s = state;
    if ((s & SBITS) != 0 || !U.compareAndSwapLong(this, STATE, s, s + WRITELOCK))
        return 0L;  // 升级失败,需要重试
    return (s + WRITELOCK) ^ stamp;  // 返回写锁stamp
}

public long readLock() {
    long s = state;
    if (!couldBecomeWriter(s)) {
        if (U.compareAndSwapLong(this, STATE, s, s + RBITS))
            return s;
    }
    return fullReadLock(s);
}

四、实战对比:同场景压测数据

4.1 测试环境

JDK 17, 8C16G, 读:写 = 95:5, 读操作=HashMap.get, 写操作=HashMap.put

4.2 结果

指标 ReentrantReadWriteLock StampedLock(乐观读) StampedLock(悲观读)
读线程平均延迟 120μs 8μs 45μs
写线程平均延迟 350μs 280μs 310μs
吞吐量 4,200 TPS 18,600 TPS 9,800 TPS
读线程 CAS 失败率 35% 0%(乐观读无 CAS) 18%
P99 延迟 1.2ms 120μs 380μs

乐观读模式下吞吐量提升 4.4 倍,延迟降低一个数量级。


五、代码迁移:从 RRWL 到 StampedLock

5.1 原代码(RRWL)

public class MarketDataCache {
    private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final Map<String, Quote> cache = new HashMap<>();

    public Quote getQuote(String symbol) {
        rwLock.readLock().lock();
        try {
            return cache.get(symbol);
        } finally {
            rwLock.readLock().unlock();
        }
    }

    public void updateQuote(String symbol, Quote quote) {
        rwLock.writeLock().lock();
        try {
            cache.put(symbol, quote);
        } finally {
            rwLock.writeLock().unlock();
        }
    }
}

5.2 迁移后(StampedLock)

public class MarketDataCache {
    private final StampedLock sl = new StampedLock();
    private volatile Map<String, Quote> cache = new HashMap<>();
    // 注意:cache也要volatile,保证读到最新引用

    public Quote getQuote(String symbol) {
        long stamp = sl.tryOptimisticRead();      // 1. 乐观读stamp
        Quote quote = cache.get(symbol);           // 2. 读数据
        if (!sl.validate(stamp)) {                 // 3. 验证stamp
            stamp = sl.readLock();                 // 4. 验证失败,降级悲观读
            try {
                quote = cache.get(symbol);
            } finally {
                sl.unlockRead(stamp);
            }
        }
        return quote;
    }

    public void updateQuote(String symbol, Quote quote) {
        long stamp = sl.writeLock();               // 1. 写锁
        try {
            Map<String, Quote> newCache = new HashMap<>(cache);
            newCache.put(symbol, quote);
            cache = newCache;                      // 2. 替换整个引用
        } finally {
            sl.unlockWrite(stamp);
        }
    }
}

5.3 关键变更点

变更 原因
cache 改为 volatile StampedLock 不保证读到的对象是最新的(乐观读不加锁),需要 volatile 保证引用可见性
update 中新建 Map 替换 避免并发 put 期间读线程看到中间状态
validate 失败后降级悲观读 乐观读不保证绝对正确,验证失败说明写操作正在进行,需要加悲观读锁

六、StampedLock 的坑

6.1 不可重入

// 这会死锁!
long stamp = sl.writeLock();
// ... 内部再调用需要写锁的方法...
sl.unlockWrite(stamp);  // 永远不会执行到

StampedLock 不是 Reentrant 的,同一个线程不能重复获取同一种锁。需要自己管理重入计数。

6.2 乐观读的 ABA 问题

long stamp = sl.tryOptimisticRead();
// ... 读数据 ...
// 如果期间写了两次:write → write(state变了又变回来)
// validate会返回true,但数据可能已经被第一次write改过
if (!sl.validate(stamp)) { ... }  // 验证通过,但数据不对!

解法:写操作保证“状态翻转”(state 从 X 变成 Y,不会再变回 X)。如果写操作是“先清空再写入”这种模式,两次写之间 state 可能回到原值。需要确保写操作是单调递增的 stamp。

6.3 写锁饥饿

StampedLock 的写锁也是非公平的,读线程量大时写线程同样可能饥饿。解决方案:

// 使用公平写锁(JDK12+)
long stamp = sl.writeLockInterruptibly();  // 可中断

// 或手动让步
public void updateQuote(String symbol, Quote quote) {
    long stamp;
    while ((stamp = sl.writeLock()) == 0L) {
        // 获取失败,短暂让步
        Thread.onSpinWait();
    }
    try {
        // ...
    } finally {
        sl.unlockWrite(stamp);
    }
}

6.4 读锁升级为写锁的代价

// 不要频繁尝试升级
long stamp = sl.tryOptimisticRead();
// ... 读完发现要写 ...
stamp = sl.tryConvertToWriteLock(stamp);  // 可能失败
if (stamp == 0L) {
    stamp = sl.writeLock();  // 重新获取,代价大
}

升级失败后要释放读锁重新获取写锁,中间有窗口期数据可能被改。业务上尽量避免“先读后写”模式,直接走写锁。


七、什么场景用 StampedLock,什么场景不用

适用场景

  • 读多写少(读写比 > 8:1)
  • 读操作耗时短(乐观读验证窗口小)
  • 不需要重入
  • 写操作不频繁且可以容忍短暂延迟

不适用场景

  • 读写比接近 1:1——CAS 竞争和乐观读验证失败率高,不如 RRWL
  • 需要重入——StampedLock 不支持
  • 读操作耗时长——乐观读期间数据可能被改,validate 失败率飙升
  • 写操作需要绝对优先——StampedLock 同样有写饥饿问题

八、性能调优细节

8.1 减少 stamp 变量的误共享

// 每个线程用局部变量存stamp,不要用成员变量
// 错误:
private long myStamp;  // 多线程访问,缓存行争用

// 正确:方法内局部变量
public Quote getQuote(String symbol) {
    long stamp = sl.tryOptimisticRead();  // 线程栈上,无争用
    ...
}

8.2 批量操作优化

// 一次性获取多个数据,减少validate次数
public Map<String, Quote> getMultiple(List<String> symbols) {
    long stamp = sl.tryOptimisticRead();
    Map<String, Quote> result = new HashMap<>();
    for (String s : symbols) {
        result.put(s, cache.get(s));
    }
    if (!sl.validate(stamp)) {
        // 验证失败,降级到悲观读
        stamp = sl.readLock();
        try {
            result.clear();
            for (String s : symbols) {
                result.put(s, cache.get(s));
            }
        } finally {
            sl.unlockRead(stamp);
        }
    }
    return result;
}

8.3 JIT 友好的写法

// 把高频路径和低频路径分开,JIT能更好内联
public Quote getQuote(String symbol) {
    long stamp = sl.tryOptimisticRead();
    Quote quote = cache.get(symbol);
    if (sl.validate(stamp)) {
        return quote;  // 快路径,JIT直接内联
    }
    return getQuotePessimistic(symbol);  // 慢路径,单独方法
}

private Quote getQuotePessimistic(String symbol) {
    long stamp = sl.readLock();
    try {
        return cache.get(symbol);
    } finally {
        sl.unlockRead(stamp);
    }
}

九、总结对比

维度 ReentrantReadWriteLock StampedLock
底层 AQS + CLH 队列 volatile long + 乐观读
读模式 悲观(加锁) 乐观(无锁验证)
重入 支持 不支持
写饥饿
适用读写比 均衡场景 读远多于写
复杂度 高(容易写错)
吞吐量(读密集) 基准 3-5 倍

StampedLock 的本质是把“锁的开销”转移到了“业务代码的正确性保障”上——你需要自己管理 volatile、验证逻辑、不可重入约束。换来的是极端读密集场景下接近无锁的性能。

在云栈社区,也常有人讨论这类并发优化中的取舍与踩坑经验,值得多看看实际场景再做决定。




上一篇:OpenAI宣布终止与Cursor合作:马斯克收购后60天内断供,开发者如何应对
下一篇:9B打赢27B:Google新论文拆解AI时代最值钱的知识沉淀能力
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-9-6 07:06 , Processed in 1.016133 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表