四、ReadWriteLock知识点梳理
1、什么是读写锁
读写锁遵循以下三条基本原则:
- 允许多个线程同时读共享变量
- 只允许一个线程写共享变量
- 如果一个写线程正在执行写操作,此时禁止读线程读共享变量
读写锁与互斥锁的一个重要区别就是读写锁允许多个线程同时读共享变量,而互斥锁是不允许的,这是读写锁在读多写少场景下性能优于互斥锁的关键。但读写锁的写操作是互斥的,当一个线程在写共享变量的时候,是不允许其他线程执行写操作和读操作
ReentrantReadWriteLock的特性:
特性 | 说明 |
---|---|
公平性选择 | 支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平 |
可重入 | 该锁支持重进入,以读写线程为例:读线程在获取了读锁之后,能够再次获取读锁。而写线程在获取了写锁之后能够再次获取写锁,同时也可以获取读锁 |
锁降级 | 遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁 |
接口ReadWriteLock仅定义了获取读锁和写锁的两个方法,即readLock()和writeLock(),其实现ReentrantReadWriteLock还提供了一些便于外界监控其内部工作状态的方法
读写锁类似于ReentrantLock,也支持公平模式和非公平模式。只有写锁支持条件变量,读锁是不支持条件变量的,读锁调用newCondition()会抛出UnsupportedOperationException的异常
2、快速实现一个缓存
public class Cache<K, V> {
final Map<K, V> map = new HashMap<>();
final ReadWriteLock rwl = new ReentrantReadWriteLock();
//读锁
final Lock readLock = rwl.readLock();
//写锁
final Lock writeLock = rwl.writeLock();
//读缓存
V get(K key) {
readLock.lock();
try {
return map.get(key);
} finally {
readLock.unlock();
}
}
//写缓存
V put(K key, V value) {
writeLock.lock();
try {
return map.put(key, value);
} finally {
writeLock.unlock();
}
}
}
3、实现缓存的按需加载
public class Cache<K, V> {
final Map<K, V> map = new HashMap<>();
final ReadWriteLock rwl = new ReentrantReadWriteLock();
//读锁
final Lock readLock = rwl.readLock();
//写锁
final Lock writeLock = rwl.writeLock();
V get(K key) {
V v = null;
//读缓存
readLock.lock();
try {
v = map.get(key);
} finally {
readLock.unlock();
}
//缓存中存在直接返回
if (v != null) {
return v;
}
//缓存中不存在,查询数据库
writeLock.lock(); //(1)
try {
//再次验证,其他线程可能已经查询过数据库
v = map.get(key); //(2)
if (v == null) { //(3)
//查询数据库
v = 省略代码无数
map.put(key, v);
}
} finally {
writeLock.unlock();
}
return v;
}
}
在代码(2)、(3)处,重新验证了一次缓存是否存在,再次验证如果还是不存在,我们才去查询数据库并更新本地缓存。为什么我们要再次验证呢?
原因是在高并发的场景下,有可能会有多线程竞争写锁。假设缓存是空,没有缓存任何东西,如果此时有三个线程T1、T2、T3同时调用get()方法,并且参数key也是相同的。那么它们会同时执行到代码(1)处,但此时只有一个线程能够获得写锁,假设是线程T1,线程T1获取写锁之后查询数据库并更新缓存,最终释放写锁。此时线程T2和T3会再有一个线程能够获取写锁,假设是T2,如果不采用再次验证的方式,此时T2会再次查询数据库。T2释放写锁之后,T3也会再次查询一次数据库。而实际上线程T1已经把缓存的值设置好了,T2、T3完全没有必要再次查询数据库。所以,再次验证的方式,能够避免高并发场景下重复查询数据的问题
4、读写锁的降级
锁降级指的是写锁降级成为读锁。锁降级是指当前拥有写锁,再获取到读锁,随后释放当前拥有的写锁的过程
ReentrantReadWriteLock不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程)。目的也是保证数据可见性,如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了数据,则其更新对其他获取到读锁的线程是不可见的
锁降级的使用场景:对变量的值比较敏感,对一个变量进行修改,下面马上要对变量进行读操作
public class CachedData {
Object data;
volatile boolean cacheValid;
final ReadWriteLock rwl = new ReentrantReadWriteLock();
//读锁
final Lock readLock = rwl.readLock();
//写锁
final Lock writeLock = rwl.writeLock();
void processCachedData() {
//获取读锁
readLock.lock();
if (!cacheValid) {
//释放读锁,因为不允许读锁的升级
readLock.unlock();
//获取写锁
writeLock.lock();
try {
//再次检查状态
if (!cacheValid) {
data = ...
cacheValid = true;
}
//释放写锁前,降级为读锁
readLock.lock();
} finally {
//释放写锁
writeLock.unlock();
}
//此处依然持有读锁
try {
use(data);
} finally {
readLock.unlock();
}
}
}
}
五、读写锁ReentrantReadWriteLock实现原理
1、类图结构
读写锁的内部维护了一个ReadLock和一个WriteLock,它们依赖Sync实现具体功能。 而Sync继承自AQS,并且也提供了公平和非公平的实现。下面只介绍非公平的读写锁实现。AQS中只维护了一个state状态,而ReentrantReadWriteLock需要维护读状态和写状态。如果在一个整型变量上维护多种状态,就一定需要按位切割这个变量,读写锁将变量切分成了两个部分,高16位表示读,低16位表示写
当前同步状态表示一个线程已经获取了写锁,且重进入了两次,同时也连续获取了两次读锁。读写锁通过位运算确定读和写各自的状态,假设当前同步状态值为S,写状态等于S&0x0000FFFF
(将高16位全部抹去),读状态等于S>>>16
(无符号补0右移16位)。当写状态增加1时,等于S+1,当读状态增加1时,等于S+(1<<16)
,也就是S+0x00010000
S不等于0时,当写状态(S&0x0000FFFF
)等于0时,则读状态(S>>>16
)大于0,即读锁已被获取
static final int SHARED_SHIFT = 16;
//共享锁(读锁)状态单位值65536
static final int SHARED_UNIT = (1 << SHARED_SHIFT);
//共享锁线程最大个数65535
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1;
//排他锁(写锁)掩码,二进制,15个1
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
//返回读锁线程数
static int sharedCount(int c) { return c >>> SHARED_SHIFT; }
//返回写锁可重入个数
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
Sync的两个内部类和几个属性:
//用来保存一个线程读锁重入的次数
static final class HoldCounter {
int count = 0;
final long tid = getThreadId(Thread.currentThread());
}
/**
* 继承了ThreadLocal,通过ThreadLocal实现记录线程获取读锁次数的记录
* key是Thread对象的引用,value是HoldCounter对象
* HoldCounter对象中保存了获取读锁次数count和线程tid
*/
static final class ThreadLocalHoldCounter
extends ThreadLocal<HoldCounter> {
public HoldCounter initialValue() {
return new HoldCounter();
}
}
//保存线程获取读锁的次数
private transient ThreadLocalHoldCounter readHolds;
/**
* 最后一个成功获取读锁的线程对应的HoldCounter对象引用,是一个缓存
* 当获取和释放读锁的时候,需要更新HoldCount,会先检测缓存的线程ID是否和当前线程ID相同
* 如果相同,就直接通过缓存引用来更新HoldCount值
* 否则,重新从readHolds中获取HoldCounter对象,同时更新这个缓存
*/
private transient HoldCounter cachedHoldCounter;
//第一个获取读锁的线程
private transient Thread firstReader = null;
//firstReader获取了多少次读锁
private transient int firstReaderHoldCount;
以上几个字段主要用于提升效率,如只有一个线程的时候,只会操作firstReaderHoldCount这个整型,以及防止写锁饥饿等
2、写锁的获取与释放
在ReentrantReadWriteLock中写锁使用WriteLock来实现
1)、void lock()
写锁是个独占锁,某时只有一个线程可以获取该锁。如果当前没有线程获取到读锁和写锁,则当前线程可以获取到写锁然后返回。如果当前已经有线程获取到读锁和写锁, 则当前请求写锁的线程会被阻塞挂起。另外,写锁是可重入锁,如果当前线程已经获取了该锁,再次获取只是简单地把可重入次数加1后直接返回
WriteLock的lock方法:
public void lock() {
sync.acquire(1);
}
AQS的acquire方法:
public final void acquire(int arg) {
//sync重写的tryAcquire方法
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
Sync重写的tryAcquire方法:
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
int w = exclusiveCount(c);
//c!=0说明读锁或者写锁已经被某线程获取
if (c != 0) {
//w==0说明已经有线程获取了读锁,只有w!=0且是当前线程占用了锁(可重入锁)
if (w == 0 || current != getExclusiveOwnerThread())
return false;
//说明当前线程获取了写锁,判断可重入次数
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
//设置可重入次数
setState(c + acquires);
return true;
}
//(1)第一个写线程获取写锁
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}
如果AQS的状态值等于0则说明目前没有线程获取到读锁和写锁没所以执行代码(1)。其中,对于writerShouldBlock方法,非公平锁的实现为:
final boolean writerShouldBlock() {
return false; // writers can always barge
}
如果代码对于非公平锁来说总是返回false,则说明代码(1)抢占式执行CAS尝试获取写锁,获取成功则设置当前锁的持有者为当前线程并返回true,否则返回false
公平锁的实现为:
final boolean writerShouldBlock() {
return hasQueuedPredecessors();
}
这里还是使用hasQueuedPredecessors来判断当前线程节点是否有前驱结点,如果有则当前线程放弃获取写锁的权限,返回false
2)、void unlock()
WriteLock的unlock方法:
public void unlock() {
sync.release(1);
}
AQS的release方法:
public final boolean release(int arg) {
//调用sync中实现的tryRelease方法
if (tryRelease(arg)) {
//激活阻塞队里面的一个线程
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
Sync重写的tryRelease方法:
protected final boolean tryRelease(int releases) {
//检查是否是写锁拥有者调用的unlock
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
int nextc = getState() - releases;
boolean free = exclusiveCount(nextc) == 0;
//因为可重入,只有当exclusiveCount(nextc)==0时候,才会真正释放锁
if (free)
setExclusiveOwnerThread(null);
setState(nextc);
return free;
}
3、读锁的获取与释放
在ReentrantReadWriteLock中读锁使用ReadLock来实现
1)、void lock()
获取读锁,如果当前没有其他线程持有写锁,则当前线程可以获取读锁,AQS的状态值state的高16位的值会增加1,然后方法返回。否则如果其他一个线程持有写锁,则当前线程会被阻塞
ReadLock的lock方法:
public void lock() {
sync.acquireShared(1);
}
AQS的acquireShared方法:
public final void acquireShared(int arg) {
//调用sync中实现的tryAcquireShared方法
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
Sync重写的tryAcquireShared方法:
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
//获取当前状态值
int c = getState();
//查看是否是有其他线程获取到了写锁,如果是则直接返回-1,而后调用AQS的doAcquireShared方法把当前线程放入AQS阻塞队列
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
//获取读锁计数
int r = sharedCount(c);
//尝试获取锁,多个读线程只有一个会成功,不成功的进入fullTryAcquireShared进行重试
if (!readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) {
//第一个线程获取读锁
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
//如果当前线程是第一个获取读锁的线程
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
//记录最后一个获取读锁的线程或记录其他线程读锁的可重入数
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
}
return 1;
}
//自旋获取读锁
return fullTryAcquireShared(current);
}
2)、void unlock()
ReadLock的unlock方法:
public void unlock() {
sync.releaseShared(1);
}
AQS的releaseShared方法:
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
Sync重写的tryReleaseShared方法:
protected final boolean tryReleaseShared(int unused) {
Thread current = Thread.currentThread();
if (firstReader == current) {
if (firstReaderHoldCount == 1)
firstReader = null;
else
firstReaderHoldCount--;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
rh = readHolds.get();
int count = rh.count;
if (count <= 1) {
readHolds.remove();
if (count <= 0)
throw unmatchedUnlockException();
}
--rh.count;
}
//循环直到自己的读计数-1,CAS更新成功
for (;;) {
int c = getState();
int nextc = c - SHARED_UNIT;
if (compareAndSetState(c, nextc))
return nextc == 0;
}
}
六、比读写锁更快的锁:StampedLock
1、StampedLock支持的三种模式
ReadWriteLock支持两种模式:一种是读锁,一种是写锁。而StampedLock支持三种模式,分别是:写锁、悲观读锁和乐观读。写锁、悲观读锁的语义和ReadWriteLock的写锁、读锁的语义非常类似,允许多个线程同时获取悲观读锁,但是只允许一个线程获取写锁,写锁和悲观读锁是互斥的。不同的是:StampedLock里的写锁和悲观读锁加锁成功之后,都会返回一个stamp;然后解锁的时候,需要传入这个stamp
final StampedLock sl = new StampedLock();
//获取/释放悲观读锁示意代码
long stamp = sl.readLock();
try {
//省略业务相关代码
} finally {
sl.unlockRead(stamp);
}
//获取/释放写锁示意代码
long stamp = sl.writeLock();
try {
//省略业务相关代码
} finally {
sl.unlockWrite(stamp);
}
StampedLock的性能之所以比ReadWriteLock要好关键是因为StampedLock支持乐观读的方式。ReadWriteLock支持多个线程同时读,但是当多个线程同时读的时候,所有的写操作会被阻塞;而StampedLock提供的乐观读,是允许一个线程获取写锁的,也就是说不是所有的写操作都被阻塞。乐观读操作是无锁的
public class Point {
private int x, y;
final StampedLock sl = new StampedLock();
//计算到原点的距离
double distanceFromOrigin() {
//乐观读
long stamp = sl.tryOptimisticRead();
//读入局部变量,
//读的过程数据可能被修改
int curX = x, curY = y;
//判断执行读操作期间,
//是否存在写操作,如果存在,sl.validate返回false
if (!sl.validate(stamp)) {
//升级为悲观读锁
stamp = sl.readLock();
try {
curX = x;
curY = y;
} finally {
//释放悲观读锁
sl.unlockRead(stamp);
}
}
return Math.sqrt(curX * curX + curY * curY);
}
}
在distanceFromOrigin()方法中,首先通过调用tryOptimisticRead()获取一个stamp,这里的tryOptimisticRead()就是乐观读。之后将共享变量x和y读入方法的局部变量中,由于tryOptimisticRead()是无锁的,所以共享变量x和y读入方法局部变量时,x和y有可能被其他线程修改了。因此最后读完之后,还需要再次验证一下是否存在写操作,这个验证操作是通过调用validate(stamp)来实现的
如果执行乐观读操作的期间,存在写操作,会把乐观读升级为悲观读锁。这个做法挺合理的,否则就需要在一个循环里反复执行乐观读,直到乐观读操作的期间没有写操作(只有这样才能保证x和y的正确性和一致性),而循环读会浪费大量的CPU
2、StampedLock使用注意事项
StampedLock不支持重入
StampedLock的悲观读锁、写锁都不支持条件变量
如果线程阻塞在StampedLock的readLock()或者writeLock()上时,此时调用该阻塞线程的interrupt()方法,会导致CPU飙升
使用StampedLock一定不要调用中断操作,如果需要支持中断功能,一定使用可中断的悲观读锁readLockInterruptibly()和写锁writeLockInterruptibly()
StampedLock读模板:
final StampedLock sl = new StampedLock();
//乐观读
long stamp = sl.tryOptimisticRead();
//读入方法局部变量
......
//校验stamp
if (!sl.validate(stamp)) {
//升级为悲观读锁
stamp = sl.readLock();
try {
//读入方法局部变量
.....
} finally {
//释放悲观读锁
sl.unlockRead(stamp);
}
}
//使用方法局部变量执行业务操作
......
StampedLock写模板:
long stamp = sl.writeLock();
try {
//写共享变量
......
} finally {
sl.unlockWrite(stamp);
}
3、StampedLock锁降级
StampedLock支持锁的降级(通过tryConvertToReadLock()方法实现)和升级(通过tryConvertToWriteLock()方法实现),下面的代码中存在一个Bug
private double x, y;
final StampedLock sl = new StampedLock();
//存在问题的方法
void moveIfAtOrigin(double newX, double newY) {
long stamp = sl.readLock();
try {
while (x == 0.0 && y == 0.0) {
long ws = sl.tryConvertToWriteLock(stamp);
if (ws != 0L) {
x = newX;
y = newY;
break;
} else {
sl.unlockRead(stamp);
stamp = sl.writeLock();
}
}
} finally {
sl.unlock(stamp);
}
}
在锁升级成功的时候,最后没有释放最新的锁,可以在if块的break上加一个stamp=ws进行释放