【大白话说Java面试题 第128题】【并发篇】第28题:读写锁 ReentrantReadWriteLock 存在的意义

📌 PDF:大白话说Java面试题 — 04-并发篇

第28题:读写锁 ReentrantReadWriteLock 存在的意义

📚 回答:

  • 核心考点ReentrantReadWriteLock(读写锁)是 Java 并发包中针对读多写少场景设计的锁机制。大厂面试不会只问"读锁共享、写锁独占"这种表面概念,而是深入考察 AQS 状态位拆分设计(高16位读锁计数 + 低16位写锁重入)、锁降级的必要性与实现细节写锁饥饿问题的根因与解决方案,以及 StampedLock 的演进动机。面试官真正想判断的是:你是否理解从 ReentrantLock 到 ReentrantReadWriteLock 再到 StampedLock 的锁演进路线,以及每种锁的适用边界和工程陷阱。

1. 为什么需要读写锁?------排它锁的性能瓶颈
  • 1.1 读-读互斥的浪费ReentrantLock 中,无论是读-读、读-写还是写-写,都会发生互斥。但在实际业务中,读操作通常远多于写操作,且读操作本身不会修改数据,多个线程同时读取理论上不会引发并发问题。使用排它锁会导致大量读线程串行等待,严重浪费 CPU 资源。

  • 1.2 性能对比数据 假设一个缓存系统,读操作占 95%,写操作占 5%:

锁类型读-读并发读-写并发写-写并发100线程读吞吐量(估算)
ReentrantLock❌ 互斥❌ 互斥❌ 互斥~1x(串行)
ReentrantReadWriteLock✅ 并发❌ 互斥❌ 互斥~50x+(读并发)
StampedLock(乐观读)✅ 并发(无阻塞)⚠️ 乐观读不阻塞写❌ 互斥~100x+
  • 1.3 典型反例------缓存系统用 ReentrantLock
// ❌ 错误:读操作被不必要的串行化
public class CacheWithReentrantLock {
    private final ReentrantLock lock = new ReentrantLock();
    private Map<String, Object> cache = new HashMap<>();

    public Object get(String key) {
        lock.lock();  // 读操作也要排队!
        try {
            return cache.get(key);
        } finally {
            lock.unlock();
        }
    }

    public void put(String key, Object value) {
        lock.lock();
        try {
            cache.put(key, value);
        } finally {
            lock.unlock();
        }
    }
}

100 个线程同时读取缓存,在 ReentrantLock 下会串行执行,吞吐量极低。而 ReentrantReadWriteLock 下 100 个读线程可以并发执行,性能提升数十倍。


2. ReentrantReadWriteLock 的 AQS 实现原理
  • 2.1 状态位拆分设计 ReentrantReadWriteLock 基于 AQS 实现,核心创新在于将 32 位 state 拆分为两部分:
// AQS 的 state 字段:高16位记录读锁,低16位记录写锁
static final int SHARED_SHIFT   = 16;
static final int SHARED_UNIT    = (1 << SHARED_SHIFT);        // 65536,读锁计数加1
static final int MAX_COUNT      = (1 << SHARED_SHIFT) - 1;  // 65535,最大重入次数
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;    // 65535,写锁掩码

// 获取读锁持有数量(高16位)
static int sharedCount(int c)   { return c >>> SHARED_SHIFT; }

// 获取写锁重入次数(低16位)
static int exclusiveCount(int c){ return c & EXCLUSIVE_MASK; }
状态位范围含义最大值
高16位state >>> 16读锁持有线程数(所有线程的读锁总数)65535
低16位state & 0x0000FFFF写锁重入次数(仅当前写线程)65535

设计精妙之处:读锁是共享锁,需要记录所有线程持有的读锁总数;写锁是独占锁,只需记录当前线程的重入次数

  • 2.2 读锁获取规则
1. 检查是否有写锁持有
   ├─ 无写锁:增加读锁计数(高16位 + 1),获取成功
   └─ 有写锁:检查是否当前线程持有(锁降级场景)
        ├─ 是当前线程:获取成功(锁降级)
        └─ 是其他线程:进入 AQS 等待队列
  • 2.3 写锁获取规则
1. 检查 state 是否为 0(无任何锁)
   ├─ 是:设置写锁状态(低16位 = 1),获取成功
   └─ 否:检查是否当前线程重入
        ├─ 是:低16位 + 1(重入)
        └─ 否:进入 AQS 等待队列

关键约束:写锁获取时,必须满足 state == 0exclusiveOwnerThread == 当前线程。只要有任何读锁存在(高16位 > 0),写锁就必须等待。

  • 2.4 公平模式 vs 非公平模式
模式获取策略吞吐量饥饿风险适用场景
非公平(默认)允许插队,新线程可直接尝试获取写锁可能饥饿读多写少,对延迟敏感
公平严格按请求顺序排队基本无饥饿写操作不能长时间等待
// 非公平模式(默认)
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();

// 公平模式
ReentrantReadWriteLock fairRwLock = new ReentrantReadWriteLock(true);

3. 锁降级:写锁降级为读锁
  • 3.1 什么是锁降级? 锁降级是指线程在持有写锁的情况下,先获取读锁,再释放写锁,将独占访问降级为共享访问的过程。这是 ReentrantReadWriteLock 支持的唯一锁转换方向。

标准锁降级流程

writeLock.lock();          // 1. 获取写锁(独占)
try {
    // 修改数据...
    data = newValue;
    readLock.lock();       // 2. 在写锁保护下获取读锁
} finally {
    writeLock.unlock();    // 3. 释放写锁(完成降级)
}

try {
    // 4. 此时仅持有读锁,其他读线程可并发进入
    use(data);
} finally {
    readLock.unlock();     // 5. 释放读锁
}
  • 3.2 为什么需要锁降级?------消除危险间隙 如果不使用锁降级,直接释放写锁再获取读锁,存在一个危险窗口
时间线(无锁降级的危险场景):
1. 线程A获取写锁
2. 线程A修改 data = newValue
3. 线程A释放写锁          ← 【危险间隙开始】
4. 线程B获取写锁
5. 线程B修改 data = anotherValue
6. 线程B释放写锁          ← 【危险间隙结束】
7. 线程A获取读锁
8. 线程A读取 data → 得到 anotherValue(非预期!)

锁降级消除危险间隙

时间线(锁降级保护):
1. 线程A获取写锁
2. 线程A修改 data = newValue
3. 线程A获取读锁          ← 读锁保护开始
4. 线程A释放写锁          ← 降级完成,但仍持有读锁
5. 线程B尝试获取写锁 → 阻塞(读锁存在)
6. 线程A安全读取 data = newValue
7. 线程A释放读锁
8. 线程B获取写锁
  • 3.3 锁降级的核心价值
价值点说明
数据一致性确保线程看到自己修改的最新数据,不被其他写线程覆盖
写后读原子性消除"释放写锁→获取读锁"之间的危险窗口
并发性优化降级后释放写锁,允许其他读线程并发访问最新数据
  • 3.4 经典应用场景------缓存更新
public class CachedData {
    private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    private final ReentrantReadWriteLock.ReadLock r = rwl.readLock();
    private final ReentrantReadWriteLock.WriteLock w = rwl.writeLock();

    private volatile boolean cacheValid;
    private Object data;

    void processCachedData() {
        r.lock();                           // 1. 先获取读锁
        if (!cacheValid) {                  // 2. 缓存失效
            r.unlock();                     // 3. 必须释放读锁!(否则死锁)
            w.lock();                       // 4. 获取写锁
            try {
                if (!cacheValid) {          // 5. 二次检查(Double Check)
                    data = fetchFromDB();   // 6. 更新缓存
                    cacheValid = true;
                }
                r.lock();                   // 7. 锁降级:写锁内获取读锁
            } finally {
                w.unlock();                 // 8. 释放写锁,保留读锁
            }
        }

        try {
            use(data);                      // 9. 安全使用数据(读锁保护)
        } finally {
            r.unlock();                     // 10. 释放读锁
        }
    }
}

关键注意点

  1. 必须先释放读锁再获取写锁:持有读锁时无法直接获取写锁,会导致死锁。
  2. 写锁内必须二次检查:释放读锁→获取写锁之间可能有其他线程已更新缓存。
  3. 锁降级顺序不可颠倒:必须是"获取写锁 → 获取读锁 → 释放写锁",否则不是真正的降级。
  • 3.5 不支持锁升级 ReentrantReadWriteLock 严禁锁升级(读锁 → 写锁)。如果允许,多个线程同时持有读锁并都想升级为写锁时,会互相等待对方释放读锁,导致死锁
// ❌ 致命错误!锁升级会导致死锁
readLock.lock();
try {
    if (needUpdate) {
        writeLock.lock();  // 死锁!其他线程也持有读锁,写锁永远无法获取
        try {
            // 更新数据
        } finally {
            writeLock.unlock();
        }
    }
} finally {
    readLock.unlock();
}

4. 写锁饥饿问题与解决方案
  • 4.1 问题根因 在默认非公平模式下,如果读线程持续不断地获取读锁,写线程可能长时间无法获取写锁,导致写锁饥饿(Write Lock Starvation)。
场景演示:
1. 线程A获取读锁 → 成功(state 高16位 = 1)
2. 线程B请求写锁 → 阻塞(等待读锁释放)
3. 线程C获取读锁 → 成功(state 高16位 = 2)
4. 线程D获取读锁 → 成功(state 高16位 = 3)
5. 线程A释放读锁 → 高16位 = 2,但线程B仍被阻塞
6. 线程E获取读锁 → 成功(高16位 = 3)
... 读线程源源不断,写线程永远轮不到
  • 4.2 解决方案对比
方案原理优点缺点适用场景
公平模式new ReentrantReadWriteLock(true),严格按请求顺序分配彻底避免饥饿吞吐量下降,线程切换频繁写操作不能长时间等待
限制读线程数量自定义逻辑控制最大并发读线程数灵活可控实现复杂,需额外计数读线程可控的业务
StampedLock乐观读不阻塞写,写锁获取机会增加性能最优API复杂,不可重入读远多于写,追求极致性能
  • 4.3 公平模式的实现原理 公平模式下,AQS 的 hasQueuedPredecessors() 方法会检查等待队列中是否有排在前面的线程。如果有,即使当前线程能获取锁,也会主动放弃并排队,从而保证写线程的获取机会。
// 公平模式:写线程不会饿死
private final ReentrantReadWriteLock rwLock = 
    new ReentrantReadWriteLock(true);

5. ReentrantReadWriteLock vs StampedLock
对比维度ReentrantReadWriteLockStampedLock
读锁类型悲观读(阻塞写)悲观读 + 乐观读(不阻塞写)
乐观读❌ 不支持tryOptimisticRead()
可重入✅ 支持❌ 不支持
Condition✅ 支持❌ 不支持
写锁饥饿非公平模式下可能乐观读不阻塞写,基本避免
API复杂度高(需管理 stamp)
性能(读多写少)极高(乐观读无锁)
适用场景通用读多写少读极多写极少,追求极致性能

StampedLock 乐观读示例

public class StampedLockExample {
    private final StampedLock lock = new StampedLock();
    private int value;

    public int read() {
        long stamp = lock.tryOptimisticRead();  // 1. 乐观读,不阻塞
        int current = value;
        if (!lock.validate(stamp)) {              // 2. 验证期间是否有写操作
            stamp = lock.readLock();              // 3. 有写,降级为悲观读
            try {
                current = value;
            } finally {
                lock.unlockRead(stamp);
            }
        }
        return current;
    }

    public void write(int newValue) {
        long stamp = lock.writeLock();
        try {
            value = newValue;
        } finally {
            lock.unlockWrite(stamp);
        }
    }
}

性能对比参考(读:写 = 100:1 场景):

锁类型平均耗时
synchronized~450ms
ReentrantReadWriteLock~180ms
StampedLock(乐观读)~120ms

6. 生产环境避坑指南
  • 6.1 严禁在读锁内直接获取写锁 这是最常见的死锁场景。必须先释放读锁,再获取写锁。

  • 6.2 锁降级顺序不可颠倒 必须是"写锁 → 读锁 → 释放写锁",如果先释放写锁再获取读锁,就失去了降级的意义。

  • 6.3 注意写锁饥饿 非公平模式下,如果读线程源源不断,写线程可能永远获取不到锁。对写延迟敏感的场景必须使用公平模式或 StampedLock。

  • 6.4 不可在 finally 中重复释放锁

// ❌ 错误:锁降级场景下,finally 可能重复释放
writeLock.lock();
readLock.lock();
try {
    // ...
} finally {
    writeLock.unlock();
    readLock.unlock();  // 如果上面抛异常,这里可能重复释放!
}

// ✅ 正确:分层 finally
writeLock.lock();
try {
    readLock.lock();
    try {
        // ...
    } finally {
        readLock.unlock();
    }
} finally {
    writeLock.unlock();
}
  • 6.5 读锁持有时间不宜过长 读锁会阻塞写锁,如果读操作耗时很长(如复杂计算、I/O),会导致写操作长时间等待。应将耗时操作移到锁外。

  • 6.6 优先使用 StampedLock 的场景 如果满足以下条件,考虑用 StampedLock 替代:

    1. 读操作远多于写操作(>100:1)
    2. 不需要锁重入
    3. 不需要 Condition 条件变量
    4. 读操作耗时短(乐观读验证成功率高)

7. 面试官追问与高分回答模板
  • 追问 1:“读写锁的实现原理是什么?”

    低分回答:“读锁共享,写锁独占。”(太浅,没有触及 AQS 状态位拆分)

    高分回答

    "ReentrantReadWriteLock 基于 AQS 实现,核心设计是将 32 位 state 拆分为高16位和低16位:高16位记录所有线程持有的读锁总数(共享锁特性),低16位记录当前写线程的重入次数(独占锁特性)。

    读锁获取时,检查 state 低16位是否为0(无写锁),或写锁持有者是当前线程(锁降级场景),满足条件则将高16位 +1。写锁获取时,要求 state 必须为0(无任何锁)或当前线程重入,然后将低16位 +1。

    这种设计使得读读并发、读写互斥、写写互斥,在读多写少场景下大幅提升并发性能。"

  • 追问 2:“什么是锁降级?为什么需要它?”

    低分回答:“写锁变成读锁。”(没有解释危险间隙)

    高分回答

    "锁降级是指线程在持有写锁的情况下,先获取读锁,再释放写锁,将独占访问降级为共享访问的过程。

    需要锁降级的核心原因是消除危险间隙。如果不降级,直接释放写锁再获取读锁,中间存在一个窗口期:其他写线程可能在此期间获取写锁并修改数据,导致当前线程读到非预期的值。

    通过锁降级,线程在释放写锁前就已经持有读锁,利用读锁的共享特性(但阻塞写锁)保护数据,确保自己修改的数据不被其他写线程覆盖,同时允许其他读线程并发访问。经典应用场景是缓存更新:先写锁更新缓存,降级为读锁后释放写锁,其他线程立即并发读取最新数据。"

  • 追问 3:“ReentrantReadWriteLock 支持锁升级吗?为什么?”

    高分回答

    "不支持锁升级(读锁 → 写锁)。如果支持,会导致死锁。

    假设线程 A 和线程 B 都持有读锁,同时都想升级为写锁。线程 A 需要等待线程 B 释放读锁才能获取写锁,线程 B 也需要等待线程 A 释放读锁。两者互相等待,形成死锁。

    正确的做法是先释放读锁,再获取写锁(如缓存更新场景中的标准流程)。虽然这会短暂失去锁保护,但可以通过二次检查(Double Check)来弥补。"

  • 追问 4:“读写锁的写锁饥饿问题怎么解决?”

    高分回答

    "写锁饥饿的根本原因是非公平模式下读线程源源不断获取读锁,写线程永远轮不到。解决方案有三种:

    1. 公平模式new ReentrantReadWriteLock(true),严格按请求顺序分配锁,写线程按排队顺序获取,彻底避免饥饿。代价是吞吐量下降。
    2. 限制读线程数量:自定义逻辑控制最大并发读线程数,给写线程留出机会。实现复杂,一般不用。
    3. StampedLock:Java 8 引入,提供乐观读模式(tryOptimisticRead),读操作不阻塞写操作,从根本上避免写锁饥饿。但 API 复杂,不可重入,无 Condition 支持。

    工程选型上,如果写操作不能长时间等待,优先公平模式;如果追求极致性能且读远多于写,用 StampedLock。"

  • 追问 5:“StampedLock 和 ReentrantReadWriteLock 怎么选?”

    高分回答

    "选择取决于业务场景和团队能力:

    • ReentrantReadWriteLock:API 简单,支持重入和 Condition,适合大多数读多写少场景。非公平模式下需注意写锁饥饿问题。
    • StampedLock:性能更高(乐观读无锁),适合读极多写极少(如 >100:1)且读操作耗时短的场景。但不可重入、无 Condition、API 复杂(需管理 stamp),使用不当容易出 Bug。

    压测数据显示,读:写 = 100:1 时,StampedLock 耗时约 120ms,ReentrantReadWriteLock 约 180ms,synchronized 约 450ms。但如果读操作本身耗时较长,乐观读验证失败率高,频繁降级为悲观读,性能优势会大打折扣。"

  • 追问 6:“读写锁在什么场景下性能反而不如 ReentrantLock?”

    高分回答

    "以下场景读写锁性能可能更差:

    1. 写多读少:写锁是独占的,且写锁获取前必须等待所有读锁释放。频繁写操作会导致大量读线程阻塞和唤醒,上下文切换开销大。此时 ReentrantLock 更简单高效。
    2. 读操作极短:如果读操作只是简单的字段访问(纳秒级),读写锁的锁获取/释放开销(涉及 CAS、AQS 队列操作)可能比 ReentrantLock 还高。
    3. 锁竞争不激烈:单线程或低并发场景下,ReentrantLock 的偏向锁/轻量级锁优化效果更好,读写锁的状态拆分反而增加复杂度。

    所以读写锁不是银弹,只有在读多写少且读操作有一定耗时的场景才能发挥优势。"


8. 方案选型速查表
业务场景推荐方案核心理由
通用读多写少(缓存、配置)ReentrantReadWriteLock(非公平)API简单,支持重入,读并发高
写操作不能长时间等待ReentrantReadWriteLock(公平)避免写锁饥饿,按序分配
读极多写极少,追求极致性能StampedLock乐观读无锁,吞吐量最高
需要 Condition 条件变量ReentrantReadWriteLockStampedLock 不支持 Condition
需要锁重入ReentrantReadWriteLockStampedLock 不可重入
写多读少ReentrantLock / synchronized读写锁的写锁开销更大
金融交易(强一致性)synchronized / ReentrantLock避免读写锁的并发隐患

💡 面试官想要的满分总结

ReentrantReadWriteLock 的存在意义在于解决读多写少场景下排它锁的性能浪费------读操作不修改数据,多个线程同时读取理应并发执行。它通过 AQS 的状态位拆分设计(高16位读锁计数 + 低16位写锁重入)实现了读读并发、读写互斥、写写互斥。

锁降级是其核心高级特性:在写锁保护下获取读锁、再释放写锁,消除"释放写锁→获取读锁"之间的危险间隙,确保数据一致性。经典应用是缓存更新场景。

但读写锁并非银弹:非公平模式下存在写锁饥饿,需通过公平模式或 StampedLock 解决;严禁锁升级(会导致死锁);写多读少场景性能反而不如 ReentrantLock。Java 8 引入的 StampedLock 通过乐观读进一步提升了读多写少场景的性能,但牺牲了可重入性和 Condition 支持。

工程选型上,先确认场景是否"读多写少",再评估是否需要重入/Condition,最后权衡性能与复杂度------没有最好的锁,只有最适合当前场景的锁。


觉得对您有帮助,麻烦点点关注啦,您的关注是我创作的最大动力~ 🎯

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

AI人工智能+电脑小能手

若对您有所帮助,请点点关注哟~

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值