淺析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


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