首页>>后端>>java->可重入读写锁ReentrantReadWriteLock源码分析

可重入读写锁ReentrantReadWriteLock源码分析

时间:2023-12-06 本站 点击:0

写在前面

前面系列文章中,我们已经分析了 JUC 下的核心实现,诸如 AQS 等等。

ReentrantReadWriteLock 的基础是 AQS,因此最好先掌握 AQS 相关原理,你可以先了解本文前置知识点:

AQS原理分析 -- 心法篇

ThreadLocal原理分析

一、起源:

1. 为什么出现读写锁?

ReentrantLock 具有完全互斥排他的效果,也就是同一时间只有一个线程在执行 ReentrantLock.lock() 方法后面的任务。

虽然这样做保证了实例变量的线程安全性,但并行效率比较底下;所以读写锁的出现,将读、写加锁操作分开处理,可以加快运行效率;

2. 读写锁适用场合

相比于 ReentrantLock 适用于一般场合,ReadWriteLock 适用于读多写少的情况,合理使用可以进一步提高并发效率。

3. 读写锁的设计

读写锁有两个锁,一个是读操作相关的锁,称为共享锁;另一个是写操作相关的锁,也叫排他锁。多个读锁之间不互斥,读锁与写锁互斥, 写锁与写锁互斥。

这两把锁中,第 1 把锁是写锁,获得写锁之后,既可以读数据又可以修改数据,而第 2 把锁是读锁,获得读锁之后,只能查看数据,不能修改数据。读锁可以被多个线程同时持有,所以多个线程可以同时查看数据。

在读的地方合理使用读锁,在写的地方合理使用写锁,灵活控制,可以提高程序的执行效率

ReentrantReadWriteLock 使用了一个 16 位的状态来表示写入锁的计数,并且使用了另一个 16 位的状态来表示读取锁的计数。

在读取锁上的操作将使用共享的获取方法与释放方法,在写入锁的操作将使用独占的获取方法与释放方法。

4. 使用案例

    /**     * 读写锁     *     * 读读共享、写写互斥、读写互斥、写读互斥     */    public class ReentrantReadWriteLockExample {       private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();       public void read() {           try {               readWriteLock.readLock().lock();               System.out.println("获取读锁:" + Thread.currentThread().getName()                       + " " + System.currentTimeMillis());               Thread.sleep(10000);           } catch (Throwable t) {               t.printStackTrace();           } finally {               readWriteLock.readLock().unlock();           }       }       @Test       public void testRR() {           ReentrantReadWriteLockExample example = new ReentrantReadWriteLockExample();           Thread t1 = new Thread(() -> {               example.read();           });           t1.setName("T1");           t1.start();           Thread t2 = new Thread(() -> {               example.read();           });           t2.setName("T2");           t2.start();           //获取读锁:T1 1596420044601           //获取读锁:T2 1596420044605       }       public void write() {           try {               readWriteLock.writeLock().lock();               System.out.println("获取写锁:" + Thread.currentThread().getName()                       + " " + System.currentTimeMillis());               Thread.sleep(10000);           } catch (Throwable t) {               t.printStackTrace();           } finally {               readWriteLock.writeLock().unlock();           }       }       @Test       public void testWW() throws InterruptedException {           ReentrantReadWriteLockExample example = new ReentrantReadWriteLockExample();           Thread t1 = new Thread(() -> {               example.write();           });           t1.setName("T1");           t1.start();           Thread t2 = new Thread(() -> {               example.write();           });           t2.setName("T2");           t2.start();           Thread.sleep(10000);           //获取写锁:T1 1596420243034           //获取写锁:T2 1596420253039       }       @Test       public void testRW() throws InterruptedException {           ReentrantReadWriteLockExample example = new ReentrantReadWriteLockExample();           Thread t1 = new Thread(() -> {               example.read();           });           t1.setName("T1");           t1.start();           Thread t2 = new Thread(() -> {               example.write();           });           t2.setName("T2");           t2.start();           Thread.sleep(10000);           //获取读锁:T1 1596420330107           //获取写锁:T2 1596420340111       }    }

从以上例子中可以看到

当测试读读模式时(testRR), 两个线程基本上同时获取到读锁,可见是读读共享;

当测试写写模式时(testWW),第二个线程基本上等了 10s 才获取到写锁,因此是写写互斥;

当测试读写模式时(testRW), 第二个线程基本上也是等了 10s 才获取到锁,因此是读写互斥;同理,写读也是互斥。

二、原理分析:

在 ReentrantReadWriteLock 实现中,state 的含义:

高16位表示读锁的重入次数(共享模式,多个线程持有,因此 重入次数=每个线程重入次数之和)

  static final int SHARED_SHIFT   = 16;  static final int SHARED_UNIT    = (1 << SHARED_SHIFT);

因为是在高 16 存读锁的重入次数,因此每重入 1 次,就需要加一个SHARED_UNIT,即 65535。

将高 16 位向左移动 16 位也就得到了读锁的重入次数

 static int sharedCount(int c)    { return c >>> SHARED_SHIFT; }

低16位表示写锁的重入次数(独占模式,每次也就一个线程持有)

因此将高 16 位抹去就是写锁的重入次数

static final int SHARED_SHIFT   = 16;static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;       static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }

提供 HoldCounter 结构,保存每个读线程以及其重入次数

    /**     * A counter for per-thread read hold counts.     * Maintained as a ThreadLocal; cached in cachedHoldCounter     */    static final class HoldCounter {        int count = 0;        // Use id, not reference, to avoid garbage retention        final long tid = getThreadId(Thread.currentThread());    }

通过 ThreadLocal 来保证线程间变量的隔离,也就是每个线程都有自己相对应的 HoldCounter 对象

    private transient ThreadLocalHoldCounter readHolds;            /**     * ThreadLocal subclass. Easiest to explicitly define for sake     * of deserialization mechanics.     */    static final class ThreadLocalHoldCounter        extends ThreadLocal<HoldCounter> {        public HoldCounter initialValue() {            return new HoldCounter();        }    }

通过 cachedHoldCounter 来缓存最近一个获取读锁成功的线程的HoldCounter,这样可以避免 ThreadLocal 查找也就是每次先从用这个字段判断,如果不行的话在从 ThreadLocal中 去查询

private transient HoldCounter cachedHoldCounter;

firstReader 字段记录第一个获取读锁成功的线程,firstReaderHoldCount 也就是相应的 HoldCount 信息。

因为第一个线程比较特殊,它是共享模式下第一个将 state 从 "0" 变成 "1",也就是只要线程持有读锁,firstReader 就不为空, 如果 firstReader = null,就说明没有线程持有读锁

private transient Thread firstReader = null;private transient int firstReaderHoldCount;

将上面这几个关键变量搞清楚了,理解 ReentrantReadWriteLock 就比较容易了

三、源码分析

一)Sync源码实现

Sync 提供共享模式、独占模式基础实现

1、独占模式

1.1、tryAcquire 尝试获取锁资源

        protected final boolean tryAcquire(int acquires) {            Thread current = Thread.currentThread();            // 获取重入次数            int c = getState();            // 获取独占锁的重入次数            int w = exclusiveCount(c);            // 说明读锁或者写锁中至少有一个被线程持有了            if (c != 0) {                // (Note: if c != 0 and w == 0 then shared count != 0)                // 如果没有写锁,那肯定就是存在读锁了(读写互斥)                // 或者是,已经存在写锁,但不是当前线程                if (w == 0 || current != getExclusiveOwnerThread())                    return false;                // 如果写锁重入次数超过65535                    if (w + exclusiveCount(acquires) > MAX_COUNT)                    throw new Error("Maximum lock count exceeded");                // Reentrant acquire                // 读锁重入                setState(c + acquires);                return true;            }            // writerShouldBlock这个就是模版方法,实现类有公平锁和非公平锁,用于判断是否需要抢占资源            // 如果是非公平锁,始终返回false,表示可以抢占锁资源            // 如果是公平锁,需要判断等待队列中是否有等待线程,如果有的话,就不能抢占资源,只能排队等待获取资源            if (writerShouldBlock() ||                !compareAndSetState(c, c + acquires))                return false;            // 到这里就肯定是能获取锁资源了,就直接设置该锁对象被自己(current thread)独占                setExclusiveOwnerThread(current);            return true;        }        //

tryAcquire 是用于独占模式下的获取资源的方法,在这里也就是用于写锁,主要流程

如果存在读锁,那此次尝试获取写锁肯定失败,因为读写互斥嘛

如果写锁超过了最大可重入次数 65535,也不行

writerShouldBlock 用于公平性实现,在公平锁实现中,如果等待队列存在等待线程,就不能抢占线程资源,乖乖的排队等候;

如果是非公平锁的实现,则可以尝试抢占锁资源      

1.2、tryRelease 尝试是否锁资源

用于独占模式下的锁资源释放,这里也就是写锁的资源释放

        protected final boolean tryRelease(int releases) {            if (!isHeldExclusively())                throw new IllegalMonitorStateException();            // 剩余重入次数                int nextc = getState() - releases;            // 计算得到写锁的剩余重入次数,如果等于0,说明写锁已经完全释放            // 相关exclusiveOwnerThread字段设置为null            boolean free = exclusiveCount(nextc) == 0;            if (free)                setExclusiveOwnerThread(null);            setState(nextc);            return free;        }

释放资源比较简单,用总的资源量(总重入次数) - 当前需要释放的资源量 得到剩余资源量,如果 剩余资源量等于 0 就说明可以完全释放写锁了

2、共享模式

2.1 、tryAcquireShared

在共享模式下尝试获取资源,这里也就是针对读锁

  static final int SHARED_SHIFT   = 16;  static final int SHARED_UNIT    = (1 << SHARED_SHIFT);0

这个方法是在共享模式下获取资源,在 ReentrantReadWriteLock 也就是获取读锁资源,有以下几个步骤:

先判断是否存在写锁,如果存在的话,必须是当前线程才能获取读锁(锁降级,后面介绍)

公平性判断是否争抢读锁资源,默认是非公平模式

通过 fullTryAcquireShared,自旋 + CAS 进行资源获取,因为在多线程竞争情况下,CAS是有可能失败的

再来回顾下 AQS 中三类返回值的含义,在共享模式下,此值对于等待队列中节点唤醒(传播模式)比较重要

大于0, 表明此次获取资源成功,同时还有资源,可以继续向后传播唤醒等待队列中的节点

等于0,表明此次获取资源成功,但没有更多资源了,不可向后传播

小于0,表明此次获取资源失败,同时不可向后传播

2.1.1 fullTryAcquireShared

  static final int SHARED_SHIFT   = 16;  static final int SHARED_UNIT    = (1 << SHARED_SHIFT);1

整个方法还是比较常规的,总的来说就是通过 自旋+CAS 来保证在多线程竞争竞争下获取到资源;对于记录每个线程的读锁重入次数,用到了一些属性

firstReader:记录的是第一个获取此读锁的线程,用于后面一些判读操作,如果是第一个线程重入的话,直接使用其配套的 firstReaderHoldCount 变量累加即可

cachedHoldCounter:记录的是最近的一个线程对应的 HoldCounter,这样的话就可以免去从 ThreadLocal 中取值的过程

readHolds:使用 ThreadLocal 线程间变量隔离,如果上面两步都没有成功,就从这里取。

2.2 tryReleaseShared

共享模式下的资源释放,这里指读锁资源的释放

  static final int SHARED_SHIFT   = 16;  static final int SHARED_UNIT    = (1 << SHARED_SHIFT);2

在读锁这种情况下使用了自旋来保证可以正释放资源,因为读锁是可以被多个线程持有,因此 CAS 操作也是可用失败的。

2.3 tryWriteLock

尝试获取写锁

  static final int SHARED_SHIFT   = 16;  static final int SHARED_UNIT    = (1 << SHARED_SHIFT);3

2.4 tryReadLock

尝试获取读锁

  static final int SHARED_SHIFT   = 16;  static final int SHARED_UNIT    = (1 << SHARED_SHIFT);4

通过自旋+CAS保证获取到锁资源

2.5 getReadHoldCount

获取读锁的重入次数

  static final int SHARED_SHIFT   = 16;  static final int SHARED_UNIT    = (1 << SHARED_SHIFT);5

二)Sync 应用

1. FairSync 公平锁

  static final int SHARED_SHIFT   = 16;  static final int SHARED_UNIT    = (1 << SHARED_SHIFT);6

只要等待队列存在等待节点就不能抢占资源,只能按照FIFO的方法乖乖的排队去获取资源,也就是不允许插队

2. NonfairSync 非公平锁

  static final int SHARED_SHIFT   = 16;  static final int SHARED_UNIT    = (1 << SHARED_SHIFT);7

需要注意的一点是 在读锁模式的非公平性判断中,为了避免需要写锁的线程无限制的等待, 如果等待队列第一个节点是想要获取写锁的线程,那这里读锁就不去争抢锁资源了,乖乖的排队获取资源,就可以避免写锁线程"饥饿状态"

为啥写锁线程会出现"饥饿状态"?

假设现在等待队列里首个等待的节点是需要获得写锁的线程B,但是目前线程A已经持有了读锁,因此线程B只能等待

这个时候如果来了线程C,想要获取读锁(读读是可以的哈),如果我们的策略允许它去抢占资源的话,线程C就可以抢到读锁,相当于读锁一直不释放,写锁根本没有办法获取到

这个时候如果还有很多线程来获取读锁,如果策略是允许抢占资源的话,那线程B就会一直被"凉着",也就是"饥饿状态"

3. ReadLock 读锁(共享模式)

  static final int SHARED_SHIFT   = 16;  static final int SHARED_UNIT    = (1 << SHARED_SHIFT);8

可以看到底层实现都是委托给Sync处理  

4. WriteLock 写锁(独占模式)

  static final int SHARED_SHIFT   = 16;  static final int SHARED_UNIT    = (1 << SHARED_SHIFT);9

可见写锁的底层实现也是委托给 Sync 实现

三)锁降级

来看一个例子:

 static int sharedCount(int c)    { return c >>> SHARED_SHIFT; }0

锁降级是指同一个线程,先获取写锁,后面可以再获取读锁

为什么需要锁的降级?

从例子中可以看出,如果我们实际中只需要对小部分数据做修改,大部分时间都是在使用数据,如果使用写锁,虽然安全,但是效率不高;因此可以使用锁降级来处理

为什么不支持锁的升级?

读写锁的特点是如果线程都申请读锁,是可以多个线程同时持有的,可是如果是写锁,只能有一个线程持有,并且不可能存在读锁和写锁同时持有的情况。

正是因为不可能有读锁和写锁同时持有的情况,所以升级写锁的过程中,需要等到所有的读锁都释放,此时才能进行升级。

假设有 A,B 和 C 三个线程,它们都已持有读锁。假设线程 A 尝试从读锁升级到写锁。那么它必须等待 B 和 C 释放掉已经获取到的读锁。如果随着时间推移,B 和 C 逐渐释放了它们的读锁,此时线程 A 确实是可以成功升级并获取写锁。

但是考虑一种特殊情况。假设线程 A 和 B 都想升级到写锁,那么对于线程 A 而言,它需要等待其他所有线程,包括线程 B 在内释放读锁。而线程 B 也需要等待所有的线程,包括线程 A 释放读锁。这就是一种非常典型的死锁的情况。谁都愿不愿意率先释放掉自己手中的锁

当然,如果可以保证每次只有一个线程可以升级,那么就可以保证线程安全

四、总结

AQS 在内部维护一个等待线程队列,其中记录了某个线程请求的是独占方法还是共享访问。

在 ReentrantReadWriteLock 中,当锁可用时,如果位于队列头部的线程执行写入操作(想要获取写锁),那么线程会得到这个锁,如果位于队列头部的线程执行读取访问操作(想要获取读锁), 那么队列中的第一个写入线程之前的所有线程都将获得这个锁;

对于第二种情况,为了防止写锁线程发生"饥饿问题"

公平实现中是 FIFO 顺序执行,只要队列有线程在排队,是不允许"插队"情况,因此不会有这个问题

非公平实现中,假设目前想要获取读锁,如果发现此时等待队列中的第一个节点是需要获取写锁,那么这个时候就不去抢占获取资源,让这个写锁有机会去获取资源,来避免线程饥饿问题。

读写锁的获取规则

如果有一个线程已经占用了读锁,则此时其他线程如果要申请读锁,可以申请成功

如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁,因为读写不能同时操作

如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,都必须等待之前的线程释放写锁,同样也因为读写不能同时,并且两个线程不应该同时写

总结为:读读共享、读写互斥、写读互斥、写写互斥

升降级策略:只能从写锁降级为读锁,不能从读锁升级为写锁

原文:https://juejin.cn/post/7100933816142954532 相关文档:

AQS原理分析 -- 心法篇

ThreadLocal原理分析


本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:/java/15919.html