写在前面
前面系列文章中,我们已经分析了 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原理分析