3
0
0

Java 多线程:ReentrantReadWriteLock 读写锁深入学习

2026-11-03
文章摘要
|

一、为什么需要读写锁?

1. 普通 ReentrantLock 的问题

ReentrantLock 是互斥锁,同一时间只允许一个线程持有锁。

例如三个线程同时查询数据:

线程 A:查询
线程 B:查询
线程 C:查询

由于 ReentrantLock 是互斥锁:

线程 A 获取锁,执行查询

线程 B 等待

线程 C 等待

线程 A 释放锁

线程 B 获取锁,执行查询

线程 C 等待

线程 B 释放锁

线程 C 获取锁,执行查询

但是,如果多个线程只是读取数据,并没有修改数据,理论上可以允许它们同时读取。

这就是读写锁要解决的问题。

2. 什么是读写锁?

ReentrantReadWriteLock 将锁分为两种:

  • 读锁(ReadLock):允许多个线程同时读取。

  • 写锁(WriteLock):同一时间只允许一个线程写入。

核心规则:

锁组合

是否允许同时执行

原因

读锁 + 读锁

允许

多个线程可以同时读取

读锁 + 写锁

不允许

读取期间不能被其他线程修改

写锁 + 写锁

不允许

同一时间只能一个线程写入

需要记住:

读读共享、读写互斥、写写互斥。

3. 通过示例理解

读读共享:

线程 A:持有读锁
线程 B:持有读锁
线程 C:持有读锁

三个线程可以同时读取。

读写互斥:

线程 A:持有读锁

线程 B:尝试获取写锁

线程 B:等待

写写互斥:

线程 A:持有写锁

线程 B:尝试获取写锁

线程 B:等待

注意:以上规则针对不同线程之间的锁竞争。持有写锁的线程可以进一步获取读锁,这是锁降级的基础。

二、ReentrantReadWriteLock 的基本使用

1. 创建读写锁

ReentrantReadWriteLock 是 Java 8 自带的类,不需要引入第三方依赖。

import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockDemo {

    private static final ReentrantReadWriteLock LOCK =
            new ReentrantReadWriteLock();

    // 获取读锁
    private static final ReentrantReadWriteLock.ReadLock READ_LOCK =
            LOCK.readLock();

    // 获取写锁
    private static final ReentrantReadWriteLock.WriteLock WRITE_LOCK =
            LOCK.writeLock();
}

2. 读锁的使用

public String query() {

    READ_LOCK.lock();

    try {

        System.out.println("查询数据");

        return "商品数据";

    } finally {

        READ_LOCK.unlock();
    }
}

多个线程可以同时执行 query(),前提是没有其他线程持有写锁。

3. 写锁的使用

public void update() {

    WRITE_LOCK.lock();

    try {

        System.out.println("修改数据");

    } finally {

        WRITE_LOCK.unlock();
    }
}

当线程 A 持有写锁时:

  • 其他线程不能获取读锁。

  • 其他线程不能获取写锁。

  • 当前线程可以重入写锁。

  • 当前线程可以获取读锁。

4. 读锁和写锁都支持可重入吗?

支持。

读锁可以重入,写锁也可以重入。

例如写锁:

WRITE_LOCK.lock();

try {

    WRITE_LOCK.lock();

    try {
        System.out.println("第二次获取写锁");
    } finally {
        WRITE_LOCK.unlock();
    }

} finally {
    WRITE_LOCK.unlock();
}

写锁重入时,持有次数增加。

读锁也是一样,同一个线程可以重复获取读锁。

三、ReentrantReadWriteLock 底层原理

这是本节最重要的知识点。

1. 一个 state 如何记录读锁和写锁?

回顾 AQS:

private volatile int state;

ReentrantLock 使用 state 记录锁的持有次数。

但是 ReentrantReadWriteLock 同时存在读锁和写锁,如何使用一个 int 类型的 state 记录两种锁的状态?

答案是:位运算。

ReentrantReadWriteLock 将一个 32 位的 int 拆分为两部分:

  • 高 16 位:记录读锁总持有次数。

  • 低 16 位:记录写锁重入次数。

AQS state:32 位

┌────────────────────┬────────────────────┐
│       高 16 位      │       低 16 位      │
│                    │                    │
│     读锁总次数      │    写锁重入次数     │
└────────────────────┴────────────────────┘

例如:

读锁总持有次数 = 3

写锁重入次数 = 2

那么:

state = (3 << 16) | 2;

2. 如何获取读锁数量?

Java 8 中的核心实现:

int sharedCount(int c) {
    return c >>> 16;
}

通过无符号右移 16 位,获取高 16 位。

3. 如何获取写锁数量?

int exclusiveCount(int c) {
    return c & 0xFFFF;
}

通过按位与,保留低 16 位。

因此:

int state = getState();

int readCount = state >>> 16;

int writeCount = state & 0xFFFF;

就能同时得到读锁数量和写锁重入次数。

4. 为什么每部分只有 16 位?

因为一个 int 只有 32 位。

高 16 位和低 16 位各自最多表示:

2^16 - 1 = 65535

因此读锁总持有次数和写锁重入次数都有上限,超过限制会抛出 Error。

5. 读锁数量到底记录什么?

这是一个容易混淆的地方。

state 高 16 位记录的是:

所有线程持有读锁的总次数,而不是持有读锁的线程数量。

例如:

线程 A:获取读锁 2 次

线程 B:获取读锁 1 次

线程 C:获取读锁 1 次

那么:

读锁总持有次数 = 4

state 高 16 位记录的是 4。

那么问题来了:

线程 A 释放读锁时,怎么知道线程 A 获取了几次?

ReentrantReadWriteLock 内部还会记录每个线程自己的读锁持有次数。

Java 8 中涉及:

  • firstReader

  • firstReaderHoldCount

  • cachedHoldCounter

  • ThreadLocalHoldCounter

这些机制用于记录和查询各线程自己的读锁持有情况。

这样才能保证每个线程正确释放自己获取的读锁。

四、ReentrantReadWriteLock 如何基于 AQS 实现?

上一课学习过 AQS 的两种模式:

  • 独占模式(Exclusive)。

  • 共享模式(Shared)。

ReentrantReadWriteLock 同时使用这两种模式。

ReentrantReadWriteLock
          |
          v
      Sync(AQS)
          |
     ┌────┴────┐
     |         |
     v         v
  ReadLock  WriteLock
     |         |
     v         v
  共享模式   独占模式

1. 读锁属于共享模式

多个线程可以同时获得读锁。

例如:

初始 state = 0

线程 A 获取读锁:

读锁数量 = 1

线程 B 获取读锁:

读锁数量 = 2

线程 C 获取读锁:

读锁数量 = 3

三个线程可以同时读取。

2. 写锁属于独占模式

写锁同一时间只能由一个线程持有。

例如:

线程 A 获取写锁:

写锁重入次数 = 1

此时:

  • 线程 B 获取读锁:失败。

  • 线程 C 获取写锁:失败。

  • 线程 A 再次获取写锁:成功,重入次数增加。

3. 写锁获取条件

简单理解:

  1. 如果已经有其他线程持有写锁,获取失败。

  2. 如果存在其他线程持有读锁,获取失败。

  3. 如果没有其他线程持有锁,可以尝试通过 CAS 获取写锁。

  4. 如果当前线程已经持有写锁,可以重入。

4. 读锁获取条件

简单理解:

  1. 如果其他线程持有写锁,获取失败。

  2. 如果当前线程自己持有写锁,可以获取读锁。

  3. 如果没有其他线程持有写锁,可以尝试通过 CAS 获取读锁。

  4. 需要根据公平性策略判断是否允许当前读线程获取。

重点:

读锁使用 AQS 共享模式,写锁使用 AQS 独占模式。

五、锁降级与锁升级

这是本节最重要、也最容易混淆的知识点。

1. 什么是锁降级?

锁降级指:

当前线程持有写锁时,再获取读锁,然后释放写锁,最终只保留读锁。

例如:

线程 A 获取写锁

读锁 = 0
写锁 = 1

       ↓

线程 A 获取读锁

读锁 = 1
写锁 = 1

       ↓

线程 A 释放写锁

读锁 = 1
写锁 = 0

最终线程 A 只持有读锁。

这就是锁降级。

2. 为什么需要锁降级?

假设有一个缓存数据。

线程 A 需要:

  1. 检查缓存是否需要更新。

  2. 更新缓存。

  3. 更新后继续读取缓存。

  4. 保证读取期间数据不会被其他线程修改。

如果直接释放写锁,再重新获取读锁:

线程 A 获取写锁

更新缓存

释放写锁

重新获取读锁

读取缓存

这里存在一个时间窗口:

释放写锁
    |
    v
其他线程可能获取写锁
    |
    v
修改缓存
    |
    v
线程 A 获取读锁

线程 A 读取的就可能不是刚刚更新的那个版本。

因此,可以使用锁降级。

3. 锁降级完整代码

import java.util.concurrent.locks.ReentrantReadWriteLock;

public class LockDowngradeDemo {

    private static final ReentrantReadWriteLock LOCK =
            new ReentrantReadWriteLock();

    private static final ReentrantReadWriteLock.ReadLock READ_LOCK =
            LOCK.readLock();

    private static final ReentrantReadWriteLock.WriteLock WRITE_LOCK =
            LOCK.writeLock();

    private static String data = "旧数据";

    public static void main(String[] args) {

        WRITE_LOCK.lock();

        try {

            // 获取写锁,更新数据
            data = "新数据";

            // 写锁尚未释放时,先获取读锁
            READ_LOCK.lock();

        } finally {

            // 释放写锁
            WRITE_LOCK.unlock();
        }

        try {

            // 此时仍然持有读锁
            System.out.println("读取数据:" + data);

        } finally {

            READ_LOCK.unlock();
        }
    }
}

执行过程:

获取写锁
   |
   v
更新数据
   |
   v
获取读锁
   |
   v
释放写锁
   |
   v
保留读锁
   |
   v
读取数据
   |
   v
释放读锁

获取读锁必须发生在释放写锁之前,否则就不能保证锁降级过程的连续性。

4. 什么是锁升级?

锁升级指:

当前线程已经持有读锁,又尝试获取写锁。

例如:

READ_LOCK.lock();

try {

    // 读取数据

    WRITE_LOCK.lock();

} finally {

    WRITE_LOCK.unlock();

    READ_LOCK.unlock();
}

这种方式存在问题。

假设线程 A 持有读锁,又尝试获取写锁。

但是写锁要求没有其他线程持有读锁,包括当前线程自己。

于是:

线程 A:

持有读锁
    |
    v
尝试获取写锁
    |
    v
写锁要求释放读锁
    |
    v
但线程 A 还在等待写锁

造成自己等待自己,形成死锁。

因此:

ReentrantReadWriteLock 不支持直接从读锁升级为写锁。

5. 如果确实需要从读操作转为写操作怎么办?

推荐方式:

先释放读锁,再获取写锁,并在获取写锁后重新检查业务条件。

READ_LOCK.lock();

try {

    if (需要更新数据()) {

        // 先释放读锁
        READ_LOCK.unlock();

        WRITE_LOCK.lock();

        try {

            // 重新检查条件
            if (仍然需要更新数据()) {

                更新数据();
            }

        } finally {

            WRITE_LOCK.unlock();
        }

        return;
    }

    // 正常读取数据

} finally {

    // 这里需要根据实际锁持有状态管理释放,
    // 实际项目更推荐将读阶段和写阶段拆分为独立方法。
}

关键点:

  1. 先释放读锁。

  2. 再获取写锁。

  3. 获取写锁后重新检查条件。

  4. 条件仍然满足才执行更新。

为什么必须重新检查?

因为释放读锁和获取写锁之间,可能有其他线程修改了数据。

六、生产环境实战:缓存读写场景

读写锁最典型的应用场景就是读多写少的数据缓存。

例如:

  • 查询商品:大量请求。

  • 更新商品:少量请求。

可以使用 ReentrantReadWriteLock 保护缓存。

1. 完整代码

import org.springframework.stereotype.Service;

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

@Service
public class ProductCacheService {

    private final Map<String, String> cache =
            new HashMap<>();

    private final ReentrantReadWriteLock LOCK =
            new ReentrantReadWriteLock();

    private final ReentrantReadWriteLock.ReadLock READ_LOCK =
            LOCK.readLock();

    private final ReentrantReadWriteLock.WriteLock WRITE_LOCK =
            LOCK.writeLock();

    // 查询商品
    public String getProduct(String productId) {

        READ_LOCK.lock();

        try {

            return cache.get(productId);

        } finally {

            READ_LOCK.unlock();
        }
    }

    // 更新商品
    public void updateProduct(
            String productId,
            String productInfo) {

        WRITE_LOCK.lock();

        try {

            cache.put(productId, productInfo);

        } finally {

            WRITE_LOCK.unlock();
        }
    }

    // 删除商品
    public void deleteProduct(String productId) {

        WRITE_LOCK.lock();

        try {

            cache.remove(productId);

        } finally {

            WRITE_LOCK.unlock();
        }
    }
}

2. 为什么查询使用读锁?

因为查询不会修改共享数据。

多个线程可以同时读取 HashMap。

3. 为什么更新使用写锁?

因为 HashMap 不是线程安全的。

如果多个线程同时修改 HashMap,可能出现线程安全问题。

因此需要写锁保护。

4. 读写锁一定比 ConcurrentHashMap 好吗?

不一定。

如果只是简单的 Map 查询和更新,可以优先考虑:

ConcurrentHashMap<String, String> cache =
        new ConcurrentHashMap<>();

ConcurrentHashMap 已经提供了并发访问能力。

读写锁更适合:

  • 读操作远多于写操作。

  • 多个操作需要共同维护一个数据状态。

  • 需要保证多个关联字段的一致性。

  • 需要对一组复合操作提供统一的互斥保护。

注意:

使用读写锁保护 Map 时,不能将内部可变对象直接返回给外部,再让外部无锁修改其字段,否则仍然可能出现线程安全问题。

另外,Spring 单例 Bean 中的 ReentrantReadWriteLock 只在当前 JVM 内有效,不能用于多个 Docker 容器之间的互斥。

七、ReentrantReadWriteLock 的公平性

ReentrantReadWriteLock 也支持公平锁和非公平锁。

创建方式:

// 默认非公平锁
ReentrantReadWriteLock lock1 =
        new ReentrantReadWriteLock();

// 公平锁
ReentrantReadWriteLock lock2 =
        new ReentrantReadWriteLock(true);

对比

公平模式

非公平模式

获取顺序

尽量按等待顺序

允许插队

线程等待

相对有序

可能长时间等待

吞吐量

通常较低

通常较高

非公平模式下,读线程可能连续获取读锁,影响等待中的写线程。

公平模式则尽量按照队列中的等待顺序安排读写线程。

注意:

ReadLock.tryLock() 和 WriteLock.tryLock() 的无参版本都不保证遵守公平性设置。

需要遵守公平策略时,可以使用带超时参数的 tryLock 方法。

八、读写锁的局限性

读写锁虽然能够提高读并发能力,但不是所有场景都适合。

  1. 写操作频繁:写线程竞争频繁,读锁的共享优势可能无法充分发挥。

  2. 读操作很短:读写锁本身的维护成本可能反而更高。

  3. 写线程可能等待较久:尤其是非公平模式下,读线程持续竞争可能影响写线程获取锁。

  4. 不支持直接锁升级:需要释放读锁,再获取写锁,并重新检查条件。

  5. 读锁不能创建 Condition:调用 ReadLock.newCondition() 会抛出 UnsupportedOperationException。

但是写锁可以创建 Condition:

Condition condition = WRITE_LOCK.newCondition();

因为 Condition 的等待和唤醒需要独占锁语义。

九、重点总结

知识点

核心结论

ReentrantReadWriteLock

基于 AQS 实现的读写锁

ReadLock

共享模式

WriteLock

独占模式

读读共享

多个线程可以同时读取

读写互斥

不同线程的读写不能同时进行

写写互斥

同一时间只能一个线程写入

state 高 16 位

读锁总持有次数

state 低 16 位

写锁重入次数

读锁重入

支持

写锁重入

支持

锁降级

写锁降为读锁,支持

锁升级

读锁升级为写锁,不支持

公平锁

尽量按等待顺序获取锁

非公平锁

允许插队,通常吞吐量更高

需要重点记住:

  1. 读写锁的核心规则是读读共享、读写互斥、写写互斥。

  2. 读锁使用 AQS 共享模式,写锁使用独占模式。

  3. AQS 的一个 state 被分成高 16 位和低 16 位。

  4. 高 16 位记录所有线程的读锁总持有次数。

  5. 低 16 位记录写锁重入次数。

  6. 读锁和写锁都支持可重入。

  7. 写锁持有者可以获取读锁,实现锁降级。

  8. 读锁不能直接升级为写锁。

  9. 锁升级需要先释放读锁,再获取写锁,并重新检查业务条件。

  10. 读写锁适合读多写少的场景,但不一定比 ConcurrentHashMap 更好。

  11. ReadLock 不支持 Condition,WriteLock 支持 Condition。

  12. ReentrantReadWriteLock 只在当前 JVM 内有效。

十、常见问题与答案

1. 为什么需要读写锁?

普通互斥锁不允许多个线程同时读取,而读写锁允许读读共享,可以提高读多写少场景的并发能力。

2. 读写锁的三个核心规则是什么?

读读共享、读写互斥、写写互斥。

3. ReentrantReadWriteLock 的读锁和写锁分别属于 AQS 什么模式?

读锁属于共享模式,写锁属于独占模式。

4. 一个 state 为什么可以同时记录读锁和写锁?

因为将 32 位拆成两部分,高 16 位记录读锁数量,低 16 位记录写锁重入次数。

5. 为什么读锁需要记录每个线程自己的持有次数?

因为多个线程共享读锁,需要知道每个线程分别获取了多少次,防止错误释放其他线程的读锁。

6. 什么是锁降级?

持有写锁时先获取读锁,再释放写锁,最终保留读锁。

7. 为什么允许锁降级?

可以在完成写操作后继续持有读锁,避免释放写锁和重新获取读锁之间出现其他线程修改数据的时间窗口。

8. 为什么不支持锁升级?

当前线程持有读锁时,写锁需要等待所有读锁释放,包括当前线程自己的读锁,因此会造成自我等待。

9. 读锁和写锁都支持可重入吗?

支持。读锁记录每个线程自己的持有次数,写锁通过 state 记录重入次数。

10. 什么时候适合使用读写锁?

读多写少、多个线程频繁读取共享数据,且需要对写入操作进行互斥保护的场景。

11. ReentrantReadWriteLock 和 ConcurrentHashMap 应该如何选择?

简单的 Map 并发访问优先考虑 ConcurrentHashMap;需要保护多个关联数据或复合操作的一致性时,可以考虑读写锁。

十一、复习测试

  1. ReentrantLock 和 ReentrantReadWriteLock 最大的区别是什么?

  2. 读读共享、读写互斥、写写互斥分别是什么意思?

  3. 为什么读写锁可以同时允许多个线程读取?

  4. ReentrantReadWriteLock 的 state 为什么要拆分成高 16 位和低 16 位?

  5. state 高 16 位和低 16 位分别记录什么?

  6. 为什么读锁使用 AQS 共享模式,写锁使用独占模式?

  7. 读锁和写锁是否都支持可重入?

  8. 什么是锁降级?为什么要先获取读锁,再释放写锁?

  9. 为什么读锁不能直接升级成写锁?

  10. 如果业务确实需要从读操作转为写操作,应该怎么处理?

  11. 为什么读写锁适合读多写少的场景?

  12. ReentrantReadWriteLock 和 ConcurrentHashMap 应该如何选择?

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!

评论