浅析ReentrantLock实现原理

从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()方法中,主要是做了以下几件事:

  1. 判断当前线程的锁的拥有者的状态值是否为0,若为0,通过该方法hasQueuedPredecessors()再判断等待线程队列中,是否存在排在前面的线程。
  2. 若是没有排在前面的线程 则通过该方法 compareAndSetState(0, acquires)设置当前的线程状态为1。
  3. 将线程拥有者设为当前线程setExclusiveOwnerThread(current)
  4. 若是当前线程的锁的拥有者的状态值不为0,说明当前的锁已经被占用,通过current == getExclusiveOwnerThread()判断锁的拥有者的线程,是否为当前线程,实现锁的可重入。
  5. 若是锁的可重入 当前线程将线程的状态值+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()方法主要执行以下几件事:

  1. 死循环处理等待线程中的前置节点,并尝试获取锁,若是p == head && tryAcquire(arg),则跳出循环,即获取锁成功。
  2. 若是获取锁不成功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


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章