Lock 的一百種玩法(剖析 Java Lock 原理)

大家都知道 Java 中有 synchronized 實現鎖,也有 Lock 接口來實現顯示的鎖。synchronized 關鍵字更多貼近 Java 虛擬機,而 Lock 則更多貼近我們的 Java 代碼。Lock 也具備了很多傳統 synchronized 不具備的功能,本身也包含了很多的設計思維。學習 Lock 可以很好地提升一個人的 Java 功底,也能從中隱示地提高一個人的編程素養。

學習忌浮躁

附(作者的話):

原本我是想用 一篇文章 來讓大家瞭解和深入理解 Lock 接口的各種知識。但是後來我深入學習後才發現,關於 Lock 的知識點,確實是 過於龐大,光靠一篇文章的簡單敘述實在是難以詳盡。
所以這篇文章我會更多的讓大家去理解 Lock 鎖的 基本實現原理設計思維,等你理解了這些,就足夠去應對一些 中型 企業(因爲要去大廠面試,基本上你的競爭對手也都懂得這些)。

我花費的更多精力寫的更詳盡的文章,是對 JDK 逐行 源碼 中的 細節 做更多探討(因爲多線程難就難在這很多很多細小的情況,各種複雜的邏輯分析,各種運行不確定和錯誤難以重現)。
所以,這篇文章可以作爲學習兩大 lock 源碼的基礎,看完我這篇博客,然後再去閱讀我的源碼解讀的文章。
ReentrantLock 的源碼解讀
ReentrantReadWriteLock 的源碼解讀

鎖的本質意義

鎖是對共享變量的一種保護:
我們在對共享變量進行操作的時候,會產生各種各樣的安全問題。爲了保證對資源的安全訪問,因此出現了鎖。
很多人可能會對線程、資源、鎖的各種關係分纏不清,我們可以這樣來理解:

  • 首先鎖是對資源的保護,因而鎖住的是資源(也就是我們的對象)
  • 線程是程序的執行者,它負責操作各種各樣的資源,因而線程需要去獲取鎖,是鎖的擁有者
  • 線程爲了保護資源,因此需要持有鎖,鎖住寶貴的資源

鎖從資源的訪問角度可以分爲 獨佔鎖共享鎖(讀鎖、寫鎖):

獨佔鎖

獨佔鎖(又叫互斥鎖、排它鎖):
對資源的保護需求不同,或者線程的操作要求不同。有時,我們必須保證一個資源的擁有者 只能有一個線程。這時線程就要自己獨佔資源,將其他線程隔絕在外。

比如:我們搶車位,一個停車位,在同一時刻,只能停下一輛汽車。我們必須在其他車來之前,獲取停車位,成功佔有停車位。此時,其他車子如果需要這個停車位,就只能等待。等到你的車子開走 了,他們中的一個才能再次佔據車位,然後其他車次繼續等待。
獨佔鎖

共享鎖:

有些情況下,我們對資源雖然需要保護,但是沒有達到一個人持有纔不會出錯的保護狀態。也就是 多個線程一起共享,也不會出錯。
就比如,我們同時需要看一部小電影。雖然這部電影的所有人很珍惜它,生怕被弄壞了,盡力保護。不過他發現,把電影傳在網上,讓別人一起去看,也不會對資源本身造成破壞。也就是可以很多人聚在一塊一起看,這樣就 增加了資源的利用
共享鎖

Lock 接口概覽

在 locks 包中,關鍵的就是 ReentrantLock 和 ReentrantReadWriteLock。
鎖的實現都是基於 Lock 接口,雖然 ReentrantReadWriteLock 沒有繼承 Lock 接口,不過,它的方法就是用來構建出一對 Lock,ReadLock 和 WriteLock,而 read 和 write 的鎖就是實現 Lock 接口,所以要理解明白 JDK 中的鎖,就要從 Lock 接口開始研究。
lock
lock 接口的各大方法:

  • void lock();
    獲取鎖的時候不死不休,只有獲取到鎖之後纔會繼續往下執行。
  • boolean tryLock();
    獲取鎖的時候淺嘗輒止,如果能獲取就獲取,獲取不到就不去獲取了。
  • boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    獲取的時候只有一部分耐心,等了太久就不等了。
  • void lockInterruptibly() throws InterruptedException;
    獲取鎖的時候沒有決心,別人一打斷,不讓他獲取就不獲取了。
  • void unlock();
    釋放鎖
  • Condition newCondition();
    lock

ReentrantLock 基本使用

ReentrantLock 有兩種,公平鎖和非公平鎖。

  • 公平鎖保證線程按照順序獲取到鎖,先來先得,後來等待。
  • 非公平鎖則會出現插隊現象。一個線程來了之後,不管三七二十一,直接搶鎖,搶到了就開心,搶不到再回去等待。
    因爲這樣可能存在另一個線程喚醒的期間,這個新來的線程已經將任務執行完了,這是候另一個線程喚醒成功,執行它的任務。(這樣在喚醒一個線程執行它的任務的時候,一次性執行完了兩個任務,從而使得非公平鎖效率更高。)
Lock lock = new ReentrantLock(true);  // 公平鎖
Lock lock = new ReentrantLock(false); // 非公平鎖
Lock lock = new ReentrantLock();      // 默認非公平鎖

加鎖解鎖

與 synchronized 不同的是,ReentrantLock 需要顯示地加鎖和解鎖
並且加鎖幾次,就需要解鎖幾次

lock.lock();   // 加鎖
lock.lock();   // 再次加鎖
lock,unlock(); // 解鎖
lock,unlock(); // 一定要再解鎖一次

等待與喚醒

Lock 接口有個 newCondition(); 方法,我在上面並沒有詳細述說。
說道這個,其實我們就能再想到 synchronized 關鍵字,我們知道,Lock 本身就是在 Java 語言層面實現了並擴展 synchronized 關鍵字,最初是爲了提升性能和提供更高級的用法。
所以要了解 Condition,只要會使用 synchronized 關鍵字下的 wait() 和 notify() 方法即可。

在調用 lock() 方法上鎖之後,就可以調用 condition.await() 方法,釋放持有的鎖,並進入等待。另一個線程就可以調用 lock() 方法獲取到鎖,然後調用 condition.signal() 或者 condition.signalAll() 方法喚醒一個或所有的線程。此時,由於鎖還被線程佔有,所以一般這個線程去叫醒其他線程後,自己就釋放鎖,讓叫醒的線程去獲取。
(和 wait notify 幾乎沒有差別)

public class Demo {
    private static Lock lock = new ReentrantLock();
    private static Condition condition = lock.newCondition();

    public static void main(String[] args) throws InterruptedException {
        Thread th = new Thread(new Runnable() {
            @Override
            public void run() {
                lock.lock(); // 獲取鎖
                System.out.println("condition.await()");
                try {
                    condition.await(); // 釋放鎖,開始等待
                    System.out.println("我又被叫回來了");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }finally {
                    lock.unlock();
                }
            }
        });
        th.start();
        
        Thread.sleep(2000L); // 睡兩秒,先讓子線程獲取鎖,然後釋放等待
        lock.lock(); // 獲取鎖
        condition.signalAll(); // 叫醒線程
        lock.unlock(); // 釋放鎖,讓子線程獲取到繼續執行
    }
}

與 synchronized 關鍵字的 wait notify 一樣,這裏同樣容易出現死鎖。
假如先 notify(也就是這裏的 signal),然後再 wait(這裏的 await),就會出現永久等待,因爲線程先喚醒過它了,之後不去喚醒了,而它纔開始等待,這樣就永遠不會結束。

public class Demo {
    private static Lock lock = new ReentrantLock();
    private static Condition condition = lock.newCondition();

    public static void main(String[] args) throws InterruptedException {
        Thread th = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(2000L); // 在這兩秒主線程已經執行了喚醒操作
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                lock.lock();
                try {
                    System.out.println("獲得鎖,調用condition.await()\n");
                    condition.await();      // 不會再被喚醒了
                    System.out.println("喚醒了...\n");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    lock.unlock();
                }
            }
        });

        th.start();
        lock.lock();
        condition.signal(); // 喚醒子線程,但是子線程還沒有開始等待
        lock.unlock();
    }
}

多條件等待與喚醒

之所以 Lock 厲害,除了高性能,還有一點就是擴展了 synchronized 關鍵字。
上面展示的 Condition 只是與 synchronized 相同之處,但是更厲害的地方在於,一個 Lock 可以不僅僅只創建一個 Condition,它可以創建多個 Condition,然後線程 await 時可以選擇不同的 Condition,signal 的時候也可以指定去喚醒特定的 Condition 下等待的線程。

就如同很多人去排隊,可以分成很多的隊列排隊。然後,每次服務員給客戶服務的時候,都可以選擇一條指定的隊伍去服務。
這樣就更加靈活,不同需求的人可以分開,比如按男女分開。這樣每次叫人都很輕鬆,而不用全都擠在一起,每次要找特定的人就會很難。比如要找女生,就去女生隊列,要找男生,就去男生隊列。如果都在一塊,就很容易叫錯人。

ReadWriteLock 鎖降級

加鎖解鎖大家很容易理解,在沒有寫鎖時,所有的讀鎖都不會阻塞,可以同時加一把鎖。
在寫鎖來時,會阻塞其他的讀鎖和寫鎖。

這裏主要提及一下鎖降級。
當一個鎖持有讀鎖時,是不能夠直接獲取讀鎖的,因爲讀鎖是共享的,所以很可能其他線程也持有讀鎖,這樣寫鎖仍然會阻塞。
但是一個線程如果持有寫鎖,那就保證了,其他線程不會持有讀鎖,那麼它就可以安全地獲取讀鎖,實現鎖降級。

鎖的底層原理

設置狀態值

我們可以用這樣一種方式來實現鎖

if(null == owner)
    owner = Thread.currentThread();

這樣,通過一個標記來表示是誰獲取了鎖。其他線程來時,由於鎖的持有者已經不是 null,它們就無法獲取鎖。
不過,代碼直接這麼寫是不行的,這不是原子操作,會出現線程不安全的問題。
可能兩個線程都執行到第一步,判斷 owner == null。然後其中一個線程修改了 owner 的值,但是另一個線程剛剛也判斷過了,也會來修改 owner 的值。
這樣就出現了問題,鎖的持有者在持有狀態時竟然被別人給奪走了!

要解決這樣一個非原子問題,有兩個解決辦法。
一個是加鎖
一個是 CAS

顯然,加鎖是不可取的。我們總不能用一把鎖去實現另一把鎖吧。
所以就需要 CAS 登場。

CAS

CAS(Compare And Swap):比較並交換。
在對一個共享變量的修改中,只有一個線程能夠修改成功。它會拿現有的值和期望值比較,如果現有的值是期望修改的那個值,纔會被修改。否則,如果被別的線程修改,就不會再是期望的值,就會修改失敗。
比如,兩個線程同時要將 0 改爲 1。由於一個線程先把 0 改成了 1,第二個線程再去修改時,值已經不是 0,所以修改失敗。
cas
所以之前的代碼就可以這樣來表示

cas(null, Thread.currentThread());

可重入

通過 CAS 雖然保證了線程能安全持有鎖。
但是仍有一個問題,當線程已經獲取了這個鎖的時候,如果在獲取鎖,就會獲取不到。那在自己內部就會死鎖,這是非常不友好的。
所以之前的一個變量並不能滿足我們的需求。

爲了能夠使鎖可以重入,我們可以設置一個 int 類型的變量。0 表示沒有鎖,1 表示有線程持有鎖,2、3、4 等等表示鎖重入的次數。
此時獲取鎖應寫爲

compareAndSwap(0, 1);

在已經持有鎖的情況下,重入時直接加 1 就行了。

自旋

通過之前的 CAS 方法,可以保證獲取鎖是安全的。但是,如果一個線程獲取不到鎖,就會直接返回 false,也就是實現了 tryLock() 的方法。
不過更多時候我們都是需要線程去等待,直到獲取鎖了之後才返回。
這時候我們可以用自旋:while() 循環讓它重複獲取鎖

while(!compareAndSwap(0, 1)) {
    // 獲取不到鎖,反覆嘗試
}

不過這樣還是有一個缺點,就是十分耗 CPU。因爲線程獲取不到鎖,一直在額外的重複加鎖的動作,而這時候持鎖者可能需要很久纔會釋放鎖。這段時間的瘋狂加鎖操作就是白白浪費了寶貴的 CPU 資源。

yield+自旋

爲了讓其他線程不用瘋狂無故消耗 CPU 資源,我們稍微對代碼加一點潤色。

while(!compareAndSwap(0, 1)) {
    Thread.yield();
}

每次獲取不到鎖,主動退出 CPU,通過線程調度使真正需要 CPU 資源的線程可以充分利用。
但是,這個方法仍舊是不靠譜的。

  • 假設只有兩個線程,當 yield() 調度之後,在 CPU 時間切片過後,仍然會切換線程,再次回到該線程執行,然後該線程再次 CAS,再次 yield() 調度。往復循環,仍然會浪費掉很多計算資源和線程的切換資源。
  • 假設線程數量很多,同時又很多線程爭搶鎖。這時,一個線程讓出 CPU,線程調度到另一個爭搶的線程,然後同樣的,再次切換到另一個爭搶的線程……如此往復,仍然是起不到好的效果。

sleep+自旋

於是,爲了讓 CPU 可以充分地供給給真正需要使用的線程。誕生了如下寫法。

while(!compareAndSwap(0, 1)) {
    Thread。sleep(???);
}

讓線程得不到鎖就睡一會。
但是,仍然有很大問題。因爲,這個睡眠時間是沒法確定的。

  • 假設持有鎖的線程執行了一小時,每次線程獲取不到鎖睡眠 10 秒。那麼,這麼長的時間裏,它仍然要重複很多次。
  • 假設持有鎖的線程只執行了 1 毫秒,但是另一個線程睡了 10 秒。這樣就浪費掉了原本有 9999 個任務的執行。

阻塞+自旋

爲了真正達到只在有任務需要 CPU 時,才讓線程執行。最終出現如下方法。

while(!compareAndSwap(0, 1)) {
    park();
}

在獲取不到鎖的時候,直接阻塞線程。只有等到另一個線程執行完了,再喚醒它。
這樣就保證了線程不浪費 CPU 資源。
(當然,實際上,線程的阻塞與喚醒也是有很多消耗的。所以在自旋鎖與阻塞鎖之間,都需要分析取捨)

ReentrantLock 基本實現原理(實現模型)

ReentrantLock 主要是基於一個 int數值變量 來表示加鎖的狀態:
0 表示沒有被加鎖,1 表示被加鎖,2、3、4… 表示持有鎖的線程重入的次數

加鎖的過程就是線程通過 CAS 去將這個變量的值從 0,修改爲 1,這樣,只有修改成功的線程能夠持有鎖。
其他線程就要自旋或者阻塞等待。

等待的線程必須被保存起來,這樣將來釋放鎖之後,才能夠找到那些等待的線程,去將其喚醒。
因此,爲了讓線程排隊,就需要一個線程安全的隊列,去存放阻塞的線程。
(再放置一個變量,用於保存當前的持鎖線程)
ReentrantLock

ReentrantReadWriteLock 實現模型

ReadWriteLock 有兩把鎖,一把讀鎖,一把寫鎖。
其中讀鎖共享,寫鎖互斥。

那麼我們就需要兩個變量去分別表示 共享鎖 和 互斥鎖 分別的持有數目
在 互斥鎖 沒有被持有的情況下,多個線程去同時獲取讀鎖,是不用阻塞,可以一起獲取到的
ReadWriteLock
而如果有 寫鎖(互斥鎖)來獲取鎖,那麼 讀鎖(包括其他寫鎖)就必須被阻塞
ReadWriteLock

AQS 的設計思維(設計模式)

AQS 內部用鏈表維護了一個隊列,用於實現對線程的排隊、阻塞、與喚醒。

不僅如此,AQS 內部封裝了 state 變量值,並且封裝了 加互斥鎖,加共享鎖,解鎖,可中斷鎖 的模板方法。
像 ReentrantLoc、ReentrantReadWriteLock,都是採用了一個內部類,去繼承 AQS,重寫沒有實現的自己需要的方法。
所以要學會 Java 中的 鎖,就必須明白 AQS。

作者的話

這篇博客寫得相對簡單,因爲確實覺得內容較爲基礎,大家應該是都很理解的。
很多地方,應該只要大致掃一眼就能懂,可以全當複習。

我將其寫完,主要是爲了銜接一下我寫的兩篇 ReentrantLock 和 ReentrantReadWriteLock 的源碼解讀,將其中的一些基礎的 lock 實現的大致原理描述,這樣,大家在閱讀源碼的時候就不容易迷失方向。

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