Java併發編程:6-Lock & Condition

前言java

在正式開始以前學習J.U.C以前,咱們先來了解一下Java中的管程模型,尤爲是對管程示意圖的掌握,會極大的幫助咱們理解併發包中的方法邏輯,以後會對Lock和Condition進行簡單的介紹。node

面試問題
Q :你對Lock和Condition的理解?
Q :ReentrantLock與synchronized的區別?面試

1.管程

管程:管理共享變量以及對共享變量的操做過程,使其支持併發。對應的英文是Monitor,Java中一般被直譯爲監視器,操做系統中通常翻譯爲「管程」。編程

在併發編程中,有兩大核心問題:一是互斥,即同一時刻只容許一個線程訪問共享資源;二是同步,即線程之間如何通訊、協做。對於這兩個問題,管程均可以解決。多線程

互斥很好理解,但同步可能就不那麼好理解了,同步在不一樣的場景也有不一樣的含義,關於指令執行順序中的同步是指代碼調用I/O操做時,必須等待I/O操做完成才返回的調用方式。 在併發編程中的同步則指的是線程之間的通訊和協做。最簡單的例子就是生產者和消費者,若是沒有商品,消費者線程如何通知生產者線程進行生產,生產商品後,生產者線程如何通知消費者線程來消費。併發

1.1 如何解決互斥

將共享變量以及對共享變量的操做 統一封裝起來,以下圖,多個線程想要訪問共享變量queue,只能經過管程提供的enq()和deq()方法實現,這兩個方法保持互斥性,且只容許一個線程進入管程。管程的模型和麪向對象模型的契合度很高,這也可功能是Java一開始選擇管程的緣由(JDK5增長了信號量),互斥鎖背後的模型其實就是它。
21-管程解決互斥.jpgapp

1.2 如何解決同步

在管程模型中,共享變量和對共享變量的操做是封裝起來的,圖中最外層的框表明着封裝,框外邊的入口等待隊列,當多個線程試圖進入管程內部時,只容許一個線程進入,其餘線程在入口等待隊列中等待,至關於多個線程同時訪問臨界區,只有一個線程拿到鎖進入臨界區,其他線程在等待區中等待,等待的時候線程狀態是阻塞的。框架

管程中還引入了條件變量的概念,並且每一個條件變量都有一個等待隊列,以下圖所示,管程經過引入「條件變量」和「等待隊列」來解決線程同步的問題。ide

結合上面提到的生產者消費者的例子,商品庫存爲空,或者庫存爲滿,都是條件變量,若是庫存爲空,那麼消費者線程會調用nofity()喚醒生產者線程,而且本身調用wait()進入「庫存爲空」這個條件變量的等待隊列中。函數

同理,生產者線程會喚醒消費者線程,本身調用wait()進入「庫存爲滿」這個條件變量的等待隊列中,被喚醒後會到入口等待隊列中從新排隊獲取鎖。這樣就能解決線程之間的通訊協做。

22-管程解決同步.jpg

1.3 管程發展史上出現的三種模型

Hasen模型:將notify()放到代碼最後,當前線程執行完再去喚醒另外一個線程。

Hoare模型:中斷當前線程,喚醒另外一個線程執行,等那個線程執行完了,再喚醒當前線程。相比Hasen模型多了一次喚醒操做。

MESA模型:當前線程T1喚醒其餘線程T2,T1繼續執行,T2並不當即執行,而是從條件隊列進到入口等待隊列中,這樣沒有多餘的喚醒操做,notify也不用放最後,可是會有一個問題,T2再次執行的時候,曾經知足的條件,如今已經不知足了,因此須要循環方式校驗條件變量。

while(條件變量){
    wait();
}

2.Lock

2.1 Lock接口的由來

以前提到併發編程的兩大核心問題:互斥,即同一時刻只容許一個線程訪問共享資源;同步,線程之間的通訊、協做。JDK5以前管程由synchronized和wait,notify來實現。JDK5以後,在J.U.C中提供了新的實現方式,使用Lock和Condition兩個接口來實現管程,其中Lock用於解決互斥,Condition用於解決同步。在Lock中維護一個「入口等待隊列」,每一個Condition中都維護一個「條件變量等待隊列」。經過將封裝後的線程對象在這兩種隊列中來回轉移,來解決互斥和同步問題。

JDK5中synchronized性能不如Lock,可是在JDK6以後,synchronized作了不少優化,將性能追上來。因此並非由於性能才提供了Lock和Condition這種管程的實現方式。而是synchronized的會自動加鎖和釋放鎖,沒法手動控制鎖的釋放,在不少狀況下不夠靈活。好比申請不到資源時,能夠經過主動釋放佔有的資源,來經過破壞不可搶佔條件。

而Lock接口就提供了更加靈活的方式來解決這個問題:

  1. 可以響應中斷。synchronized的問題是,若是獲取不到鎖,線程就會進入阻塞,而且沒法響應中斷信號,Lock接口提供了能夠響應中斷信號的加鎖方式,這樣就能夠主動釋放佔有的資源,以達到破壞不可搶佔條件。
  2. 支持超時。若是線程在一段時間內沒有獲取到鎖,不是進入阻塞,而是返回一個錯誤,一樣會釋放持有的資源,也能夠達到破壞不可搶佔條件。
  3. 非阻塞地獲取鎖,若是嘗試獲取鎖失敗,並不進入阻塞狀態,而是直接返回,也能夠達到破壞不可搶佔條件。
//支持中斷的加鎖
void lockInterruptibly() throws InterruptedException;
//支持超時的加鎖
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
//支持非阻塞獲取鎖
boolean tryLock();

總的來講,顯式的Lock對象在加鎖和釋放鎖方面,相對於內建的synchronized鎖來講,賦予更細粒度的控制。

2.2 ReentrantLock 原理

ReentrantLock在API層面實現了和synchronized關鍵字相似的加鎖功能,並且在使用上更加靈活。其原理僅僅是利用了volatile相關的Happens-Before規則來保證可見性和有序性,經過CAS判斷或修改鎖的state狀態來保證原子性。

ReentrantLock的具體實現則是使用AQS框架來完成的。其靜態內部類Sync繼承了AbstractQueuedSynchronizer,NonfairSync和FairSync繼承Sync,各自重寫了嘗試加鎖的tryAcquire方法。使ReentrantLock能夠支持公平鎖和非公平鎖。

AbstractQueuedSynchronizer內部持有一個volatile的成員變量state,加鎖時會讀寫state的值;解鎖時也會讀寫state的值 。至關於用先後兩次對volatile變量的修改操做,將共享變量的修改操做給包起來了。並經過傳遞性規與volatile規則共同保證可見性和有序性。

簡化後的代碼以下面所示:

class SampleLock{
    volatile int state;
    // 加鎖時必須先執行修改state的操做,再執行對共享變量進行操做
    lock(){
        state=1;
    //    ...

    }
    unlock(){
    //    ...
        state=0;
    }
    // 解鎖時必須最後執行修改state的操做,對共享變量的操做要在以前完成,這樣才能保證volatile規則
}

2.3 可重入

可重入鎖,同一把鎖能夠在持有時再進行獲取(synchronized也能夠),獲取幾回也必需要釋放幾回,否則會形成死鎖 。

以ReentrantLock爲例,state初始化爲0,表示未鎖定狀態。A線程lock()時,會調用tryAcquire()獨佔該鎖並將state+1。此後,其餘線程再tryAcquire()時就會失敗,直到A線程unlock()到state=0(即釋放鎖)爲止,其它線程纔有機會獲取該鎖。固然,釋放鎖以前,A線程本身是能夠重複獲取此鎖的(state會累加),這就是可重入的概念。但要注意,獲取多少次就要釋放多麼次,這樣才能保證state是能回到0狀態。

try {
    reentrantLock.lock();
    reentrantLock.lock();
} finally {
    reentrantLock.unlock();
}
// 因爲加了兩次鎖,但只釋放了一次,因此其餘線程沒法成功拿到鎖,會進入阻塞。

貼出ReentrantLock的部分源碼供你們參考,如下爲非公平鎖的嘗試獲取鎖方法。

final boolean nonfairTryAcquire(int acquires) {    // acquires = 1
            final Thread current = Thread.currentThread();
            int c = getState();
            //c爲0,能夠理解爲當前鎖未被使用,那麼當前線程就能夠去競爭一下鎖
            if (c == 0) {
                //競爭的過程就是使用CAS嘗試去修改state狀態
                if (compareAndSetState(0, acquires)) {
                    //成功則設置 獨佔鎖的擁有線程 爲當前線程
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
/* 程序執行到這一步,有兩種可能:
                1:c==0可是競爭失敗了    2:c!=0
                第一種狀況:鎖沒人用卻競爭失敗了說明競爭激烈,則線程須要進入等待隊列中等待,
            就像多人搶着出門,擠着誰都出不去,但只要有人在門口等待一下,有序撤離,則能夠很快經過。
                第二種狀況:c!=0,說明當前鎖被使用,下邊判斷鎖在誰手裏,若是本身拿着則累加state,
            鎖在別人手裏,則和狀況同樣,進入等待隊列中。 */
            else if (current == getExclusiveOwnerThread()) {
                // 判斷爲重入鎖,對state進行累加,
                int nextc = c + acquires;
                // int的MAX爲2147483647 再+1的話會溢出,不會變成2147483648,會變成-2147483647
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                // 由於只有當前持有鎖的線程才能走到這裏,因此此處並不須要使用CAS,
                setState(nextc);
                return true;
            ...

如下爲非公平鎖的嘗試釋放鎖方法。

protected final boolean tryRelease(int releases) {// acquires = 1
    // 解一次鎖減state減一次
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    // 只有減爲0時,說明鎖能夠被其餘線程獲取,返回true,同時設置 獨佔鎖的擁有線程 爲當前線程
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}

2.4 公平鎖與非公平鎖

public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

ReentrantLock有兩個構造函數,一個無參構造,一個須要傳入boolean類型的fair,這個參數表明的就是公平策略,若是傳入true,則會構造一個公平鎖,也就是誰等的時間長,誰得到鎖。默認構造的是非公平鎖。

在管程模型中有一個入口等待隊列,若是一個線程沒有獲取到鎖,就會進入等待隊列,當有線程釋放鎖的時候,就須要從等待隊列中喚醒一個等待的線程,若是是公平鎖的話,會喚醒等待時間最長的,非公平鎖則不必定,有可能剛進入等待時間最短的反而被喚醒。

public class MyReentrantLock5_公平鎖 extends Thread {
     //設置爲公平鎖
    public static ReentrantLock lock = new ReentrantLock(true);  
    @Test
    public void test() throws InterruptedException {
        Thread t1 = new Thread(() -> {
            //線程啓動後先休眠1s,儘可能保證量兩個線程同時搶鎖,
            //否則可能t1拿鎖放鎖100次了,t2還沒啓動
            TimeUnit.SECONDS.sleep(1);
            for (int i = 0; i < 100; i++) {
                lock.lock();
                System.out.println(Thread.currentThread().getName() + " 得到鎖");
                lock.unlock();
            }
        });
Thread t2 = new Thread(() -> {
            TimeUnit.SECONDS.sleep(1);
            for (int i = 0; i < 100; i++) {
                lock.lock();
                System.out.println(Thread.currentThread().getName() + " 得到鎖");
                lock.unlock();
            }
        });
        t1.start();t2.start();
        t1.join();t2.join();
    }
}
//Output
// 因爲lock設置的是公平鎖,因此能夠看到t1和t2輪流得到鎖。
一個寫多線程測試代碼須要注意的點,上面這段程序我是使用Junit提供的@Test註解來運行的,沒有放在main方法中來跑,若是上邊的代碼定義在main中,則能夠不用寫最後兩行代碼「 t1.join() ; t2.join() ;」。

@Test運行方式是在main方法中經過反射來執行test()方法,在執行test方法執行完後會當即退出,若是沒有t1.join();將沒法看到t1的打印結果。main方法中執行則會等待其中線程執行完成返回後再退出。

//非公平鎖
    final void lock() {
        // 在加鎖的時候就去嘗試一下
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        else
            acquire(1);
    }
    //公平鎖
    final void lock() {
        acquire(1);
    }
//公平鎖/非公平鎖
    final boolean tryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();
        if (c == 0) {
            //公平鎖比非公平鎖多了下面這一行判斷,檢查等待隊列的首節點(head是頭節點,head後邊
            //纔是阻塞隊列中保存的第一個節點)是否是當前線程,若是不是的話則須要去排隊
            if (!hasQueuedPredecessors() &&    
                compareAndSetState(0, acquires)) {
                setExclusiveOwnerThread(current);
                return true;
            }
        }
        ...
    }

3.Condition

3.1 Condition簡介

Condition是一個接口,這個接口是爲告終合ReentrantLock實現管程模型。再次搬出Java中的管程示意圖。

22-管程解決同步.jpg

Lock與Condition這二者之間的關係能夠參考synchronized和wait()/notify()。

Condition聲明瞭一組等待/通知的方法,AbstractQueuedSynchronizer 中的ConditionObject內部類實現了這個接口。 經過API的方式來對ReentrantLock進行相似於wait和notify的操做 。

// Codition方法
void await() throws InterruptedException;
boolean await(long time, TimeUnit unit) throws InterruptedException;
void signal();
void signalAll();

3.2 Condition原理

在每一個Condition中, 都維護着一個隊列,每當執行await()方法,都會將當前線程封裝爲一個節點,並添加到條件等待隊列尾部。而後完全釋放與Condition對象綁定的鎖(也就是ReentrantLock對象),注意這裏是完全釋放,不管ReentrantLock重入了幾回都會所有釋放,在釋放鎖的同時還會並喚醒阻塞在鎖的入口等待隊列中的一個線程,完成以上操做後再將本身阻塞。

在其餘線程調用該Condition的signal()後,該線程會被喚醒,喚醒後會從條件變量等待隊列中將該線程對應的節點移除 ,而後從新去競爭鎖,若是拿不到的話會再次進去入口等待隊列中。

public final void await() throws InterruptedException {
            if (Thread.interrupted())
                throw new InterruptedException();
            //添加到等待隊列尾部
            Node node = addConditionWaiter();
            //完全釋放鎖,並喚醒入口等待隊列中仍在等待的頭節點,可能有的節點在等待途中取消了等待,
            //但隊列不會馬上移除這些節點,只是會將等待狀態修改成取消,
            //在須要執行喚醒的時候,再統一將這些已取消的節點移除。
            int savedState = fullyRelease(node);
            int interruptMode = 0;
            //判斷當前節點是否在入口等待隊列中,在入口等待隊列中的線程是不持有鎖的。
            //若是對一個不持有鎖的對象進行掛起和喚醒操做,則可能出現Lost-weakup問題。
            //線程在阻塞過程當中產生中斷也會退出循環。
while (!isOnSyncQueue(node)) {
               //調用 LockSupport.park 阻塞當前線程
                LockSupport.park(this);
                //喚醒後會檢查在阻塞期間是否被中斷過,檢查的結果是三種狀態:
                //THROW_IE、REINTERRUPT、0。前兩種會致使退出循環。
               /* THROW_IE:
                 *     中斷在 node 轉移到同步隊列「前」發生,須要當前線程自行將 node 轉移到同步隊
                 *     列中,並在隨後拋出 InterruptedException 異常。
                  REINTERRUPT:
                 *     中斷在 node 轉移到同步隊列「期間」或「以後」發生,此時代表有線程正在調用 
                 *     singal/singalAll 轉移節點。在該種中斷模式下,再次設置線程的中斷狀態。
                 *     向後傳遞中斷標誌,由後續代碼去處理中斷。
                 */
                if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
                    break;
            }
            if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
                interruptMode = REINTERRUPT;
            if (node.nextWaiter != null) // clean up if cancelled
                //清理等待狀態爲 取消(CANCELLED) 的節點
                unlinkCancelledWaiters();
            if (interruptMode != 0)
                reportInterruptAfterWait(interruptMode);
        }

4.總結

synchronized和ReentrantLock 的區別

synchronized ReentrantLock
可以響應中斷 N Y
支持超時 N Y
非阻塞地獲取鎖 N Y
可重入 Y Y
支持公平鎖 N Y
獲取鎖/釋放鎖 自動 手動
發生異常時 自動釋放鎖 需手動釋放鎖
支持多個條件變量 N Y

synchronized 依賴於 JVM 而 ReentrantLock 依賴於 API

synchronized 是依賴於 JVM 實現的,JDK6 爲 synchronized 關鍵字進行了不少優化,這些優化都是在虛擬機層面實現的。ReentrantLock 是 JDK 層面實現的,也就是 API 層面,須要 lock() 和 unlock() 方法配合 try/finally 語句塊來完成。

ReentrantLock能夠支持多個條件變量

經過synchronized關鍵字與wait()和notify()/notifyAll()方法相結合實現的管程,其內部只能經過調用鎖定對象的wait()和notify()進行線程間通訊。假設有一個生產者多個消費者,消費者在消費完後須要通知生產者進行生產,但因爲生產者和其餘消費者都在synchronized鎖定的同一個對象上wait。

調用notify隨機喚醒的話,可能會喚醒的消費者,也可能喚醒生產者,若是喚醒生產者則能夠進行生產,若是被喚醒的是消費者,那麼該消費者仍是會因爲沒有庫存會喚醒其餘線程,本身繼續等待,若是消費者的數量遠遠多於生產者,那麼會一直出現消費者喚醒其餘消費者的現象,生產者不會被喚醒,則程序沒法繼續執行下去;

調用notifyAll方法的話,能夠解決這個問題,但也帶來另外一個問題。喚醒所有消費者的同時也會喚醒所有生產者,會帶來很大的性能開銷。

所以若是有一種方式能將生產者和消費者分離開,支持區分類型的喚醒,那這個問題就迎刃而解了。

經過Lock和Condition實現的管程對這一問題進行了解決,以前開頭的時候提過,Lock解決互斥,Condition解決同步,經過ReentrantLock對象的newCondition()方法,能夠在鎖定對象上綁定多個條件變量,也就是一個Lock對象中能夠建立多個Condition實例。

線程對象能夠註冊在指定的Condition中,從而能夠有選擇性的進行線程通知,在調度線程上更加靈活。Condition實例的signalAll()方法 只會喚醒註冊在該Condition實例中的全部等待線程。

Lock lock = new ReentrantLock();
Condition providers = lock.newCondition();
Condition consumer = lock.newCondition();
...
// 喚醒全部生產者
providers.signalAll();    
// 喚醒因此消費者
consumer.signalAll();

寫在最後:

本身動手實踐纔是真理,本身寫兩個線程,而後使用線程斷點一步一步的跟着看,在每一個環節儘量本身模擬多線程併發的狀況來觀察程序的運行變化。

28-多線程斷點.jpg

29-多線程斷點.jpg

在本人學習這一部份內容時,也對AQS源碼進行了閱讀,大體的流程很容易走下來,可是在流程背後的一些設計細節,殊不知其因此然。所以在本篇中沒有對整個AQS原理進行詳細的介紹,學習是一個逐漸深刻的過程。有的東西須要週期反覆的思考才能理解透徹。

Reference

  《Java 併發編程實戰》
  《Java 編程思想(第4版)》
  https://time.geekbang.org/col...
  https://juejin.im/post/5ae755...
  http://www.tianxiaobo.com

相關文章
相關標籤/搜索