这是我参与8月更文挑战的第9天,活动详情查看: 8月更文挑战
StampedLock是Java8提供的一把新锁,主要目的是对ReentrantReadWriteLock读写锁的增强,在原有读写锁的基础上增加了一个乐观锁模式,并且在内部对读锁,写锁的访问API进行优化,也让读锁和写锁之间可以互相转换,让我们在使用的时候可以从更细粒度的维度为控制并发,减少加锁带来的性能减少问题。 ReentrantReadWriteLock锁这个具体的使用与介绍,这里就不介绍了,有感兴趣的朋友可以自行去查阅相关的资料了解,这篇文章主要是介绍StampedLock锁的相关特性、源码实现、以及如何使用等相关知识。
StampedLock的使用
在对StampedLock的实现原理分析之前,我们先来看下如何使用它,在这里就采用Oracle官方提供的方案来看下我们如何去使用它?官方案例地址:docs.oracle.com/javase/8/do…
public class StampedLockDemo {
/**
* 共享变量
*/
private double x, y;
/**
* 实例化StampedLock锁
*/
private final StampedLock sl = new StampedLock();
/**
* 移动坐标:写操作
*
* @param deltaX
* @param deltaY
*/
void move(double deltaX, double deltaY) {
//获取写锁
long stamp = sl.writeLock();
try {
x += deltaX;
y += deltaY;
} finally {
//操作完成后释放锁
sl.unlockWrite(stamp);
}
}
/**
* 使用乐观锁
*
* @return
*/
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);
}
/**
* 使用悲观读锁升级案例
* @param newX
* @param newY
*/
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) {
//转换成功,替换票据
stamp = ws;
x = newX;
y = newY;
break;
} else {
//读锁转换写锁失败,释放掉原有的读锁
sl.unlockRead(stamp);
//重新去获取写锁
stamp = sl.writeLock();
}
}
} finally {
//释放锁
sl.unlock(stamp);
}
}
}
从官网的实例,使用了方法:writeLock-获取写锁,readLock-获取读锁,tryOptimisticRead-获取乐观锁,tryConvertToWriteLock-将写锁升级为读写,unlockRead-释放锁,validate-对票据验证是否发生够修改 这几个方法。在使用乐观锁的时候,我们都需要判断拿到数据与获取到锁这段期间票据有没有发生过修改,如果发生了修改,我们需要重新去获取锁,可以直接获取悲观读写锁,防止采用乐观锁后,票据一直被修改。看官方提供的案例,使用起来比较比较清晰明了,但是在使用的时候我们一定要在finally中释放锁,不然锁一直不释放,后续的线程拿不到锁一直处于阻塞状态,我们接下来从源码角度看下是怎么实现的。
StampedLock相关源码解析
创建锁
StampedLock内部是基于CLH(CLH是一种基于单向链表的高性能、公平的自旋锁。申请加锁的线程通过前驱节点的变量进行自旋。在前置节点解锁后,当前节点会结束自旋,并进行加锁。在SMP架构下,CLH更具有优势。在NUMA架构下,如果当前节点与前驱节点不在同一CPU模块下,跨CPU模块会带来额外的系统开销,而MCS锁更适用于NUMA架构)旋锁实现,锁会在内部维护一个等待线程链表队列,在获取锁的时候,没有申请到锁的线程都以FIFO(先进先出)的策略记录到队列中,队列中每个节点代表一个线程,节点保存一个标记位判断当前线程是否已经释放锁。 在当一个线程视图获取锁的时候,首先会取得队列的尾部节点作为其前序节点(因为是FIFO队列模式,所以只需要判断其前面一个节点状态就行)是否释放锁,如果前面的尾部节点没有释放锁,就将自己加入到等到队列尾部中,然后自旋等待;如果当前的前序尾部线程已经释放锁,则获取锁,执行当前线程。 在StampedLock内部中维护了一个内部节点,对我们需要执行的线程进行了封装,也定义了比较多的常量值,在了解其他方法前,我们先看下内部定义的常量以及队列节点对象信息。
- 内部队列节点
static final class WNode {
//前一个节点指针
volatile WNode prev;
//后一个节点指针
volatile WNode next;
//读线程链表
volatile WNode cowait; // list of linked reader
//我们需要执行的线程对象
volatile Thread thread; // non-null while possibly parked
//节点状态:等待或者取消
volatile int status; // 0, WAITING, or CANCELLED
//节点模式:读或者写
final int mode; // RMODE or WMODE
WNode(int m, WNode p) { mode = m; prev = p; }
}
- 内部常量
//队列节点状态常量:等待
private static final int WAITING = -1;
//队列节点状态常量:取消
private static final int CANCELLED = 1;
//节点模式:读
private static final int RMODE = 0
//节点模式:写
private static final int WMODE = 1;
//队列头节点
private transient volatile WNode whead;
//队列尾节点
private transient volatile WNode wtail;
//读锁视图
transient ReadLockView readLockView;
//写锁视图
transient WriteLockView writeLockView;
//多锁视图
transient ReadWriteLockView readWriteLockView;
//当前锁的状态
private transient volatile long state;
//记录值
private transient int readerOverflow;
state:
-
记录当前锁的状态,是有写锁占用还是读锁占用,其中 long的倒数第八位是1,则表示由写锁占用(0000 0001),前七位由读锁占用(1-126)。
-
构造函数
public StampedLock() {
state = ORIGIN;
}
实例化一把锁,这是锁状态为:_ORIGIN(0000 0000)代_表空锁,这个时候锁已经初始化完毕,剩下就需要我们去获取锁来使用 _ _
获取锁
- writeLock-获取写锁
public long writeLock() {
long s, next; // bypass acquireWrite in fully unlocked case only
return ((((s = state) & ABITS) == 0L && U.compareAndSwapLong(this, STATE, s, next = s + WBIT)) ?
next : acquireWrite(false, 0L));
}
从获取写锁的源码看出进行了计算,判断当前读锁和写锁是否都未被使用,则获取成功并且更新state后,返回long next(计算的票据值),如果获取失败,则通过acquireWrite方法将写锁加入到等待队列中。 acquireWrite加入写锁等待队列的源码挺长的,就是对队列相关的操作,在这里就不去分析有兴趣的话,自己看下详细的源码仔细研究
- readLock-获取读锁
public long readLock() {
long s = state, next; // bypass acquireRead on common uncontended case
return ((whead == wtail && (s & ABITS) < RFULL &&
U.compareAndSwapLong(this, STATE, s, next = s + RUNIT)) ?
next : acquireRead(false, 0L));
}
读锁的源码与写锁源码基本上一样的,但是读锁可以多个共享。
- unlockWrite-释放锁
public void unlockWrite(long stamp) {
WNode h;
if (state != stamp || (stamp & WBIT) == 0L)
throw new IllegalMonitorStateException();
state = (stamp += WBIT) == 0L ? ORIGIN : stamp;
if ((h = whead) != null && h.status != 0)
release(h);
}
在释放锁之前内部先对传入的票据是否相同,如果不同则抛出一样;票据相同先修改state的值,然后调用release()来唤醒等待队列中的队首节点
private void release(WNode h) {
if (h != null) {
WNode q; Thread w;
U.compareAndSwapInt(h, WSTATUS, WAITING, 0);
if ((q = h.next) == null || q.status == CANCELLED) {
for (WNode t = wtail; t != null && t != h; t = t.prev)
if (t.status <= 0)
q = t;
}
if (q != null && (w = q.thread) != null)
U.unpark(w);
}
}
对获取到的队首节点判断是否为空以及是取消状态的话,就将改节点扔掉,重新从队列中获取一个节点出来,直到获取到的节点不为空以及需要执行的线程不为空,就调用U.unpark()来执行我们的线程任务
- tryOptimisticRead-尝试获取乐观锁
public long tryOptimisticRead() {
long s;
return (((s = state) & WBIT) == 0L) ? (s & SBITS) : 0L;
}
在没有线程获取写锁的时候,则乐观读锁获取成功,并且返回票据信息,乐观锁使用的时候只需要获取,不需要释放。在获取的时候只要没有线程获取写多,则可以获取到乐观读锁,同时将共享数据存储到局部变量中,并不会阻塞其他线程对共享数据进行修改,正是因为这样的机制,就会造成使用共享数据时,会出现不一致的问题,所以在使用乐观读锁时,需要反复对数据进行校验,将本地变量的值写会到共享内存时,需要判断当前乐观锁的票据信息是否有效,如果有效才进行后续的操作,无效的话需要锁升级为悲观读锁,然后在进行数据操作。
- validate-校验票据
public boolean validate(long stamp) {
U.loadFence();
return (stamp & SBITS) == (state & SBITS);
}
在上面我们只是对几个使用比较常见的方法进行的粗略的解读,大体上对StampedLock的实现原理有了一定的认识,至于具体的实现有兴趣的朋友自行去研究。接下来我们对StampedLock的相关特性做一些总结
StampedLock特点
- 获取锁的时候,都会返回stamp票据信息,如果票据为0则代表获取失败,需要重新获取,非0值则代表获取成功
- 释放锁的时候,都需要传入获取到的票据信息,会先对传入的票据信息进行准确性校验,如果不一致,则抛出异常,主要做的好处是控制使用和释放属于同一把锁
- 对于写锁是不可重入的,如果一个线程已经获取了写锁,后面再去获取写锁就会造成死锁的问题
- 提供了三种类型的锁
- 写锁: 锁不共享,一个写锁只能由一个线程获取,当一个获取到写锁后,其他线程都会阻塞等待
- 悲观读锁: 多个线程可以共享一把读锁,当获取到读锁后,写锁被阻塞,读锁与写锁互斥
- 乐观读锁: 当没有写锁时,可以获取乐观读锁,乐观读锁不需要释放,获取到乐观读锁后,不会阻塞后续写入的操作,在对需要操作的时候需要验证票据的有效性
- 锁等待队列采用FIFO,先进先出模式,先等到的节点先获取到锁
- 读锁与写锁可以互相转换