手把手帶你分析ReentrantLock加鎖過程

ReentrantLock加鎖過程分析

1、自旋?如何實現一把自旋鎖

通俗的講,自旋就是不斷的判斷條件觸發自己執行的功能,很多線程同步的思想都來源於自旋,我們以兩個線程搶佔資源來理解下自旋:

我們看到,當線程t1和線程t2共同搶佔資源時,假如線程t1搶佔到了資源,這時t1需要加鎖並設置狀態state=1,線程t2過來後會先判斷狀態state是否爲0,如果不爲0則一直循環判斷state,直到線程t1解鎖並設置state=0,線程t2纔會繼續搶佔資源,線程t2不斷循環判斷的過程就是自旋。

僞代碼①

volatile int state=0;//state標識,設置爲原子操作
void lock(){
 while(!compareAndSet(0,1)){
 }
}
//邏輯代碼
void unlock(){
 state=0;
}
boolean compareAndSet(int except,int newValue){
 //cas操作,修改status成功則返回true
}

我們分析下這個僞代碼,這段代碼存在一個原子變量state初始值爲0,當線程t1拿到鎖後,會先利用compareAndSet(0,1)方法進行判斷,compareAndSet(0,1)的作用是比較傳入的值是否爲1,當傳入的值爲0時,則設置爲1並返回true,傳入的值爲1時則返回false,在代碼中,如果state=0,就將state設置爲1並返回true,如果state=1則返回false。假設線程t1搶佔鎖時state=0,則!compareAndSet(0,1)就爲false,則線程t1跳過while循環執行自己的邏輯代碼;當線程t2想要獲取鎖時,因爲此時state=1,則!compareAndSet(0,1)爲true,線程t2就進入while循環內不斷的進行循環判斷,直到線程t1執行解鎖方法並設置state爲0,線程t2才能繼續參與下一輪搶佔鎖。

NOTE:沒有獲取到鎖的線程會一直進行while循環判斷,這樣做非常耗費CPU資源,所有這種方法並不可取。

因爲很多鎖的實現都是在自旋方法上的改進,所以在原僞代碼的基礎上加入睡眠和喚醒方法來提高代碼的執行效率

僞代碼②

volatile int state=0;
Queue parkQueue;//隊列

void lock(){
 while(!compareAndSet(0,1)){

  park();
 }
 //邏輯代碼
   unlock()
}

void unlock(){
 lock_notify();
}

void park(){
 //將當前線程加入到等待隊列
 parkQueue.add(currentThread);
 //將當期線程釋放cpu 
 releaseCpu();
}
void lock_notify(){
 //獲取要喚醒的線程
 Thread t=parkQueue.header();
 //喚醒線程
 unpark(t);
}

僞代碼②是在僞代碼①的基礎上加入了睡眠和喚醒操作,這樣可以保證在隊列中的線程不佔用CPU資源,park和unpark是java.util.concurrent.locks包下的方法,用於睡眠和喚醒。這樣,我們就可以手動實現鎖來保證線程的同步了,事實上,很多的鎖的編寫都是基於這個思路的。下面,就可以引入我們要學的鎖--ReentrantLock,它的加鎖/解鎖就類似於僞代碼②

2、ReentrantLock的提出

在jdk1.6之前,我們使用鎖實現同步使用的是synchronized關鍵字,但是synchronized的實現原理是調用操作系統函數來實現加鎖/解鎖,我們都知道一旦涉及操作系統的函數,那麼代碼執行的效率就會變低,因此,使用synchronized關鍵字來實現加/解鎖就被稱爲重量級鎖,爲了改善這一情況,Doug Lea就寫了ReentrantLock鎖,這種鎖分情況在jvm層面和操作系統層面完成加鎖/解鎖的過程,因此代碼執行效率顯著提高,後來sun公司在jdk1.6以後也改進了synchronized,使得synchronized的執行效率和reentrantLock差不多,甚至更好,但是由於ReentrantLock可以直接代碼操作加鎖/解鎖,可中斷獲取鎖等特性,因此使用的比較多。

3、ReentrantLock加鎖分析

3.1、AQS簡介

在學習ReentrantLock加鎖之前,我們先了解下隊列同步器AbstractQueueedSynchronizer的概念,簡稱爲AQS,它是用來構建鎖的基礎框架,通過內置的FIFO隊列來完成線程隊列中的排隊工作

AQS提供了一個node結點類,主要有以下屬性

volatile Node prev;//執行前一個線程
volatile Node next;//執行下一個線程
volatile Thread thread;//結點中的當前線程

除此之外,AQS爲了維護好線程隊列,它還定義了兩個結點用於指向隊列頭部和隊列尾部,定義了了state用於修飾鎖的狀態

private transient volatile Node head;//指向隊列頭
private transient volatile Node tail;//指向隊列尾
private volatile int state;//鎖狀態,默認爲0,加鎖成功則爲1,重入+1 解鎖則爲0
private transient Thread exclusiveOwnerThread;//獨佔鎖的線程

隊列線程圖示

AQS中有很多操作鎖的方法,我們會以ReentrantLock的加鎖過程來講解這些方法,在這裏就不單獨講解。

3.2、ReentrantLock加鎖總體分析

爲了方便分析,我們先編寫一個Demo,分別以線程1、線程2搶佔鎖的步驟來學習ReentrantLock

/**
 * @Author: Simon Lang
 * @Date: 2020/5/8 16:19
 */
public class TestReentrantLock {

    public static void main(String[] args){
        final ReentrantLock lock=new ReentrantLock(true);
        Thread t1=new Thread("t1"){
            @Override
            public void run() {
                lock.lock();
                lockTest();
                lock.unlock();
            }
        };
        Thread t2=new Thread("t2"){
            @Override
            public void run() {
                lock.lock();
                lockTest();
                lock.unlock();
            }
        };
        t1.start();
        t2.start();
    }

    public static void lockTest(){

        System.out.println(Thread.currentThread().getName());
        try {
            Thread.sleep(2000);
            System.out.println(" -------end");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在這個測試Demo裏,鎖對象就是ReentrantLock對象,加鎖過程就是調用lock對象裏的lock方法,即lock.lock()

lock方法是reentrantLock類中提供的方法,Sync是同步器(AQS)提供的實施加鎖的方法,AQS提供了兩種加鎖方法,分別爲公平鎖和非公平鎖。

//同步器提供的加鎖方法
private final Sync sync;
 public void lock() {
        sync.lock();
    }

爲了方便後序加鎖流程的分析,我們先簡要說明下的公平鎖與非公平鎖的區別。

公平鎖的源碼

final void lock() {
    acquire(1);//1------標識加鎖成功之後改變的值
}

非公平鎖的源碼

final void lock() {
 if (compareAndSetState(0, 1))//cas判斷
  setExclusiveOwnerThread(Thread.currentThread());//設置當前前線程搶佔
 else
   acquire(1);
} 

我們看到非公平鎖比公平鎖多了個判斷,非公平鎖在在執行lock方法時,會先進行cas判斷,如果爲0直接搶佔鎖成功,如果state=1,則進行acquire(1)方法判斷,而公平鎖是直接進行acquire(1)判斷,事實上,公平鎖公平的原因是因爲它考慮隊列中線程的排隊順序,保證的依次進行加鎖執行,而非公平鎖則是直接判斷狀態state的值進行搶佔。

爲了使得分析代碼的時候不容易繞暈,我們先從邏輯層面上分析ReentrantLock的加鎖的流程,具體的細節放在每個線程執行的流程上講解。

結合上面的僞代碼②,大家可能會對這個流程圖會有疑問:state=0不應該直接加鎖麼?爲什還要判斷是否加入隊列呢?

其實這和線程間的併發執行有關,釋放鎖的過程也是併發執行的,釋放鎖執行順序可能是①設置state=0②unpark③喚醒下一個線程。如果獲取當前鎖的線程進行步驟②操作時,另一個線程就進來判斷了,如果這個線程不進行是否需要排隊判斷則會引發線程安全問題。

我們以公平鎖爲例學習reentrantLock的加鎖過程

3.3、線程1執行流程

當線程1執行公平鎖的過程中,會首先執行acquire(1)方法,我們來分析下線程1的執行步驟

acquire(int arg)方法是獨佔式的獲取同步狀態,如果當前線程獲取同步狀態成功,則由該方法返回,否則將會進入同步隊列等待。

 public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

線程1會首先執行tryAcquire(arg)方法,

tryAcquire(int arg)是獨佔式獲取鎖,實現該方法需要查詢當前狀態並判斷同步狀態是否符合預期,然後在進行cas設置同步狀態

 protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
     //獲取鎖的狀態
            int c = getState();
     //如果c=0,則判斷是否需要排隊
            if (c == 0) {
                if (!hasQueuedPredecessors() &&
                    compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
     //否則,判斷當前線程是否是正在持有鎖的線程
            else if (current == getExclusiveOwnerThread()) {
                //如果是,則判斷重入次數
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
     //否則,返回false
            return false;
        }

線程1會先獲取當前的鎖的狀態,假設忽略主線程,線程t1是第一個進來的,所以state=0,繼續判斷是否需要排隊(調用hasQueuedPredecessors)

  public final boolean hasQueuedPredecessors() {
        Node t = tail; // Read fields in reverse initialization order
        Node h = head;
        Node s;
        return h != t &&
            ((s = h.next) == null || s.thread != Thread.currentThread());
    }

因爲線程t1是第一個,所以線程隊列爲空,tail和head均爲null,所以條件h!=t不成立,hasQueuedPredecessors方法返回爲false,所以在tryAcquire方法中第一個判斷條件成立,又因爲此時的state=0,所以執行compareAndSetState返回爲true,第二個判斷條件成立。執行setExclusiveOwnerThread(current)將線程1上鎖成功並返回true,acquire()也正常返回,一直返回到我們編寫的邏輯代碼內。

線程1執行流程圖

3.4、線程2執行流程

在線程t1執行的過程中,假設線程2來試圖獲取鎖,它首先還是會先執行acquire方法

 public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

在acquire方法中先執行tryAcquire方法進行條件判斷

protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
     //獲取鎖的狀態
            int c = getState();
     //如果c=0,則判斷是否需要排隊
            if (c == 0) {
                if (!hasQueuedPredecessors() &&
                    compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
     //否則,判斷當前線程是否是正在持有鎖的線程
            else if (current == getExclusiveOwnerThread()) {
                //如果是,則判斷重入次數
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
     //否則,返回false
            return false;
        } 

因爲此時的state=1且當前持有鎖的線程爲線程t1,所以線程t2執行tryAcquire()方法直接返回false給acquire方法。

在acquire()方法內,!tryAcquire()爲true,所以要進行第二個判斷acquireQueued(addWaiter(Node.EXCLUSIVE),arg)

我們先分析addWaiter(Node.EXCLUSIVE)方法

將新加入的線程結點加入到隊列尾部

 private Node addWaiter(Node mode) {
     //將當前線程設置爲線程結點
        Node node = new Node(Thread.currentThread(), mode);
        // Try the fast path of enq; backup to full enq on failure
     //將隊列的尾節點賦給pred
        Node pred = tail;
     //判斷pred是否爲空結點
        if (pred != null) {
            //將當前線程(t2線程結點)結點的前驅結點設爲pred
            node.prev = pred;
            //將node結點cas操作
            if (compareAndSetTail(pred, node)) {
                //建立連接關係
                pred.next = node;
                return node;
            }
        }
        enq(node);
        return node;
    }

這段代碼首先會將線程t2設置成線程結點,判斷隊列中是否存在線程結點,如果不存在,則執行enq(node)先構造一個空的線程結點

    private Node enq(final Node node) {
        //死循環
        for (;;) {
            Node t = tail;
            if (t == null) { // Must initialize
                //構造一個空節點
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {//將線程t2結點加入隊列
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }

圖解構造線程結點

  • 第一次循環構造空線程結點


  • 第二次循環將線程t2結點加入隊列

將t2結點加入到隊列中並返回addWaiter方法,addWaiter返回t2線程結點到acquire方法中執行acquireQueued方法

 final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            //死循環
            for (;;) {
                //獲取當前線程結點的上一個結點p
                final Node p = node.predecessor();
                //判斷p是否爲頭結點,並嘗試這再次獲取鎖
                if (p == head && tryAcquire(arg)) {
                    //將當前結點設置爲頭結點
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                //否則,讓線程t2結點睡眠
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

這段代碼主要是判斷線程t2結點的前驅結點是否爲頭結點,如果爲頭結點就嘗試再次獲取鎖,否則就直接睡眠,如果不能獲取鎖就一直睡眠停留在這裏,否則就會返回執行用戶編寫的代碼

線程2執行流程圖


前面提到,ReentrantLock可以分情況在jvm層面和操作系統層面執行,我們將線程執行分爲以下幾種情況

  • 只有一個線程:直接進行CAS操作,不需要隊列(jvm層面)

  • 線程交替執行:直接進行CAS操作,不需要隊列(jvm層面)

  • 資源競爭

競爭激勵:調用park方法(操作系統層面)

競爭不激烈:多一次自旋 ,如果能獲取到鎖,則在jvm層面執行;不能獲取到鎖,執行park方法(在操作系統層面執行)

參考文獻

[1]https://blog.csdn.net/java_lyvee/article/details/98966684

[2]方騰飛.java併發編程的藝術

往期推薦

Intellij idea 2020永久破解,親測可用!!!

spring大廠高頻面試題及答案看完這篇緩存穿透的文章,保證你能和面試官互扯!!!Redis高頻面試題及答案大白話布隆過濾器,又能和面試官扯皮了~【吊打面試官】Mysql大廠高頻面試題!!!天天用Redis,持久化方案有哪些你知道嗎?面試官:你知道哪幾種事務失效的場景?天天寫 order by,你知道Mysql底層執行流程嗎?
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章