快速回顾

  • 读写锁在同一时刻允许多个线程访问
  • 当一个写线程访问时,所有其他线程阻塞
  • 当一个读线程访问时,其他读线程不会被阻塞
  • 读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。
  • 在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量

    特性

  • 支持公平锁与非公平锁

  • 可重入
  • 支持锁降级:遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级为读锁

    API

  • ReentrantReadWriteLock.ReadLock readLock(): 获取读锁

  • ReentrantReadWriteLock.WriteLock writeLock(): 获取写锁
  • int getReadLockCount():返回当前读锁被获取到的次数,与线程数无关,同一个线程获取n次 返回n
  • int getReadHoldCount():返回当前线程获取读锁的次数
  • boolean isWriteLocked():判断读锁是否被获取了
  • int getWriteHoldCount():获取当前写锁被获取的次数

概述

ReentrantReadWriteLock,也叫读写锁,在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。

读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。除了保证写操作对读操作的可见性以及并发性的提升之外,读写锁能够简化读写交互场景的编程方式。假设在程序中定义一个共享的用作缓存数据结构,它大部分时间提供读服务(例如查询和搜索),而写操作占有的时间很少,但是写操作完成之后的更新需要对后续的读服务可见。

在没有读写锁支持的(Java 5 之前)时候,如果需要完成上述工作就要使用 Java 的等待通知机制,就是当写操作开始时,所有晚于写操作的读操作均会进入等待状态,只有写操作完成并进行通知之后,所有等待的读操作才能继续执行(写操作之间依靠 synchronized 关键进行同步),这样做的目的是使读操作能读取到正确的数据,不会出现脏读。

改用读写锁实现上述功能,只需要在读操作时获取读锁,写操作时获取写锁即可。当写锁被获取到时,后续(非当前写操作线程)的读写操作都会被阻塞,写锁释放之后,所有操作继续执行,编程方式相对于使用等待通知机制的实现方式而言,变得简单明了。

一般情况下,读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量,ReentrantReadWriteLock 其实实现的是 ReadWriteLock 接口。
实际上,上面的可以总结为三种情况:

  1. 读读并发,即同一时刻可以允许多个读线程访问,共享。
  2. 读写互斥,即在写线程访问时,所有的读线程和其他写线程均被阻塞。
  3. 写写互斥,在写线程访问时,其他写线程均被阻塞。

    使用

    读读并发

  1. public static void main(String[] args) {
  2. //读写锁
  3. ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
  4. //读锁
  5. Lock readLock = rwl.readLock();
  6. //写锁
  7. Lock writeLock = rwl.writeLock();
  8. //线程1
  9. new Thread(()->{
  10. readLock.lock();
  11. try {
  12. for (int i = 0; i < 10; i++) {
  13. read();
  14. }
  15. } finally {
  16. readLock.unlock();
  17. }
  18. },"t1").start();
  19. //线程2
  20. new Thread(()->{
  21. readLock.lock();
  22. try {
  23. for (int i = 0; i < 10; i++) {
  24. read();
  25. }
  26. } finally {
  27. readLock.unlock();
  28. }
  29. },"t2").start();
  30. }
  31. public static void read() {
  32. System.out.println("我是" + Thread.currentThread().getName());
  33. try {
  34. //休眠1s是为了更清楚演示
  35. Thread.sleep(1000);
  36. } catch (InterruptedException e) {
  37. e.printStackTrace();
  38. }
  39. }
  1. 我是t1
  2. 我是t2
  3. 我是t2
  4. 我是t1
  5. 我是t2
  6. 我是t1
  7. 我是t1
  8. 我是t2
  9. ......

可以看到t1在 for 循环没有执行完成之前,t2也可以拿到锁。

读写互斥

当把t2换成写锁时:

  1. //线程2
  2. new Thread(()->{
  3. writeLock.lock();
  4. try {
  5. for (int i = 0; i < 10; i++) {
  6. read();
  7. }
  8. } finally {
  9. writeLock.unlock();
  10. }
  11. },"t2").start();
  1. 我是t1
  2. 我是t1
  3. 我是t1
  4. 我是t1
  5. 我是t1
  6. 我是t1
  7. 我是t1
  8. 我是t1
  9. 我是t1
  10. 我是t1
  11. 我是t2
  12. 我是t2
  13. ......

可以看到t1在 for 循环执行完成之后,t2才拿到锁开始执行,读写都互斥,写写就不用说了吧。

读写锁的适用场景

在一些共享资源的读和写操作,且写操作没有读操作那么频繁的场景下可以用读写锁。
常见的有:

  • 商品的库存,因为一般看的人多,买的人少。
  • 缓存,多线程更新和获取。

    原理

    读写状态的设计

    读写锁有两个锁,而且他们是有关系的,显然是用一个AQS,但是一个AQS只有一个state。
    为了用一个state表示两种锁的状态就需要对状态拆分,int是32位,所以拆成两个部分,高16位标识读状态,低16位标识写状态。
    在设置或者获取状态时需要做一些位运算

    写锁的获取与释放

    写锁是一个支持重进入的排它锁。若写状态为0且读状态为0,直接获取写锁;若写状态大于0,看获取同步状态的线程是不是当前线程,是则写状态+1,否则,构建节点进入同步队列,自旋阻塞。
    写锁的释放与ReentrantLock的释放过程基本类似,每次释放写状态-1,当写状态为0时表示写锁已被释放,唤醒同步队列里的线程,同时前次写线程的修改对后续读写线程可见。

    1. final boolean tryWriteLock() {
    2. Thread current = Thread.currentThread();
    3. int c = getState();
    4. if (c != 0) {
    5. int w = exclusiveCount(c); // c与0x0000FFFF求与
    6. // c!=0但是低16位为0表示有人拿了读锁,有人拿了读锁是不能获取写锁的
    7. if (w == 0 || current != getExclusiveOwnerThread())
    8. return false;
    9. // MAX_COUNT是0xffff, 因为写状态是只有16位的
    10. if (w == MAX_COUNT)
    11. throw new Error("Maximum lock count exceeded");
    12. }
    13. if (!compareAndSetState(c, c + 1))
    14. return false;
    15. setExclusiveOwnerThread(current);
    16. return true;
    17. }
    18. // 这没啥好说的
    19. protected final boolean tryRelease(int releases) {
    20. if (!isHeldExclusively())
    21. throw new IllegalMonitorStateException();
    22. int nextc = getState() - releases;
    23. boolean free = exclusiveCount(nextc) == 0;
    24. if (free)
    25. setExclusiveOwnerThread(null);
    26. setState(nextc);
    27. return free;
    28. }

    读锁的获取与释放

    读锁是一个支持重进入的共享锁,它能够被多个线程同时获取,写状态位0时总能获取,写状态不为0,不会获取,获取成功读状态就+1。
    释放的时候呢就是读状态-1,减到0就是读锁释放了。

    锁降级

    锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。
    直接搬书上的一个例子 ```java class Process { private ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(); private ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock(); private ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock(); private boolean isUpdate = false; private LinkedList linkedList = new LinkedList<>();

    public void processData() {

    1. readLock.lock();
    2. if (!isUpdate) {
    3. // 必须先释放读书
    4. readLock.unlock();
    5. writeLock.lock();
    6. try {
    7. if (!isUpdate) {
    8. // 模拟生产数据
    9. for (int i = 0; i < 1000; i++) {
    10. linkedList.push(i);
    11. }
    12. isUpdate = true;
    13. }
    14. readLock.lock(); // 1
    15. } finally {
    16. writeLock.unlock();
    17. }
    18. // 写锁降级为读锁
    19. }
    20. // 2
    21. try {
    22. while (!linkedList.isEmpty()) {
    23. System.out.println(Thread.currentThread().getName() + "消费数据:" + linkedList.poll());
    24. }
    25. } finally {
    26. readLock.unlock();
    27. }

    } }

public class Demo_03_04_5_LockChange { public static void main(String[] args) { Process process = new Process(); for (int i = 0; i < 10; i++) { new Thread(() -> { process.processData(); }).start(); } } } ```

为什么要锁降级

:防止当前线程的写锁修改数据后还没准确的读到就被其他线程拿到写锁后修改了
主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁, 假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。

假设1不加读锁的话,线程A执行到2这个位置,写锁已经释放了,读锁也没有,所可能恰好此时有别的线程(线程B)获取到了写锁,又修改了数据,此时线程A或的数据还是之前的,这就是他没有感知到线程B对数据的更新。