适用于读多写少的场景,特点是读读不互斥,读写与写写互斥。
private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private Lock r = readWriteLock.readLock();
private Lock w = readWriteLock.writeLock();
// 读操作上读锁
public Data get(String key) {
r.lock();
try {
// TODO 业务逻辑
}finally {
r.unlock();
}
}
// 写操作上写锁
public Data put(String key, Data value) {
w.lock();
try {
// TODO 业务逻辑
}finally {
w.unlock();
}
}
读写锁有一个顶层规范接口ReadWriteLock
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
我们常用的ReentrantReadWriteLock
,它就实现了ReadWriteLock
接口,并创建了两个内部类分别代表读锁和写锁。同时还有公平锁与非公平锁
public class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable {
private static final long serialVersionUID = -6992448646407690164L;
/** 读锁 */
private final ReentrantReadWriteLock.ReadLock readerLock;
/** 写锁 */
private final ReentrantReadWriteLock.WriteLock writerLock;
/** 同步器 同时还定义了三个内部类,用来满足公平锁与非公平锁的实现*/
final Sync sync;
public ReentrantReadWriteLock() {
this(false);
}
public ReentrantReadWriteLock(boolean fair) {
// 同步器 公平锁与非公平锁
// 这里先创建好了sync对象,然后再传给读写锁,为读写锁中的sync属性赋值
sync = fair ? new FairSync() : new NonfairSync();
// 读写锁
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}
// 实现ReadWriteLock接口,重写的两个方法
public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; }
public ReentrantReadWriteLock.ReadLock readLock() { return readerLock; }
......
}
ReentrantReadWriteLock
结构如下图所示,定义了五个内部类
Sync
类继承了AbstractQueuedSynchronizer
类,而公平锁与非公平锁的两个类继承了Sync
类
而读锁与写锁则是实现了Lock
接口
读锁:
// 读锁实现了Lock接口,也就是实现接口中的抽象方法。在加锁与解锁的时候就是调用同步器中对共享锁操作的方法
public static class ReadLock implements Lock, java.io.Serializable {
private static final long serialVersionUID = -5992448646407690164L;
private final Sync sync;
protected ReadLock(ReentrantReadWriteLock lock) {
// 确定公平锁与非公平锁实例,使用的是ReentrantReadWriteLock构造方法初始化时创建是sync
sync = lock.sync;
}
// 加锁,调用共享锁进行加锁
public void lock() {
sync.acquireShared(1);
}
...
// 解锁,调用共享锁的解锁方法
public void unlock() {
sync.releaseShared(1);
}
...
}
写锁:
public static class WriteLock implements Lock, java.io.Serializable {
private static final long serialVersionUID = -4992448646407690164L;
private final Sync sync;
// 确定公平锁与非公平锁实例,使用的是ReentrantReadWriteLock构造方法初始化时创建是sync
protected WriteLock(ReentrantReadWriteLock lock) {
sync = lock.sync;
}
// 加锁 调用的是独占锁加锁方法
public void lock() {
sync.acquire(1);
}
......
// 解锁 调用的是独占锁解锁方法
public void unlock() {
sync.release(1);
}
......
}
所以具体的实现还是在继承了AbstractQueuedSynchronizer
类的Sync
类中的tryAcquire() tryRelease() tryAcquireShared() tryReleaseShared()
这些方法中。
如何判断读锁与写锁的加锁状态
我们知道Sync
类继承了AbstractQueuedSynchronizer
类,是基于AQS来实现的,但是AQS中只有一个变量用来标识加锁的状态,那么应该如何实现一个字段来标记读锁与写锁两个状态嘞?
ReentrantReadWriteLock
它是使用了高低位的方式来实现的,state是一个int类型的变量,int在java中占4字节,也就是32bit,可以用高16位表示读锁状态,低16位表示写锁状态。
加读锁时就判断低16位是否为0;加写锁时就判断整个state是否为0,再去判断低16位是否为0。这样就知道了当前到底是加了读锁还是加了写锁。
通过高低位判断读写锁状态不难,如果还要满足可重入应该要如何实现嘞?
ThreadLocal
来记录各个线程自己的重入次数。读写状态设计
采用高低位来实现的
实现代码如下
abstract static class Sync extends AbstractQueuedSynchronizer {
static final int SHARED_SHIFT = 16;
static final int SHARED_UNIT = (1 << SHARED_SHIFT);
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1; // 因为只有16位来表示加锁次数,所以最大也就只能加2^16-1次
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
/** 读锁状态,直接将高16位往后移动16就是读锁的状态了 */
static int sharedCount(int c) { return c >>> SHARED_SHIFT; }
/** 独占锁状态,按位与即可 */
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
...
}
加写锁的代码如下,代码量不多
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
int w = exclusiveCount(c); // 低16位的值
if (c != 0) {
// 如果state不等于0,同时低16位等于0,那么就表示此时被加了读锁,当然就返回false加锁失败。
// 如果state不等于0,同时低16位不等于0,但是锁是别的线程加的,当然也就返回false加锁失败。
if (w == 0 || current != getExclusiveOwnerThread())
return false;
// 判断加锁次数是否超过最大允许的次数 2^16-1
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// 自增,只有一个线程,不需要CAS操作
setState(c + acquires);
return true;
}
// 下面的逻辑就表示state==0 还未被加锁,那么state+1 并为exclusiveOwnerThread属性赋值为当前线程
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}
加读锁做的事情要稍微多一点
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
int c = getState();
// 低16位不等于0 并且 加写锁的线程也不是当前线程,那么就直接返回,加锁失败
if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current)
return -1;
// 将高16位往后移的结果,加了读锁的次数
int r = sharedCount(c);
// 下面这个if就是state+1,因为是高16位,所以是直接加的65536
if (!readerShouldBlock() && r < MAX_COUNT && compareAndSetState(c, c + SHARED_UNIT)) {
// state加完后,再保存各个线程自己重入的次数
// r==0 就表示读锁次数为0,第一个线程来加读锁,保存第一个线程到firstReader属性中,并记录读锁的次数为1
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
// 假如是第一个线程,重入读锁次数自增
firstReaderHoldCount++;
} else {
// 上面两个if都是针对第一个获取到读锁的线程,后续加读锁的线程处理逻辑如下
// 这里封装了一个HoldCounter对象,里面有一个count属性代表重入次数
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
// readHolds其实就是ThreadLocal,把某个线程加了读锁的次数保存起来
// readHolds实例对象中 它重写了ThreadLocal的initialValue()方法,返回一个HoldCounter对象
// 所以这里第一次调用get()方法相当于是获取了一个新创建的HoldCounter对象
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
// 各个线程自己重入读锁次数自增
rh.count++;
}
return 1;
}
// 这是加共享锁完整的逻辑,和上面代码差不多,完整方法中它会循环CAS修改state的值
return fullTryAcquireShared(current);
}
其实从上面加读锁加写锁的两个if可以发现,同一个线程,如果先加了读锁再去加写锁是加不了的。但我先加了写锁,再去加读锁却是可以加的。这样也就引申出了锁降级的概念
锁降级指的是写锁降级成为读锁。
如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。
锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。锁降级可以帮助我们拿到当前线程修改后的结果而不被其他线程所破坏,防止更新丢失。
因为线程1如果不加读锁,直接释放写锁,那么假如此时来了个线程2进行了修改操作,此时线程1可能是感知不到线程2的数据更新的,读取到的数据还是线程1以前的值。所以线程1需要加读锁,线程2的修改线程需要暂时等待,等线程1释放读锁后,线程2才尝试去获取写锁。
RentrantReadWriteLock不支持锁升级,也就是先加读锁再加写锁。
创建一个读写锁类,去实现ReadWriteLock
接口,目的是尽量满足规范
实现了接口就要实现接口中的两个抽象方法,返回读锁和写锁。所以需要创建两个内部类,分别代表读锁和写锁,两个内部类都要实现Lock
接口。目的一是为了满足锁的规范,二是因为ReadWriteLock
接口中的抽象方法必须要求返回值是Lock
接下来就要实现读锁/写锁的加锁与释放锁逻辑。对于这种竞争锁、锁状态维护、竞争锁失败进阻塞队列…这种重复造轮子的活就没必要自己再去写一遍了,直接使用AQS即可
所以我们现在还需要定义一个内部类Sync
让这个类去继承AbstractQueuedSynchronizer
类,重点要重写的方法如下所示:
tryAcquire(int); tryRelease(int); tryAcquireShared(int); tryReleaseShared(int);
如果还想实现公平锁与非公平锁的话那么就再创建两个内部类,去继承Sync
类,再去写相应的逻辑
接下来直接在读锁和写锁的lock() unlock()
方法中直接调用上面四个方法即可。
重点,写第四步中四个方法的业务逻辑
咱们使用了AQS,但是这其中只有一个state标识位,我们可以采用高低位的方式来分别表示读锁和写锁的状态
并且还要思考如何实现可重入,写锁的可重入倒是简单,直接state低16位自增即可。读锁可以使用高16位来记录所有线程加读锁的次数,同时使用ThreadLocal来记录各个线程自己读锁的重入次数
ReentrantReadWriteLock它有个潜在的问题:如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这是一种悲观的读锁。
Java 8引入了新的读写锁:StampedLock
StampedLock和ReentrantReadWriteLock相比,改进之处在于:读的过程中也允许获取写锁后写入!在原先读写锁的基础上新增了一种叫乐观读(Optimistic Reading)的模式。该模式并不会加锁,所以不会阻塞线程,会有更高的吞吐量和更高的性能。
tryOptimisticRead()
才会返回非 0 的邮戳(Stamp),如果在获取乐观读之后没有出现写模式线程获取锁,则在方法validate返回 true ,允许多个线程获取乐观读以及读锁,同时允许一个写线程获取写锁。在使用乐观读的时候一定要按照固定模板编写,否则很容易出 bug,我们总结下乐观读编程模型的模板:
public void optimisticRead() {
// 1. 非阻塞乐观读模式获取版本信息
long stamp = lock.tryOptimisticRead();
// 2. 拷贝共享数据到线程本地栈中
copyVaraibale2ThreadMemory();
// 3. 校验乐观读模式读取的数据是否被修改过,validate()返回false则表示在乐观读过程中发生了写锁
if (!lock.validate(stamp)) {
// 3.1 校验未通过,上读锁
stamp = lock.readLock();
try {
// 3.2 拷贝共享变量数据到局部变量
copyVaraibale2ThreadMemory();
} finally {
// 释放读锁
lock.unlockRead(stamp);
}
}
// 3.3 校验通过,使用线程本地栈的数据进行逻辑操作
useThreadMemoryVarables();
}
思考:为何 StampedLock 性比 ReentrantReadWriteLock 好?
关键在于StampedLock 提供的乐观读。ReentrantReadWriteLock 支持多个线程同时获取读锁,但是当多个线程同时读的时候,所有的写线程都是阻塞的。StampedLock 的乐观读允许一个写线程获取写锁,所以不会导致所有写线程阻塞,也就是当读多写少的时候,写线程有机会获取写锁,减少了线程饥饿的问题,吞吐量大大提高。
思考:允许多个乐观读和一个写线程同时进入临界资源操作,那读取的数据可能是错的怎么办?
乐观读不能保证读取到的数据是最新的,所以将数据读取到局部变量的时候需要通过lock.validate(stamp)
校验是否被写线程修改过,若是修改过则需要上悲观读锁,再重新读取数据到局部变量。
使用案例
public class StampedLockTest{
public static void main(String[] args) throws InterruptedException {
Point point = new Point();
//第一次移动x,y
new Thread(()-> point.move(100,200)).start();
Thread.sleep(100);
new Thread(()-> point.distanceFromOrigin()).start();
Thread.sleep(500);
//第二次移动x,y
new Thread(()-> point.move(300,400)).start();
}
}
@Slf4j
class Point {
private final StampedLock stampedLock = new StampedLock();
private double x;
private double y;
public void move(double deltaX, double deltaY) {
// 获取写锁
long stamp = stampedLock.writeLock();
log.debug("获取到writeLock");
try {
x += deltaX;
y += deltaY;
} finally {
// 释放写锁
stampedLock.unlockWrite(stamp);
log.debug("释放writeLock");
}
}
public double distanceFromOrigin() {
// 获得一个乐观读锁
long stamp = stampedLock.tryOptimisticRead();
// 注意下面两行代码不是原子操作
// 假设x,y = (100,200)
// 此处已读取到x=100,但x,y可能被写线程修改为(300,400)
double currentX = x;
log.debug("第1次读,x:{},y:{},currentX:{}",
x,y,currentX);
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 此处已读取到y,如果没有写入,读取是正确的(100,200)
// 如果有写入,读取是错误的(100,400)
double currentY = y;
log.debug("第2次读,x:{},y:{},currentX:{},currentY:{}",
x,y,currentX,currentY);
// 检查乐观读锁后是否有其他写锁发生
if (!stampedLock.validate(stamp)) {
// 获取一个悲观读锁
stamp = stampedLock.readLock();
try {
currentX = x;
currentY = y;
log.debug("最终结果,x:{},y:{},currentX:{},currentY:{}",
x,y,currentX,currentY);
} finally {
// 释放悲观读锁
stampedLock.unlockRead(stamp);
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}
对于读多写少的高并发场景 StampedLock的性能很好,通过乐观读模式很好的解决了写线程“饥饿”的问题,我们可以使用StampedLock 来代替ReentrantReadWriteLock ,但是需要注意的是 StampedLock 的功能仅仅是 ReadWriteLock 的子集,在使用的时候,还是有几个地方需要注意一下。