深入理解读写锁ReentrantReadWriteLock
前言
业务开发中我们可能涉及到读写操作。
面对写和读,对于数据同步,在使用Lock锁和 synchronized关键字同步数据时候,对于读读而言,两个线程也需要争抢锁,此时额外争抢锁是没有意义的,造成性能损耗,写的时候,不能读,没有写的时候,读线程不能互斥。
对于 Lock锁和 synchronized 来说。都是互斥锁,读读也存在互斥。
针对这种场景,JAVA的并发包提供了读写锁ReentrantReadWriteLock,它内部,维护了 一对相关的锁,一个用于只读操作,称为读锁;一个用于写入操作,称为写锁
如实例:
生产者和消费者而言
当一个线程负责生产,2个线程负责消费,生产者没有进行生产时,两个消费线程都可以去消费数据(这里我们不考虑 重复数据问题)
两个线程彼此还要争抢资源
private static final int LINED_SIZE = 1000;
private static int num = 0;
private static final Object lock = new Object();
private static final LinkedList<Integer> linkedList = new LinkedList<>();
public static void main(String[] args) throws InterruptedException {
t1.start();
t2.start();
t3.start();
t1.setPriority(5);
t2.setPriority(5);
t3.setPriority(5);
t1.join();
t2.join();
t3.join();
TimeUnit.SECONDS.sleep(2);
System.out.println(" main end ");
}
static class ConsumerObje implements Runnable {
@Override
public void run() {
while (true){
synchronized (lock) {
while (linkedList.size() == 0) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
try {
Thread.sleep(5_00);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " : " + linkedList.removeFirst());
lock.notifyAll();
}
}
}
}
static class ProductObje implements Runnable {
@Override
public void run() {
while (true) {
synchronized (lock) {
while (linkedList.size() >= LINED_SIZE) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
int n = num++;
System.out.println(" 正在生产: " + n);
linkedList.addLast(n);
lock.notifyAll();
}
}
}
}
思考
-
为什么需要使用读写锁ReentrantReadWriteLock
-
这个锁有什么好处?缺点?
好处: 读读不能互斥,提升锁性能,减少线程竞争。 缺点是:当读锁过多时候,写锁少,存在锁饥饿现象。
读写锁ReentrantReadWriteLock用法详解
ReentrantReadWriteLock 也提供了公平和非公平锁
基于构造默认非公平锁, ReentrantReadWriteLock 读写锁内部也是基于AQS队列实现的。
public ReentrantReadWriteLock() {
this(false);
}
//读写锁
private static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock(true);
//写锁
private final static Lock writeLock = readWriteLock.writeLock();
//读锁
private final static Lock readLock = readWriteLock.readLock();
private final static List<Long> longs = new ArrayList<>();
public final static void main(String[] args) throws InterruptedException {
// new Thread(ReentrantReadWriteLockTest::write).start();
// TimeUnit.SECONDS.sleep(1);
// new Thread(ReentrantReadWriteLockTest::write).start();
new Thread(ReentrantReadWriteLockTest::write).start();
TimeUnit.SECONDS.sleep(1);
new Thread(ReentrantReadWriteLockTest::read).start();
new Thread(ReentrantReadWriteLockTest::read).start();
}
static void write() {
try {
writeLock.lock();
TimeUnit.SECONDS.sleep(1);
System.out.println(Thread.currentThread().getName() + " write ");
longs.add(System.currentTimeMillis());
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
writeLock.unlock();
}
}
static void read() {
try {
readLock.lock();
TimeUnit.SECONDS.sleep(1);
longs.forEach(x -> System.out.println(x));
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
readLock.lock();
}
}
可以看到我们写了一条数据,两条数据同时打印出来,读读是不互斥的。
Thread-0 write 1648997092090 1648997092090
读写锁 存在一个问题: 当读锁比例很多,写锁很少,锁竞争情况下,写锁抢到锁的机会就回少,读锁数量太大的情况下,写锁不一定能抢到锁.
我们使用非公平锁,来测试,启动5个读锁,一个写锁。
//读写锁
private static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock(false);
//写锁
private final static Lock writeLock = readWriteLock.writeLock();
//读锁
private final static Lock readLock = readWriteLock.readLock();
private final static List<Long> longs = new ArrayList<>();
public final static void main(String[] args) throws InterruptedException {
new Thread(ReentrantReadWriteLockTest2::write).start();
TimeUnit.SECONDS.sleep(1);
//new Thread(ReentrantReadWriteLockTest2::read).start();
//new Thread(ReentrantReadWriteLockTest2::read).start();
for (int i = 0; i <5; i++) {
new Thread(ReentrantReadWriteLockTest2::read).start();
}
}
static void write() {
for (;;){
try {
writeLock.lock();
TimeUnit.SECONDS.sleep(1);
System.out.println(Thread.currentThread().getName() + " write ");
longs.add(System.currentTimeMillis());
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
writeLock.unlock();
}
}
}
static void read() {
for (;;){
try {
readLock.lock();
TimeUnit.SECONDS.sleep(1);
longs.forEach(x -> System.out.println(x));
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
readLock.lock();
}
}
}
测试结果这里就不写了,刚开始一直写,后来一直读,写锁机会很少,当读线程比例再大时,写的机会就更少了。
ReentrantReadWriteLock 原理剖析
ReentrantReadWriteLock 支持读锁和写锁
ReentrantReadWriteLock是可重入的读写锁实现类。在它内部,维护了一对相关的锁, 一个用于只读操作,另一个用于写入操作。只要没有 Writer 线程,读锁可以由多个 Reader 线程同时持有。也就是说,写锁是独占的,读锁是共享的。
实现了读写锁的接口
既然支持读写锁,那读锁和写锁都需要一个状态去保存锁的状态,在 aqs 中是使用变量state变量进行保存的。
ReentrantReadWriteLock 中是如何保存的呢?
在源码里可以看到
在 ReentrantLock 中,使用 Sync ( 实际是 AQS )的 int 类型的 state 来表示同步状态,表示锁 被一个线程重复获取的次数。
但是,读写锁 ReentrantReadWriteLock 内部维护着一对读写 锁,如果要用一个变量维护多种状态,需要采用“按位切割使用”的方式来维护这个变量,将 其切分为两部分:高16为表示读,低16为表示写。( 类似 线程池的状态和工作线程数量)
获取锁方法可以看到,获取锁然后再根据这个计算线程数量 ,这个方法是是写锁释放锁
状态计算
通过位运算。假如当前同步状态为S, 那么: 写状态,等于 S & 0x0000FFFF(将高 16 位全部抹去)。 当写状态加1,等于S+1. 读状态,等于 S >>> 16 (无符号补 0 右移 16 位)。 当读状态加1,等于 S+(1<<16),也就是S+0x00010000 。
根据状态的划分能得出一个推论:S不等于0时,当写状态(S&0x0000FFFF)等于0时,则读状 态(S>>>16)大于0,即读锁已被获取。
exclusiveCount(int c) 表示获得持有写状态的锁的次数。 sharedCount(int c) 表示获得持有读状态的锁的线程数量。不同于写锁,读锁可以同时被多个线程持有。而每个线程持有的读锁支持重入的特性,所以需要对每个 线程持有的读锁的数量单独计数,这就需要用到 HoldCounter 计数器,实际上是 ThreadLocal 保存每一个读线程锁重入的次数。
HoldCounter 计数器
读锁的内在机制其实就是一个共享锁。一次共享锁的操作就相当于对HoldCounter 计数器 的操作。获取共享锁,则该计数器 + 1,释放共享锁,该计数器 - 1。只有当线程获取共享锁后 才能对共享锁进行释放、重入操作。
HoldCounter是用来记录读锁重入数的对象 ThreadLocalHoldCounter是ThreadLocal变量,用来存放不是第一个获取读锁的线 程的其他线程的读锁重入数对象
第一个获取读锁的重入次数 可以看到 用一个变量保存 ,以及保存线程。
写锁
加锁
acquire(int arg) 方法 ,可以看到这里获取锁失败,还是加入到同步队列里,还是aqs里的方法 ,排它锁的node节点 很熟悉。
acquireQueued 方法里就不用看了,也是aqs里的设置唤醒节点为-1状态,然后unpark 阻塞。
可以看到写锁是一个支持重进入的排它锁。
如果当前线程已经获取了写锁,则增加写状态。
如果当 前线程在获取写锁时,读锁已经被获取(读状态不为0)或者该线程不是已经获取写锁的线程, 则当前线程进入等待状态。如上面的aqs 同步队列以及unpark阻塞那段代码。
解锁
解锁很简单,和之前解锁几乎一样,唯一不一样的地方就是从高位获取锁。
读锁
加锁
通过重写AQS的tryAcquireShared方法和 tryReleaseShared方法。
看代码意思大致是
首先这里是获取写锁 然后判断是否是当前线程,这里是读写锁降级。获取读锁失败返回
下面获取读锁,这里就简单了一看就能看到,先判断第一个读锁线程以及数量, 是则设置第一个,没有就设置 1 有就+1。
不是则从 ThreadLocal 里获取设置,没有就设置 1 有就+1。
这里是读锁是否阻塞,公平锁和非公平锁的实现。可以不用管。
compareAndSetState(c, c + SHARED_UNIT) cas这行失败则表示有竞争,则执行下面代码,进行自旋重试。
解锁 这里没啥可说的,如果是第一个读锁则设置线程是null,重入次数-1 不是则从 ThreadLocal 里拿,然后进行设置以及-1。
最后cas更新读锁状态数量。
释放锁
doReleaseShared
也是共享锁的释放逻辑,还是aqs里的逻辑,很熟悉。
最后
读写锁的实现继承图
ReentrantReadWriteLock 读写锁既有有点也有缺点
好处: 读读不能互斥,提升锁性能,减少线程竞争。 缺点是:当读锁过多时候,写锁少,存在锁饥饿现象。
使用时候需要控制读写比例,防止出现锁饥饿现象。
当出现读比例特别大时候,ReentrantReadWriteLock锁就不适合了,此时JDK8之后提供的StampedLock锁更适合读写比例大的场景
设计的精髓的地方
- 1 一个变量保存 2 个状态 和 线程池里类似
- 2 读锁的可重入使用 ThreadLocal 进行存储
- 3 写锁可以重入
- 4 写锁降级(没释放锁时候获取读锁,保证数据的一致性)