1、ReentrantReadWriteLock (可重入读写锁)
1.1、在前面我们剖析了ReentrantLock(可重入锁),其实现是使用了AQS同步器来实现的,我们知道ReentrantLock是以独占的方式来实现锁互斥的,也就是说,当去获取锁的时候,如果获取成功就会将当前锁的独占线程设置为当前线程,在锁被占有阶段当其他线程来申请锁的时候,就会被加入到AQS的同步队列中进行等待,当持有锁的线程释放锁,就会去唤醒同步队列中的head节点的next节点的线程,让当前线程重新去申请锁,如果不考虑其他因素,是一定能够申请到锁的。这个是ReentrantLock的实现原理。
1.2、那么什么是ReentrantReadWriteLock,从字面的意思来看就是可重入的读写锁。它分为两把锁 读锁跟写锁,且都是可重入的。
读锁跟写锁的互斥特性:
读锁 跟 读锁 :是不互斥的,如果多条线程同时读取共享资源,那么是不会发生线程阻塞的。
读锁 跟 写锁 :是互斥的,也就是说当有线程持有读锁,此时去申请写锁的线程将会被阻塞,同理,当有线程持有 写锁,如果此时有线程去申请读锁也会被阻塞。
写锁 跟 写锁 :是互斥的,当有线程持有写锁,那么再去申请的线程就会被阻塞。
1.3、ReentrantReadWriteLock的实现原理:
我们知道ReentrantLock的实现中,获取锁失败的线程会被构建节点node加入的AQS的通过不队列中,且node的mode(模式)是EXCLUSIVE(独占),其实ReentrantReadWriteLock也是使用AQS的同步队列来实现的,且申请写锁的线程也是构建独占的node加入到AQS的同步队列,但是申请读锁的线程是SHARED共享模式的node加入到同步队列中的。
ReentrantReadWriteLock 的队列唤醒规则:
1、当ReentrantReadWriteLock 的等待队列队首结点是共享结点,说明当前写锁被占用,当写锁释放时,会以传播的方式唤醒头结点之后紧邻的各个共享结点。
2、当ReentrantReadWriteLock 的等待队列队首结点是独占结点,说明当前读锁被使用,当读锁释放归零后,会唤醒队首的独占结点。
1.4、ReentrantReadWriteLock的弊端:
在上面我们分析ReentrantReadWriteLock的基本规则与实现方式,我么会发现一个问题,那就是在非公平的环境中读多写少的情况下,写线程会发生线程饥饿,什么意思呢?我们假设有一个ReentrantReadWriteLock读写锁,当前是读锁被占有的,且有一个申请写锁的线程被阻塞,因为读锁是不互斥的,这个时候大量的读操来读取数据,这个时候就会造成那一条申请写锁的线程会一直被阻塞,这就造成了写线程的饥饿。因此在jdk1.8的时候,提供了一个StampLock来解决这个问题。
2、StampLock
在讲StampLock之前我们先讲一下锁升级\锁降级
2.1、什么是锁的升降级?
锁升级:读锁 --> 写锁,意思就是一条线程在不释放读锁的情况下去申请写锁,如果申请到了,再将读锁释放掉,当前线程就从获取到读锁升级到了写锁。 支持锁升级的锁有:StampLock。
锁降级:写锁-->读锁,意思就是一条线程在不释放写锁的情况下,去申请读锁,如果申请到了,再将写锁释放掉,那么当前线程就顺利的从写锁降级到读锁了。支持锁降级的锁有:ReentrantReadWriteLock、StampLock。
2.2、什么是StampLock?
StampLock在JDK1.8时引入,是对读写锁ReentrantReadWriteLock的增强,该类提供了一些功能,优化了读锁、写锁的访问,同时使读写锁之间可以互相转换,更细粒度控制并发。在上面我们发现了ReentrantReadWriteLock会产生写线程的饥饿问题,因此StampLock就是为了优化ReentrantReadWriteLock写线程饥饿问题而产生的。
2.3、StampLock的特点:
1、所有获取锁的方法,都返回一个邮戳(Stamp),Stamp为0表示获取失败,其余都表示成功;
2、所有释放锁的方法,都需要一个邮戳(Stamp),这个Stamp必须是和成功获取锁时得到的Stamp一致;
3、StampedLock是不可重入的;(如果一个线程已经持有了写锁,再去获取写锁的话就会造成死锁)
4、StampedLock有三种访问模式:
①Reading(读模式):功能和ReentrantReadWriteLock的读锁类似
②Writing(写模式):功能和ReentrantReadWriteLock的写锁类似
③Optimistic reading(乐观读模式):这是一种优化的读模式。
5、StampedLock支持读锁和写锁的相互转换我们知道RRW中,当线程获取到写锁后,可以降级为读锁,但是读锁是不 能直接升级为写锁的。StampedLock提供了读锁和写锁相互转换的功能,使得该类支持更多的应用场景。
6、无论写锁还是读锁,都不支持Conditon等待
2.4、StampLock的使用案例(我们使用官网的案例):
public class Point {
private double x, y;
private final StampedLock sl = new StampedLock();
public void move(double deltaX, double deltaY) {
使用写锁-独占操作,并返回一个邮票
long stamp = sl.writeLock();
try {
x += deltaX;
y += deltaY;
} finally {
使用邮票来释放写锁
sl.unlockWrite(stamp);
}
}
使用乐观读锁访问共享资源
注意:乐观读锁在保证数据一致性上需要拷贝一份要操作的变量到方法栈,并且在操作数据时候可能其
他写线程已经修改了数据,而我们操作的是方法栈里面的数据,也就是一个快照,所以最多返回的不是
最新的数据,但是一致性还是得到保障的。
public double distanceFromOrigin() {
使用乐观读锁-并返回一个邮票,乐观读不会阻塞写入操作,从而解决了写操作线程饥饿问题。
long stamp = sl.tryOptimisticRead();
拷贝共享资源到本地方法栈中
double currentX = x, currentY = y;
if (!sl.validate(stamp)) {
如果验证乐观读锁的邮票失败,说明有写锁被占用,可能造成数据不一致,
所以要切换到普通读锁模式。
stamp = sl.readLock();
try {
currentX = x;
currentY = y;
} finally {
sl.unlockRead(stamp);
}
}
如果验证乐观读锁的邮票成功,说明在此期间没有写操作进行数据修改,那就直接使用共享数据。
return Math.sqrt(currentX * currentX + currentY * currentY);
}
锁升级:读锁--> 写锁
public void moveIfAtOrigin(double newX, double newY) { // upgrade
// Could instead start with optimistic, not read mode
long stamp = sl.readLock();
try {
while (x == 0.0 && y == 0.0) {
读锁转换为写锁
long ws = sl.tryConvertToWriteLock(stamp);
if (ws != 0L) {
如果升级到写锁成功,就直接进行写操作。
stamp = ws;
x = newX;
y = newY;
break;
} else {
//如果升级到写锁失败,那就释放读锁,且重新申请写锁。
sl.unlockRead(stamp);
stamp = sl.writeLock();
}
}
} finally {
//释放持有的锁。
sl.unlock(stamp);
}
}
}
2.5、StampLock的实现原理:
上面我们提到了ReentrantReadWriteLock的实现方式是使用AQS的同步队列来实现的,在队列中write 锁的节点node类型是独占(EXCLUSIVE),read 锁的节点node类型是共享(SHARED), 如果唤醒的write锁的节点,只会唤醒当前一个write 锁的节点,当唤醒的是一个read锁的节点就会逐个唤醒后续的read锁节点(跟CountDownLatch的传递性唤醒是一个道理),直到又碰见一个write锁节点为止。
StampLock的实现原理也是使用了AQS的同步队列来实现的,但是多个read锁节点相邻的时候,它并不是放入到AQS的同步队列中,而是会有一个cwait的节点用来存放相邻的read锁的节点,唤醒的时候则唤醒AQS中的当前read锁节点+cwait的所有read锁节点。这就是StampLock的实现原理与ReentrantReadWriteLock的实现原理的区别。示意图如下:
2.6、StampedLock的等待队列与ReentrantReadWriteLock的AQS同步队列相比,有以下特点:
1、 当入队一个线程时,如果队尾是读结点,不会直接链接到队尾,而是链接到该读结点的cowait链中,cowait链本质是一个栈;
2、当入队一个线程时,如果队尾是写结点,则直接链接到队尾;
3、唤醒线程的规则和AQS类似,都是首先唤醒队首结点。区别是StampedLock中,当唤醒的结点是读结点时,会唤醒该读结点的cowait链中的所有读结点(顺序和入栈顺序相反,也就是后进先出)。