- 参考博客(本篇 文章基本就是来自这两篇博客,结合起来为自己总结用,谢谢):
Java多线程进阶(十一)—— J.U.C之locks框架:StampedLock
面试居然问到了StampedLock,我却是啥都不知道…
StampedLock的由来
- `StampedLock类:在JDK1.8时引入,是对读写锁ReentrantReadWriteLock的增强,该类提供了一些功能,优化了读锁、写锁的访问,同时使读写锁之间可以互相转换,更细粒度控制并发。
- 之所以说是对读写锁ReentrantReadWriteLock的增强,是因为其会产生写饥饿问题,而`StampedLock就解决了这个问题;
StampedLock的特点
- StampedLock: 这是一个什么东西呢?英文单词Stamp,意思是邮戳;
- 所有获取锁的方法,都返回一个邮戳:Stamp为0表示获取失败,其余都表示成功;
- 所有释放锁的方法,都需要一个邮戳:这个Stamp必须是和成功获取锁时得到的Stamp一致;
- StampedLock是 不可重入 的:
- 注意: 如果一个线程已经持有了写锁,再去获取写锁的话就会造成死锁;
- `StampedLock有三种访问模式:
- `Reading(读模式):功能和ReentrantReadWriteLock的读锁类似;
- `Writing(写模式):功能和ReentrantReadWriteLock的写锁类似;
- `Optimistic reading(乐观读模式):这是一种优化的读模式;
- 这是最主要的特点,乐观读模式,即使读线程获取到了读锁,写线程尝试获取写锁也不会阻塞,这相当于对读模式的优化,但是可能会导致数据不一致的问题。所以,当使用
Optimistic reading
获取到读锁时,必须对获取结果进行校验。
- 这是最主要的特点,乐观读模式,即使读线程获取到了读锁,写线程尝试获取写锁也不会阻塞,这相当于对读模式的优化,但是可能会导致数据不一致的问题。所以,当使用
- `StampedLock:支持读锁和写锁的相互转换
- 在`ReentrantReadWriteLock中,当线程获取到写锁后,可以降级为读锁,但是读锁是不能直接升级为写锁的;
- 而`StampedLock提供了读锁和写锁相互转换的功能,使得该类支持更多的应用场景;
- 不过无论写锁还是读锁,都不支持`Conditon等待;
StampedLock的基本使用
- 在`StampedLock的Oracle官方文档中就提供了一个非常好的例子,让我们可以很快的理解StampedLock的使用,如下:
class Point {
private double x, y;
private final StampedLock sl = new StampedLock();
// 第一种模式:Writing(写模式),这个也是独占锁,没有什么改动,注意stamp的运用即可
//第二种模式同理
void move(double deltaX, double deltaY) {
long stamp = sl.writeLock(); //涉及对共享资源的修改,使用写锁-独占操作
try {
x += deltaX;
y += deltaY;
} finally {
sl.unlockWrite(stamp);
}
}
/**
* 第三种模式:`Optimistic reading`(乐观读模式),使用乐观读锁访问共享资源
* 注意:乐观读锁在保证数据一致性上需要拷贝一份要操作的变量到 方法栈🧡,并且在操作数据时候可能其他写线程已经修改了数据,
* 因此需要校验
*/
🧡注意点1:
double distanceFromOrigin() {
long stamp = sl.tryOptimisticRead(); // 获取`Optimistic reading`(乐观读模式)
double currentX = x, currentY = y; // 拷贝共享资源到本地方法栈中
🧡注意点2:
if (!sl.validate(stamp)) { // 如果有写锁被占用,可能造成数据不一致,所以要切换到普通读锁模式
stamp = sl.readLock();
try {
currentX = x;
currentY = y;
} finally {
sl.unlockRead(stamp); //如果是读模式没有被锁占用,则可以直接返回,没有上锁的操作
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
/*
最大特点:读锁---》写锁的转换
模板如下:
*/
void moveIfAtOrigin(double newX, double newY) { // upgrade
// 这里是悲观锁策略readLock,同样也可以用乐观锁策略来举例
long stamp = sl.readLock();
try {
//相当于自旋,满足条件则不停尝试
while (x == 0.0 && y == 0.0) {
long ws = sl.tryConvertToWriteLock(stamp); //读锁升级为写锁,ws=0表示失败,反之升级成功
if (ws != 0L) { //升级成功,则修改数据并返回
stamp = ws;
x = newX;
y = newY;
break;
} else {
//升级写锁失败,则显示释放读锁并且显示获取写锁
sl.unlockRead(stamp);
stamp = sl.writeLock();
}
}
} finally { //因为不知道是否能升级成功,则通过通用式解锁
🧡注意点3: sl.unlock(stamp);
}
}
}
- 看完官方提供的例子,我们要注意几个地方:
- 注意点2:
public boolean validate(long stamp) {
U.loadFence();
return (stamp & SBITS) == (state & SBITS);
}
- 参数是上次锁操作返回的邮戳:
- 如果在调用
validate()
之前,如果没有写锁被占用,那就返回true
:表示锁保护的共享数据并没有被修改,因此之前的读取操作是肯定能保证数据完整性和一致性的,直接返回即可; - 如果在调用
validate()
之前,如果有写锁被占用,那就返回false
:表示之前的数据读取和写操作冲突了,程序需要进行重试(多自旋几次),或者升级为悲观读锁。
- 如果在调用
- 注意点1:
- “Optimistic reading”的使用模板:
long stamp = lock.tryOptimisticRead(); // 乐观读模式返回的邮戳
copyVaraibale2ThreadMemory(); // 拷贝变量到线程本地堆栈
if(!lock.validate(stamp)){ // 校验是否有写锁被占用
long stamp = lock.readLock(); // 如果有写锁被占用则获取读锁
try {
copyVaraibale2ThreadMemory(); // 拷贝变量到线程本地堆栈
//---相关操作
} finally {
lock.unlockRead(stamp); // 释放悲观读锁
}
}
useThreadMemoryVarables(); // 使用线程本地堆栈里面的数据进行操作
- 注意点3:
public void unlock(long stamp) {
if ((stamp & WBIT) != 0L)
unlockWrite(stamp);
else
unlockRead(stamp);
}
- 用于 写锁➡读锁 的释放锁用,因为不知道转没转成功,则通过判断释放读锁或写锁。
和重入锁的比较
- 通过上面的例子,我们不难看出,StampedLock的编程复杂度要比重入锁复杂的多,代码也不是很简介,那为什么还要用呢?
- 最本质的原因就是性能的提升,一般来说,这种乐观锁的性能要比普通的重入锁快几倍,而且随着线程数量的不断增加,性能的差距会越来越大。
- 简而言之,在大量并发的场景中StampedLock的性能是碾压重入锁和读写锁的;
- 不过它也有缺点,我们前面多少也提到一些,总结如下:
- 如果使用乐观读,那么冲突的场景要应用自己处理;
- 不可重入,如果连续调用两次写锁请求,那就会死锁!!!!!
- 它不支持wait/notify机制;
- 不过如果能很好的理解StampedLock,那么它是首选!!!!
StampedLock的原理分析
- StampedLock虽然不像其它锁一样定义了内部类来实现AQS框架,但是StampedLock的基本实现思路还是利用CLH队列进行线程的管理,通过同步状态值来表示锁的状态和类型;
StampedLock的常用属性
- StampedLock内部定义了很多常量,定义这些常量的根本目的还是和ReentrantReadWriteLock一样,对同步状态值按位切分,以通过位运算对State进行操作:
- state:
- 这是一个64位的整数,long,StampedLock对它的使用是非常巧妙的;
- 如何巧妙呢?写锁被占用的标志是第8位为1,读锁使用低7位,正常情况下读锁数目为1-126(0有特殊含义,127用来表示读状态标识RBITS),超过126时,使用int整型的readerOverflow属性保存超出的读锁计数(图来自上面所写参考的博客)(这里的写锁释放次数是不是应该表示为写锁释放次数➕1呢? 因为1表示初始状态);
- state的相关位运算:🧡🧡🧡
private static final int LG_READERS = 7; //位数,这7位一般用来表示读锁的那7位
private static final long RUNIT = 1L; //单位读锁 ... 0000 0001
private static final long WBIT = 1L << LG_READERS; //写锁标志位 ... 1000 0000
private static final long RBITS = WBIT - 1L; //读状态标识 ... 0111 1111
private static final long RFULL = RBITS - 1L; //读锁的最大数量 ... 0111 1110(126)
private static final long ABITS = RBITS | WBIT; //用于获取读写状态 ... 1111 1111
private static final long SBITS = ~RBITS; // 111111... 1000 0000
private static final long ORIGIN = WBIT << 1; //初始化state值 ...1 0000 0000
private transient volatile long state; //同步状态
private transient int readerOverflow; //读锁数量超过126,用readerOverflow记录
- 那具体怎么记录读锁次数,写锁次数呢?
- 首先state同步状态初始值是ORIGIN:…0001 0000 0000;(不能是0,因为有特殊含义)
- 当有写锁占用时,即state=state➕WBIT : … 0001 1000 0000;
- 释放写锁,即再加wait,state=state➕WBIT : … 0010 0000 0000; (如果再加锁,就变成了 0010 1000 0000)
- 如果继续读锁占用,就直接加1即: 0010 0000 0001;
- 为什么要记录写锁释放的次数呢?
- 因为state的同步状态判断都是基于CAS操作的,而普通的CAS操作可能会遇到 “ABA”问题,如果不记录次数,那么当写锁释放,申请,再释放时,我们将无法判断数据是否被写过;而这里记录了释放的次数,因此出现"释放->申请->释放"的时候,CAS操作就可以检查到数据的变化,从而判断写操作已经有发生,作为一个乐观锁来说,就可以准确判断冲突已经产生,剩下的就是交给应用来解决冲突即可。因此,这里记录释放锁的次数,是为了精确地监控线程冲突。
StampedLock的内部数据结构
- 这里简单介绍一下它的数据结构:
- 在StampedLock中,有一个队列,里面存放着等待在锁上的线程。该队列是一个链表,链表中的元素是一个叫做Node的对象;
- 我们先看其Node类结构图和源码:
abstract static class Node { //JDK16
volatile Node prev; // initially attached via casTail
volatile Node next; // visibly nonnull when signallable
Thread waiter; // visibly nonnull when enqueued
volatile int status; // written by owner, atomic bit ops by others
//---省略本质是一些native方法操作
}
static final class WriterNode extends Node { // node for writers}
static final class ReaderNode extends Node { // node for readers
volatile ReaderNode cowaiters; // 等待的读模式节点 jdk8中用的cowait
//---省略本质是一些native方法操作
}
// Bits for Node.status
static final int WAITING = 1; //JDK8 为 -1
static final int CANCELLED = 0x80000000; // must be negative
/** Head of CLH queue */
private transient volatile Node head;
/** Tail (last) of CLH queue */
private transient volatile Node tail;
/*JDK8中不同的地方
static final int WAITING = -1 static final int CANCELLED = 1
在Node中还会有一个属性 final int mode : RMODE =0、WMODE =1分别来表示ReaderNode、WriterNode
并多了一个构造器
WNode(int m ,WNode p){ WNode就是JDK16中的Node
mode = m;
prev = p;
}
*/
-
可知:
- Node是一个抽象类,具体由 WriterNode,ReaderNode实例类来实现;
- JDK8中其结点是这样的(均网图,来自参考文献):
-
当队列中有若干个线程等待时,整个队列可能看起来像这样的:
StampedLock对多核CPU优化
- StampedLock相比ReentrantReadWriteLock,对多核CPU进行了优化,可以看到,当CPU核数超过1时,会有一些自旋操作(转自别人博客):
StampedLock对象的创建
- StampedLock构造器,构造时设置下同步状态值为ORIGIN:
public StampedLock() {
state = ORIGIN;
}
- StamedLock提供了三类视图:
// views 视图
transient ReadLockView readLockView;
transient WriteLockView writeLockView;
transient ReadWriteLockView readWriteLockView;
- 这些视图其实是对StamedLock方法的封装,便于习惯了ReentrantReadWriteLock的用户使用:
- 比如,ReadWriteLockView 的两个方法:
final class ReadWriteLockView implements ReadWriteLock {
public Lock readLock() { return asReadLock(); }
public Lock writeLock() { return asWriteLock(); }
}
StampedLock读锁的申请和释放
- 让我们先看一下StampedLock对读锁的申请(读锁比写锁复杂):
/*
* 获取读锁时,如果写锁被占用,线程阻塞,且方法不相应中断,返回非0表示成功
*/
public long readLock() {
long s = state, next;
//条件1:(whead == wtail 表示:表示队列为空,或者只有一个结点
//条件2:(s & ABITS) < RFULL 表示:写锁没有被占用,并且读锁数量没有超过界限126
//同时满足条件1、2再去修改同步状态,让其读锁+1, 成功返回next,失败则自旋请求读锁,求不到则入队,入队还自旋,实在不行挂起
return ((whead == wtail && (s & ABITS) < RFULL &&
U.compareAndSwapLong(this, STATE, s, next = s + RUNIT)) ? next : acquireRead(false, 0L));
}
- 接下来我们看一下acquireRead(boolean interruptible, long deadline) 源码:
- 这个方法非常复杂,用到了大量自旋操作,我就按别人的分析➕自己的理解来写:
/**
* 尝试自旋的获取读锁, 获取不到则加入等待队列, 并阻塞线程
*
* @param interruptible true 表示检测中断, 如果线程被中断过, 则最终返回INTERRUPTED
* @param deadline 如果非0, 则表示限时获取
* @return 非0表示获取成功, INTERRUPTED表示中途被中断过
*/
private long acquireRead(boolean interruptible, long deadline) {
WNode node = null, p; // node:指向入队结点, p:指向入队前的队尾结点
/**
* 自旋入队操作:
* 如果写锁未被占用, 则立即尝试获取读锁;
* 如果写锁被占用, 则将当前读线程包装成结点, 并插入等待队列(如果队尾是写结点,直接链接到队尾;否则,链接到队尾读结点的栈中)
*/
for (int spins = -1; ; ) {
WNode h;
if ((h = whead) == (p = wtail)) { // 如果队列为空
for (long m, s, ns; ; ) {
if ((m = (s = state) & ABITS) < RFULL ? // 判断写锁是否被占用,小于则没被占用,大
U.compareAndSwapLong(this, STATE, s, ns = s + RUNIT) : //写锁未占用,且读锁数量未超限, 则更新同步状态
//m < WBIT:写锁未占用,ns = tryIncReaderOverflow(s)) != 0L:但读锁数量超限, 超出部分放到readerOverflow字段中
(m < WBIT && (ns = tryIncReaderOverflow(s)) != 0L))
return ns; // 获取成功后, 直接返回同步状态ns
else if (m >= WBIT) { // 写锁被占用,以随机方式探测是否要退出自旋
if (spins > 0) {
if (LockSupport.nextSecondarySeed() >= 0)
--spins;
} else {
if (spins == 0) {
WNode nh = whead, np = wtail;
if ((nh == h && np == p) || (h = nh) != (p = np))
break;
}
spins = SPINS;
}
}
}
}
if (p == null) { // 表示队列为空, 则初始化队列
WNode hd = new WNode(WMODE, null); //构造头节点
if (U.compareAndSwapObject(this, WHEAD, null, hd))
wtail = hd;
} else if (node == null) { // 将当前线程包装成读结点,并指向队尾结点
node = new WNode(RMODE, p);
} else if (h == p || p.mode != RMODE) {
// 如果队列只有一个头结点, 或队尾结点不是读结点, 则直接将结点链接到队尾, 链接完成后退出自旋
if (node.prev != p)
node.prev = p;
else if (U.compareAndSwapObject(this, WTAIL, p, node)) {
p.next = node;
break; //退出自旋
}
}
// 队列不为空, 且队尾是读结点, 则将添加当前结点链接到队尾结点的cowait链中(实际上构成一个栈, p是栈顶指针 )
else if (!U.compareAndSwapObject(p, WCOWAIT, node.cowait = p.cowait, node)) {
// CAS操作队尾结点p的cowait字段,实际上就是头插法(node.cowait = p.cowait)插入结点(头插法就是按栈的规则进行的)
node.cowait = null;
} else {
for (; ; ) {
WNode pp, c;
Thread w;
// 尝试唤醒头结点的cowait中的第一个元素, 假如是读锁会通过循环释放cowait链
if ((h = whead) != null && (c = h.cowait) != null &&
U.compareAndSwapObject(h, WCOWAIT, c, c.cowait) &&
(w = c.thread) != null) // help release
U.unpark(w); //唤醒,不让其挂起了
if (h == (pp = p.prev) || h == p || pp == null) { //这里是队列为空,或者只有一个??
long m, s, ns;
do {
if ((m = (s = state) & ABITS) < RFULL ? //获取读锁,就是本方法刚开始循环的操作
U.compareAndSwapLong(this, STATE, s, ns = s + RUNIT) :
(m < WBIT && (ns = tryIncReaderOverflow(s)) != 0L))
return ns;
} while (m < WBIT);
}
if (whead == h && p.prev == pp) {
long time;
if (pp == null || h == p || p.status > 0) {
node = null; // throw away
break;
}
if (deadline == 0L)
time = 0L;
else if ((time = deadline - System.nanoTime()) <= 0L)
return cancelWaiter(node, p, false);
Thread wt = Thread.currentThread();
U.putObject(wt, PARKBLOCKER, this);
node.thread = wt;
if ((h != pp || (state & ABITS) == WBIT) && whead == h && p.prev == pp) {
// 写锁被占用, 且当前结点不是队首结点, 则阻塞当前线程
U.park(false, time);
}
node.thread = null;
U.putObject(wt, PARKBLOCKER, null);
if (interruptible && Thread.interrupted()) //中断操作
return cancelWaiter(node, p, true);
}
}
}
}
for (int spins = -1; ; ) {
WNode h, np, pp;
int ps;
if ((h = whead) == p) { // 如果当前线程是队首结点, 则尝试获取读锁(我认为是第二个结点,头节点为空)
if (spins < 0)
spins = HEAD_SPINS;
else if (spins < MAX_HEAD_SPINS)
spins <<= 1;
for (int k = spins; ; ) { // spin at head
long m, s, ns;
if ((m = (s = state) & ABITS) < RFULL ? // 判断写锁是否被占用
U.compareAndSwapLong(this, STATE, s, ns = s + RUNIT) : //写锁未占用,且读锁数量未超限, 则更新同步状态
(m < WBIT && (ns = tryIncReaderOverflow(s)) != 0L)) {
//写锁未占用,但读锁数量超限, 超出部分放到readerOverflow字段中
// 获取读锁成功, 释放cowait链中的所有读结点
WNode c;
Thread w;
// 释放头结点, 当前队首结点成为新的头结点
whead = node;
node.prev = null;
// 从栈顶开始(node.cowait指向的结点), 依次唤醒所有读结点, 最终node.cowait==null, node成为新的头结点
while ((c = node.cowait) != null) {
if (U.compareAndSwapObject(node, WCOWAIT, c, c.cowait) && (w = c.thread) != null)
U.unpark(w);
}
return ns;
} else if (m >= WBIT &&
LockSupport.nextSecondarySeed() >= 0 && --k <= 0)
break;
}
} else if (h != null) { // 如果头结点存在cowait链, 则唤醒链中所有读线程
WNode c;
Thread w;
while ((c = h.cowait) != null) {
if (U.compareAndSwapObject(h, WCOWAIT, c, c.cowait) &&
(w = c.thread) != null)
U.unpark(w);
}
}
if (whead == h) {
if ((np = node.prev) != p) {
if (np != null)
(p = np).next = node; // stale
} else if ((ps = p.status) == 0) // 将前驱结点的等待状态置为WAITING, 表示之后将唤醒当前结点
U.compareAndSwapInt(p, WSTATUS, 0, WAITING);
else if (ps == CANCELLED) {
if ((pp = p.prev) != null) {
node.prev = pp;
pp.next = node;
}
} else { // 阻塞当前读线程
long time;
if (deadline == 0L)
time = 0L;
else if ((time = deadline - System.nanoTime()) <= 0L) //限时等待超时, 取消等待
return cancelWaiter(node, node, false);
Thread wt = Thread.currentThread();
U.putObject(wt, PARKBLOCKER, this);
node.thread = wt;
if (p.status < 0 && (p != h || (state & ABITS) == WBIT) && whead == h && node.prev == p) {
// .status < 0 :如果前驱的等待状态为WAITING, (state & ABITS) == WBIT) :且写锁被占用, 则阻塞(挂起)当前调用线程
U.park(false, time);
}
node.thread = null;
U.putObject(wt, PARKBLOCKER, null);
if (interruptible && Thread.interrupted())
return cancelWaiter(node, node, true);
}
}
}
}
- 看完一遍又一遍,忍不住说一声牛逼!
- 总之,就是自旋,自旋再自旋,通过不断的自旋来尽可能避免线程被真的挂起,只有当自旋充分失败后,才会真正让线程去等待。
- 读锁的释放:
public void unlockRead(long stamp) {
long s, m; WNode h;
for (;;) {
// 检查同步状态,如果不匹配,或者没有被读写锁占用,则抛出异常
if (((s = state) & SBITS) != (stamp & SBITS) ||
(stamp & ABITS) == 0L || (m = s & ABITS) == 0L || m == WBIT)
throw new IllegalMonitorStateException();
// 读锁计数没有超过限制
if (m < RFULL) {
if (U.compareAndSwapLong(this, STATE, s, s - RUNIT)) { // 读锁数量-1
// 如果当前读锁数量为1,且头节点不为空,状态不为初始态,则唤醒
if (m == RUNIT && (h = whead) != null && h.status != 0)
release(h);
break;
}
}
else if (tryDecReaderOverflow(s) != 0L)
// 读线程个数溢出检测,则溢出数段-1
break;
}
}
注意,当读锁的数量变为0时才会调用release方法,唤醒队首结点:🧡🧡🧡🧡🧡🧡
private void release(WNode h) {
if (h != null) {
WNode q; Thread w;
// 将其状态改为0
U.compareAndSwapInt(h, WSTATUS, WAITING, 0);
// 如果头节点的下一个节点为空或者其状态为已取消
if ((q = h.next) == null || q.status == CANCELLED) {
// 从尾节点向前遍历找到一个可用的(状态为WAITING)的节点
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);
}
}
StampedLock写锁的申请和释放
- 让我们再看一下StampedLock对写锁的申请:
/*
* 获取写锁,返回0表示失败,进入阻塞,且方法不相应中断,非0为成功
*/
public long writeLock() {
long s, next;
return ((((s = state) & ABITS) == 0L && //((s = state) & ABITS) == 0L表示读锁和写锁都未被使用
U.compareAndSwapLong(this, STATE, s, next = s + WBIT)) ? //CAS操作:将第8位置为1,表示写锁被占用
next : acquireWrite(false, 0L)); //如果成功,返回next,失败就调用acquireWrite(),加入等待队列
}
- 接下来我们看一下acquireWrite(boolean interruptible, long deadline) 源码:
/**
* 尝试自旋的获取写锁, 获取不到则阻塞线程
*
* @param interruptible true 表示检测中断, 如果线程被中断过, 则最终返回INTERRUPTED
* @param deadline 如果非0, 则表示限时获取
* @return 非0表示获取成功, INTERRUPTED表示中途被中断过
*/
private long acquireWrite(boolean interruptible, long deadline) {
WNode node = null, p;
/**
* 自旋入队操作
* 如果没有任何锁被占用, 则立即尝试获取写锁, 获取成功则返回.
* 如果存在锁被使用, 则将当前线程包装成独占结点, 并插入等待队列尾部
*/
for (int spins = -1; ; ) {
long m, s, ns;
if ((m = (s = state) & ABITS) == 0L) { // 读写锁都未被占用
if (U.compareAndSwapLong(this, STATE, s, ns = s + WBIT)) // 尝试立即获取写锁
return ns; // 获取成功直接返回
} else if (spins < 0)
spins = (m == WBIT && wtail == whead) ? SPINS : 0;
else if (spins > 0) {
if (LockSupport.nextSecondarySeed() >= 0)
--spins;
} else if ((p = wtail) == null) { // 队列为空, 则初始化队列
WNode hd = new WNode(WMODE, null);
if (U.compareAndSwapObject(this, WHEAD, null, hd))
wtail = hd;
} else if (node == null) // 将当前线程包装成写结点,入队
node = new WNode(WMODE, p);
else if (node.prev != p)
node.prev = p;
else if (U.compareAndSwapObject(this, WTAIL, p, node)) { // 链接结点至队尾
p.next = node;
break;
}
}
for (int spins = -1; ; ) {
WNode h, np, pp;
int ps;
if ((h = whead) == p) { // 如果当前结点是队首结点, 则立即尝试获取写锁(同样的疑惑,应该是前置结点)
if (spins < 0)
spins = HEAD_SPINS;
else if (spins < MAX_HEAD_SPINS)
spins <<= 1;
for (int k = spins; ; ) { // spin at head
long s, ns;
if (((s = state) & ABITS) == 0L) { // 写锁未被占用
if (U.compareAndSwapLong(this, STATE, s,
ns = s + WBIT)) { // CAS修改State: 占用写锁
// 将队首结点从队列移除
whead = node;
node.prev = null;
return ns;
}
} else if (LockSupport.nextSecondarySeed() >= 0 &&
--k <= 0)
break;
}
} else if (h != null) { // 唤醒头结点的栈中的所有读线程
WNode c;
Thread w;
while ((c = h.cowait) != null) {
if (U.compareAndSwapObject(h, WCOWAIT, c, c.cowait) && (w = c.thread) != null)
U.unpark(w);
}
}
if (whead == h) {
if ((np = node.prev) != p) {
if (np != null)
(p = np).next = node; // stale
} else if ((ps = p.status) == 0) // 将当前结点的前驱置为WAITING, 表示当前结点会进入阻塞, 前驱将来需要唤醒我
U.compareAndSwapInt(p, WSTATUS, 0, WAITING);
else if (ps == CANCELLED) {
if ((pp = p.prev) != null) {
node.prev = pp;
pp.next = node;
}
} else { // 阻塞当前调用线程
long time; // 0 argument to park means no timeout
if (deadline == 0L)
time = 0L;
else if ((time = deadline - System.nanoTime()) <= 0L)
return cancelWaiter(node, node, false);
Thread wt = Thread.currentThread();
U.putObject(wt, PARKBLOCKER, this);
node.thread = wt;
if (p.status < 0 && (p != h || (state & ABITS) != 0L) && whead == h && node.prev == p)
U.park(false, time); // emulate LockSupport.park
node.thread = null;
U.putObject(wt, PARKBLOCKER, null);
if (interruptible && Thread.interrupted())
return cancelWaiter(node, node, true);
}
}
}
}
- 通过源码我们可以总结一下,acquireWrite() 大体做了下面几件事情:
- 入队操作:
- 如果头结点等于尾结点wtail == whead, 表示快轮到我了,所以进行自旋等待,抢到就结束了;
- 如果wtail==null ,说明队列都没初始化,就初始化一下队列;
- 如果队列中有其他等待结点,那么只能老老实实入队等待了;
- 阻塞并等待:
- 如果头结点等于前置结点 (h = whead) == p), 那说明也快轮到我了,不断进行自旋等待争抢;
- 否则唤醒头结点中的读线程;
- 如果抢占不到锁,那么就park()当前线程;(下图为参考博客中的第二篇中的图)
- 入队操作:
- 总而言之,acquireWrite()函数就是用来争抢锁的,它的返回值就是代表当前锁状态的邮戳,同时,为了提高锁的性能,acquireWrite()使用大量的自旋重试,因此,它的代码看起来有点晦涩难懂。
- 写锁的释放:
public void unlockWrite(long stamp) {
WNode h;
/*
条件1:检查当前同步状态和当时获取锁的时候的同步状态(邮戳)是否相同
条件2:读写锁都未持有
满足两个条件其中的一个,就抛出异常
*/
if (state != stamp || (stamp & WBIT) == 0L)
throw new IllegalMonitorStateException();
// 正常情况下,stamp+=WBIT第8位应该位0,表示写锁释放,如果溢出,置为初始态ORIGN
state = (stamp += WBIT) == 0L ? ORIGIN : stamp;
// 头结点不为空,且不失初始状态(因为都是阻塞状态(WAITING)),尝试唤醒后续的线程
if ((h = whead) != null && h.status != 0)
//唤醒等待队列的(unpark)后续对首线程
release(h);
}
- realese() 方法很简单,先将头结点的等待状态置为0,表示即将唤醒后继结点,然后立即唤醒下一个为等待状态的结点:
private void release(WNode h) {
if (h != null) {
WNode q; Thread w;
// 将其状态改为0
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;
}
// 唤醒q节点所在的线程
if (q != null && (w = q.thread) != null)
U.unpark(w);
}
}
StampedLock悲观读占满CPU的问题
- 我们通过一个例子,说明StampedLock悲观锁疯狂占用CPU的问题:
public class StampedLockTest {
public static void main(String[] args) throws InterruptedException {
final StampedLock lock = new StampedLock();
Thread t1 = new Thread(() -> {
// 获取写锁
lock.writeLock();
// 模拟程序阻塞等待其他资源
LockSupport.park();
});
t1.start();
// 保证t1获取写锁
Thread.sleep(100);
Thread t2 = new Thread(() -> {
// 阻塞在悲观读锁
lock.readLock();
});
t2.start();
// 保证t2阻塞在读锁
Thread.sleep(100);
// 中断线程t2,会导致线程t2所在CPU飙升
t2.interrupt();
t2.join();
}
}
- 上述代码中,在中断t2后,t2的CPU占用率就会沾满100%。而这时候,t2正阻塞在readLock()方法上,换言之,在受到中断后,StampedLock的读锁有可能会占满CPU。
- 这是什么原因呢? 因为StampedLock内太多的自旋引起的!
- 如果没有中断,那么阻塞在readLock()上的线程在经过几次自旋后,会进入park()等待,一旦进入park()等待,就不会占用CPU了;
- 但是park() 这个方法有一个特点,就是一旦线程被中断,**park()**就会立即返回,但是它不会抛出异常!!!!;
- 本来呢,你是想在锁准备好的时候,unpark()的线程的,但是现在锁没好,你直接中断了,park()也返回了,但是,毕竟锁没好,所以就又去自旋了;
- 转着转着,又转到了park() 方法,但悲催的是,线程的中断标记一直打开着,park() 就阻塞不住了,于是,下一个自旋又开始了,没完没了的自旋停不下来了,所以CPU就爆满了;
- 那怎么解决这个问题呢?
- 要解决这个问题,本质上需要在StampedLock内部,在park()返回时,需要判断中断标记为,并作出正确的处理,比如,退出,抛异常,或者把中断位给清理一下,都可以解决问题。
- 但很不幸,至少在JDK8里,还没有这样的处理。因此就出现了上面的,中断readLock()后,CPU爆满的问题;
- 感谢敖丙大神的分析!!!
总结:
StampedLock的等待队列与RrentrantReadWriteLock的CLH队列相比,有以下特点:
- 当入队一个读请求的结点:如果队尾是读结点,不会直接链接到队尾,而是链接到该读结点的cowait链中,cowait链本质是一个栈;
- 当入队一个写请求的结点:如果队尾是写结点,则直接链接到队尾;
- 唤醒线程的规则和AQS类似,都是首先唤醒队首结点。区别是StampedLock中,当唤醒的结点是读结点时,会唤醒该读结点的cowait链中的所有读结点(顺序和入栈顺序相反,也就是先进后出);
- 另外,StampedLock使用时要特别小心,避免锁重入的操作,在使用乐观读锁时也需要遵循相应的调用模板,防止出现数据不一致的问题;
- 最后,注意StampedLock悲观读占满CPU的问题!!!
- 题外话:如果想要进一步了解,可以看第一篇参考博客的例子分析,大神级分析(个人建议看一看)!!!