从0到1 怎样凭空设计一个ReentrantLock—浅析ReentrantLock实现原理
自己动手设计一个ReentrantLock的思路演化
倘若真的就让我们凭空实现一把锁出来 我们应该怎样设计这把锁呢?
这里提供了几种伪代码思路
实现思路的伪代码—自旋
volatile int status=0;//标识 0是无锁状态 1是上锁状态
void lock(){
while(!compareAndSet(0,1)){
}
}
void unlock(){
status=0;
}
boolean compareAndSet(int expect, int newValue){
//cas修改status 成功返回true
return true;
}
你能get到他的逻辑吗?哈哈
我们简单解释 有两个线程A和B 此时线程A进入lock()这里 调用这个CAS方法 将0改为1 CAS返回true 加上前面的
!整体返回false 于是给某个同步方法加上了锁 线程B此时走到这里 CAS的期望值是0 但是此时拿到的status真实值是1 CAS比较不成功 返回false 加上!整体返回true 进入while{ }这个里面一直转啊转 等多会A释放了锁 把status改成0 线程B才能获取到锁
这个逻辑就是这样的 有点酷啊
我们看一下他的缺点
这里可是有个while死循环 当倒霉的线程B进行CAS修改不成功的时候 就会一直转在这个while死循环里 就浪费了cpu资源
我们思考一个改进办法 让得不到锁的线程让出cpu
伪代码 sleep+自旋
volatile int status=0;//标识 0是无锁状态 1是上锁状态
void lock(){
while(!compareAndSet(0,1)){
sleep(10);
}
}
void unlock(){
status=0;
}
boolean compareAndSet(int expect, int newValue){
//cas修改status 成功返回true
return true;
}
这是我们思考的第一种 让得不到锁的线程让出cpu的想法
但是这里有问题 就是睡眠的时间不好控制 比如A占了一把锁占了10分钟 这里睡眠了10秒钟 那么每隔10秒
倒霉的B就会去在试着去CAS获取锁一下 并没有完全的改善 所以睡眠的时间不好控制 多了少了都有问题
这种方法我们淘汰
伪代码 park+自旋
volatile int status=0;//标识 0是无锁状态 1是上锁状态
Queue parkQueue;
void lock(){
while(!compareAndSet(0,1)){
//没有获取到锁的线程让它休眠一会
park();
}
}
void unlock(){
status=0;
lock_notify();
}
void park(){
//将当前线程加入到等待队列
parkQueue.add(currentThread);
//释放cpu
releaseCPU();
}
void lock_notify(){
//得到等待队里要被唤醒的头部线程
Thread t= parkQueue.getHeader();
//唤醒他
unpark(t);
}
boolean compareAndSet(int expect, int newValue){
//cas修改status 成功返回true
return true;
}
我们得到了一种park+自旋的方式 这种方式似乎解决了好多问题 即上了锁 又去解决了获取不到锁的线程浪费cpu的问题
注意 我们这里设置了一个等待队列 每次我们把队列头的元素取出来把他叫醒
ReentrantLock浅析
我们上面得到了一个巧妙的结果 即park+自旋
这个思路基本就是我们源码的宏观思路了
下面我们真的看一下@author Doug Lea这个大神是怎么写出来一个锁的!!!
ReentrantLock的具体实现由有公平锁和非公平锁两种形式 我们逐一具体来看
这里因为ReentrantLock的具体实现包含公平锁和非公平锁两种 我们得初步对两种实现的整个一个链路有一个全面的认识
公平锁的具体实现
我们进入lock()方法的公平锁实现
我们按照上面这幅图的公平锁链路一点点往下走
抽象的lock()
abstract void lock();
FairSync
final void lock() {
acquire(1);
}
进入acquire(1)
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
其中tryAcquire(arg)
是尝试获取锁,这个方法是公平锁的核心之一,它的源码如下
tryAcquire
protected final boolean tryAcquire(int acquires) {
//获取当前线程
final Thread current = Thread.currentThread();
//0表示无锁状态 1表示有锁状态
int c = getState();
// 若为0,说明是无锁状态
if (c == 0) {
//判断自己要不要排队 不用排队就把state改为1 表示上锁了
//hasQueuedPredecessors()这个方法
//就是去判断有没有其他线程已经在队列里排队了
//如果前头没其他线程排队 这个方法返回false 加上! 返回true
//返回true 就cas赋值
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
//设置当前线程为拥有锁的线程
setExclusiveOwnerThread(current);
return true;
}
}
//判断当前线程current和当前持有锁的线程是不是相同
else if (current == getExclusiveOwnerThread()) {
//是重入就把锁的计数器加1
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
在tryAcquire()
方法中,主要是做了以下几件事:
- 判断当前线程的锁的拥有者的状态值是否为0,若为0,通过该方法
hasQueuedPredecessors()
再判断等待线程队列中,是否存在排在前面的线程。 - 若是没有排在前面的线程 则通过该方法
compareAndSetState(0, acquires)
设置当前的线程状态为1。 - 将线程拥有者设为当前线程
setExclusiveOwnerThread(current)
- 若是当前线程的锁的拥有者的状态值不为0,说明当前的锁已经被占用,通过
current == getExclusiveOwnerThread()
判断锁的拥有者的线程,是否为当前线程,实现锁的可重入。 - 若是锁的可重入 当前线程将线程的状态值+1,并更新状态值。
公平锁的tryAcquire()
,实现的原理图如下:
当tryAcquire尝试获取锁失败的时候 表明此时有锁的竞争 就要把后面来的线程加入到等待队列里去
我们来看看acquireQueued()
方法,该方法是将线程加入等待的线程队列中并且park这个线程,源码如下:
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
//死循环
for (;;) {
//predecessor()方法拿到node的上一个节点
//把上一个节点记成p
final Node p = node.predecessor();
//判断p是不是头部
//如果前置节点p是头部 就又调用tryAcquire方法自旋去尝试获取锁
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
// 在获取锁失败后,应该将线程Park(暂停)
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
acquireQueued()
方法主要执行以下几件事:
- 死循环处理等待线程中的前置节点,并尝试获取锁,若是
p == head && tryAcquire(arg)
,则跳出循环,即获取锁成功。 - 若是获取锁不成功
shouldParkAfterFailedAcquire(p, node) &&parkAndCheckInterrupt()
就会将线程暂停。
这里模拟一个场景 线程t1获取到了锁 并且一直持有锁不去释放 t2尝试获取锁失败 便要加入阻塞队列并park
用一张图去解释上面这个场景的执行流程
还是上面的这个场景 依旧是t1占着锁一直不放 我们把t2加入到AQS队列的示意图画一下
上图是线程t2进入到队列中的最终示意图
我们把这个线程t2入队的中间细节画一个流程图说明一下 这里也会重点阐释为什么t2之前会有一个thread为null的节点
这里我们解释这个有意思的现象 t2之前为什么会有一个thread为null的节点
这是因为此时t1持有锁 持有锁的线程永远不会参与排队
在排队的第一个永远是第二个节点
此时 t2正在排队 t2就是AQS中的第二个节点 但是t2是第一个排队的 因为t1持有锁 t1不参与排队
本文参考
公众号 非科班的科班
https://mp.weixin.qq.com/s/PNsI9LgkG3sdreEZs9G93A
b站视频
https://www.bilibili.com/video/BV14J4112757