聊聊 Java 多線程(4)-鎖的分類有這麼多

目前,多線程編程可以說是在大部分平臺和應用上都需要實現的一個基本需求。本系列文章就來對 Java 平臺下的多線程編程知識進行講解,從概念入門底層實現上層應用都會涉及到,預計一共會有五篇文章,希望對你有所幫助😎😎

本篇文章是第四篇,來介紹 Java 平臺下的鎖機制,鎖是 Java 開發者實現線程同步最爲簡單的一種方式

鎖是 Java 開發者實現線程同步最爲簡單的一種方式,最簡單的情形下我們只需要添加一個 synchronized 關鍵字就可以實現線程同步,但鎖的分類細數下來也不少,JVM 自動爲代碼中的鎖所做的優化措施也有很多,這裏來對其詳細講一講

一、悲觀鎖、樂觀鎖

悲觀鎖與樂觀鎖兩者體現了多個線程在對共享數據進行併發操作時的不同看法

對於多個線程間的共享數據,悲觀鎖認爲自己在使用數據的時候很有可能會有其它線程也剛好前來修改數據,因爲在使用數據前都會加上鎖,確保在使用過程中數據不會被其它線程修改。synchronized 關鍵字和 Lock 接口的實現類都屬於悲觀鎖

樂觀鎖則認爲在使用數據的過程中其它線程也剛好前來修改數據的可能性很低,所以在使用數據前不會加鎖,而只是在更新數據的時候判斷數據之前是否有被別的線程更新了。如果數據沒有被更新,當前線程就可以將自己修改後的數據成功寫入。而如果數據已經被其它線程更新過了,則根據不同的實現方式來執行不同的補救操作(報錯或者重複嘗試)。樂觀鎖在 Java 中是通過使用無鎖編程來實現的,最常採用的是 CAS 算法java.util.concurrent 包中的原子類就是通過 CAS 自旋來實現的

總的來說,悲觀鎖適合寫操作較多的場景,加鎖可以保證執行寫操作時數據的正確性。樂觀鎖適合讀操作較多的場景,不加鎖能夠使讀操作的性能大幅度提升


synchronized 關鍵字和 Lock 接口所代表的悲觀鎖比較常見,這裏主要來看下樂觀鎖

樂觀鎖採用的 CAS 算法全稱是 Compare And Swap(比較與交換),是一種無鎖算法,在不使用鎖(所以也不會導致線程被阻塞)的情況下實現在多線程之間的變量同步

CAS 算法涉及到三個操作數:

  • 需要讀寫的內存值 V
  • 進行比較的值 A
  • 要寫入的新值 B

當且僅當 V 的值等於 A 時,CAS 纔會用新值 B 來更新 V 的值,且保證了“比較+更新”這整個操作的原子性。當 V 的值不等於 A 時則不會執行任何操作。一般情況下,“更新”是一個會不斷重試的操作

這裏來看下 AtomicInteger 類的用於自增加一的方法 incrementAndGet() 是如何實現的。

public class AtomicInteger extends Number implements java.io.Serializable {
  
  private static final Unsafe unsafe = Unsafe.getUnsafe();
  
  /**
    * Atomically increments by one the current value.
    *
    * @return the updated value
   */
  public final int incrementAndGet() {
        return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
  }
  
}

incrementAndGet() 方法是通過 unsafe.getAndAddInt() 來實現的。getAndAddInt() 方法會循環獲取給定對象 o 中的偏移量 offset 處的值 v,然後判斷內存值是否等於 v。如果相等則將內存值修改爲 v + delta,否則就繼續整個循環進行重複嘗試,直到修改成功才退出循環,並且將舊值返回。整個“比較+更新”操作封裝在 compareAndSwapInt() 方法中,在 JNI 裏是藉助於一個 CPU 指令完成的,屬於原子操作,可以保證多個線程都能夠看到同一個變量的修改值

    public final int getAndAddInt(Object o, long offset, int delta) {
        int v;
        do {
            v = getIntVolatile(o, offset);
        } while (!compareAndSwapInt(o, offset, v, v + delta));
        return v;
    }

    public native int getIntVolatile(Object var1, long var2);
    
    public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

CAS 雖然很高效,但是也存在ABA問題,且如果 CAS 操作長時間不成功的話,會導致其一直自旋,給處理器帶來非常大的開銷

二、可重入鎖、非可重入鎖

鎖是否可重入表示的是這麼一種特性:鎖的持有線程在不釋放鎖的前提下,是否能夠再次申請到同一個鎖。例如,如果 synchronized 是可重入鎖,那麼 doSomething1() 是可以正常執行的。如果 synchronized 是不可重入鎖,那麼 doSomething1() 就會導致死鎖。而在現實情況下,Java 中 synchronized 和 ReentrantLock 都是可重入鎖

    private synchronized void doSomething1() {
        doSomething2();
    }

    private synchronized void doSomething2() {

    }

這裏也以 ReentrantLock 作爲例子來從看下其重入流程

我們知道,在使用 ReentrantLock 時,我們調用了 Lock.lock() N 次後就要相應調用 Lock.unlock() N 次纔可以使得持有線程真正地釋放了鎖,那麼這裏自然就需要有個狀態值來記錄 ReentrantLock 申請了幾次鎖(即重入了幾次)。ReentrantLock 包含一個內部類 Sync,Sync 繼承自 AQS(AbstractQueuedSynchronizer)

AQS 中維護了一個 int 類型的同步狀態值 status,其初始值爲 0,在不同的場景下具有不同的含義,對於 ReentrantLock 來說就是用來標記重入次數。當線程嘗試獲取鎖時,ReentrantLock 就會嘗試獲取並更新 status 值

  • 如果 status 等於 0。表示鎖沒有被其它線程搶佔,則把 status 置爲 1,同時當前線程成功搶佔到鎖
  • 如果 status 不等於 0。判斷當前線程是否是該鎖的持有線程,如果是的話則執行 status + 1,表示當前線程再次重入了一次。如果當前線程不是該鎖的持有線程,則意味着搶佔失敗

當持有線程釋放鎖時,ReentrantLock 同樣會先獲取當前 status 值。如果 status - 1 等於 0,表示當前線程已經撤銷了所有的申請操作,此時線程纔會真正釋放鎖,否則持有線程就還是依然佔用着鎖

三、公平鎖、非公平鎖

當多個線程同時申請同一個排他性資源,申請資源失敗的線程往往是會存入一個等待隊列中,當後續資源被其持有線程釋放時,如果剛好有一個活躍線程來申請資源,此時選擇哪一個線程來獲取資源的獨佔權就是一個資源調度的過程,資源調度策略的一個重要屬性就是能否保證公平性。所謂公平性,是指資源的申請者是否嚴格按照申請順序而被授予資源的獨佔權。如果資源的任何一個先申請者總是能夠被比任何一個後申請者先獲得資源的獨佔權,那麼該策略就被稱爲公平調度策略。如果資源的後申請者可能比先申請者先獲得資源的獨佔權,那麼該策略就被稱爲非公平調度策略。注意,非公平調度策略往往只是不保證資源調度的公平性,即它只是允許不公平的資源調度現象,而不是表示它刻意造就不公平的資源調度

公平的資源調度策略不允許插隊現象的出現,資源申請者總是按照先來後到的順序獲得資源的獨佔權。如果當前等待隊列爲空,則來申請資源的線程可以直接獲得資源的獨佔權。如果等待隊列不爲空,那麼每個新到來的線程就被插入等待隊列的隊尾。公平的資源調度策略的優點是:每個資源申請者從開始申請資源到獲得相應資源的獨佔權所需時間的偏差會比較小,即每個申請者成功申請到資源所需的時間基本相同,且可以避免出現線程飢餓現象。缺點是吞吐率較低,爲了保證 FIFO 加大了發生線程上下文切換的可能性

非公平的資源調度策略則允許插隊現象。新到來的線程會直接嘗試申請資源,只有當申請失敗時纔會將線程插入等待隊列的隊尾。假設兩種多個線程一起競爭同一個排他性資源的場景:

  1. 當資源被釋放時,如果剛好有一個活躍線程來申請資源,該線程就可以直接搶佔到資源,而無需去喚醒等待隊列中的線程。這種場景相對公平調度策略就少了將新到來的線程暫停將等待隊列隊頭的線程喚醒的兩個操作,而資源也一樣有被得到使用
  2. 即使等待隊列中的某個線程已經被喚醒來試圖搶佔資源的獨佔權,如果新到來的活躍線程佔用資源的時間不長的話,那麼就有可能在被喚醒的線程開始申請資源之前,新到來的活躍線程已經釋放了對資源的獨佔權,從而不妨礙被喚醒的線程申請資源。這種場景也一樣避免了將新到來的線程暫停這麼一個操作

因此,非公平調度策略的優點主要有兩點:

  1. 吞吐率一般來說會比公平調度策略高,即單位時間內它可以爲更多的申請者調配資源
  2. 降低了發生上下文切換的概率

非公平調度策略的缺點主要有兩點:

  1. 由於允許插隊現象,極端情況下可能導致等待隊列中的線程永遠也無法獲得其所需的資源,即出現線程飢餓的活性故障現象
  2. 每個資源申請者從開始申請資源到獲得相應資源的獨佔權所需時間的偏差可能較大,即有的線程可能很快就能申請到資源,而有的線程則要經歷若干次暫停與喚醒才能成功申請到資源

綜上所訴,公平調度策略適用於資源被持有的時間較長或者線程申請資源的平均時間間隔較長的情形,或者要求申請資源所需的時間偏差較小的情況。總的來說使用公平調度策略的開銷會比使用非公平調度策略的開銷要大,因此在沒有特別需求的情況下,應該默認使用非公平調度策略

公平鎖就是指採用了公平調度策略的鎖,非公平鎖就是指採用了非公平調度策略的鎖。Java 中的 synchronized 就是非公平鎖;而 ReentrantLock 既支持公平調度策略也支持非公平調度策略,且默認使用的也是非公平調度策略

這裏來簡單看下 ReentrantLock 的源碼來了解下公平鎖和非公平鎖的實現區別

ReentrantLock 申請和釋放鎖的大部分邏輯都是在其內部類 Sync 裏實現的。Sync 包含公平鎖 FairSync 和非公平鎖 NonfairSync 兩個不同的子類實現,ReentrantLock 默認使用的是 NonfairSync

FairSyncNonfairSync 在申請鎖時的會分別調用以下兩個方法,兩者的唯一的區別只在於公平鎖在獲取同步狀態時多了一個限制條件:hasQueuedPredecessors()

hasQueuedPredecessors() 方法用於判斷等待隊列中是否有排在當前線程之前的線程,如果有返回 true,否則返回 false。所以說,ReentrantLock 的公平調度策略只有在等待隊列爲空時才允許當前的活躍線程執行申請鎖的操作,而非公平調度策略則是直接就進行申請

四、互斥鎖、共享鎖

互斥鎖也稱爲排他鎖,是指該鎖一次只能被一個線程持有,當某個線程已經在持有鎖的時候其它來申請同個鎖實例的線程只能進行等待,以此來保證臨界區內共享數據的安全性。Java 中的 synchronized 和 java.util.concurrent.locks.Lock 接口的實現類就屬於互斥鎖

互斥鎖使得多個線程無法以線程安全的方式在同一時刻對共享數據進行只讀取而不更新的操作,這在共享數據讀取頻繁但更新頻率較低的情況下降低了系統的併發性,共享鎖就是爲了應對這種問題而誕生的。共享鎖是一種改進型的排他鎖,也稱爲共享/排他鎖。共享鎖允許多個線程同時讀取共享變量,但是一次只允許一個線程對共享變量進行更新。任何線程讀取共享變量的時候,其它線程無法更新這些變量;一個線程更新共享變量的時候,其它線程都無法讀取和更新這些變量

Java 平臺中的讀寫鎖就是對共享鎖這個概念的實現,由 java.util.concurrent.locks.ReadWriteLock 接口來定義,其默認實現類是 java.util.concurrent.locks.ReentrantReadWriteLock

ReadWriteLock 接口定義了兩個方法,分別用來獲取讀鎖(ReadLock)和寫鎖(WriteLock)。ReadLock 是共享的,WriteLock 是排他的,ReadLock 和 WriteLock 的操作最終都要轉交由內部類 Sync 來完成

上面在講“可重入鎖與非可重入鎖”這一節內容的時候,有提到:AQS 中維護了一個 int 類型的同步狀態值 status,其初始值爲 0,在不同的場景下具有不同的含義。對於 ReentrantReadWriteLock 來說,status 就用來標記當前持有讀鎖和寫鎖的線程分別是多少

而爲了在一個 32 位的 int 類型整數上來存儲兩種不同含義的數據,就需要將 status 進行分段切割,高 16 位用來存儲讀鎖當前被獲取的次數,低 16 位用來存儲寫鎖當前被獲取的次數

Sync 類內部就提供了兩個分別用來計算讀線程和寫線程個數的方法

1、共享流程

這裏先來看下線程在獲取讀鎖時的申請流程,這裏主要是要先前置判斷下讀寫鎖的寫鎖是否已經被持有了

        protected final int tryAcquireShared(int unused) {
            Thread current = Thread.currentThread();
            int c = getState();
            if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current)
                //如果當前已經有線程持有了寫鎖,且該線程並非當前線程
                //則返回 -1,表示讀鎖獲取失敗
                //這裏之所以要判斷線程是否相等,是因爲 ReentrantReadWriteLock 支持鎖的降級,可以在已經持有寫鎖的時候申請讀鎖
                return -1;
            
            //下面就是多個線程前來申請讀鎖或者是同個線程多次申請讀鎖的流程了
            int r = sharedCount(c);
            if (!readerShouldBlock() &&
                r < MAX_COUNT &&
                compareAndSetState(c, c + SHARED_UNIT)) {
                if (r == 0) {
                    firstReader = current;
                    firstReaderHoldCount = 1;
                } else if (firstReader == current) {
                    firstReaderHoldCount++;
                } else {
                    HoldCounter rh = cachedHoldCounter;
                    if (rh == null || rh.tid != getThreadId(current))
                        cachedHoldCounter = rh = readHolds.get();
                    else if (rh.count == 0)
                        readHolds.set(rh);
                    rh.count++;
                }
                return 1;
            }
            return fullTryAcquireShared(current);
        }

而線程在釋放讀鎖時,主要就是更新 state 值,將讀線程數量減一,寫線程數量不做改動

        protected final boolean tryReleaseShared(int unused) {
            Thread current = Thread.currentThread();
            if (firstReader == current) {
                // assert firstReaderHoldCount > 0;
                if (firstReaderHoldCount == 1)
                    firstReader = null;
                else
                    firstReaderHoldCount--;
            } else {
                HoldCounter rh = cachedHoldCounter;
                if (rh == null || rh.tid != getThreadId(current))
                    rh = readHolds.get();
                int count = rh.count;
                if (count <= 1) {
                    readHolds.remove();
                    if (count <= 0)
                        throw unmatchedUnlockException();
                }
                --rh.count;
            }
            for (;;) {
                int c = getState();
                //SHARED_UNIT 等於 1 << 16
                //c - SHARED_UNIT 就想當於 c 的高16位減1,低16位保持不變
                //從而使得讀線程數量減1,寫線程數量不變
                int nextc = c - SHARED_UNIT;
                //通過 CAS 來更新 state
                if (compareAndSetState(c, nextc))
                    // Releasing the read lock has no effect on readers,
                    // but it may allow waiting writers to proceed if
                    // both read and write locks are now free.
                    return nextc == 0;
            }
        }

2、互斥流程

再來看下線程在獲取寫鎖時的流程。主要是要考慮寫鎖的可重入性以及讀寫鎖的公平性與否

        protected final boolean tryAcquire(int acquires) {
            /*
             * Walkthrough:
             * 1. If read count nonzero or write count nonzero
             *    and owner is a different thread, fail.
             * 2. If count would saturate, fail. (This can only
             *    happen if count is already nonzero.)
             * 3. Otherwise, this thread is eligible for lock if
             *    it is either a reentrant acquire or
             *    queue policy allows it. If so, update state
             *    and set owner.
             */
            Thread current = Thread.currentThread();
            int c = getState();
            int w = exclusiveCount(c); //獲取當前寫線程數量
            if (c != 0) {
                // (Note: if c != 0 and w == 0 then shared count != 0)
                //1. 如果 c != 0 && w == 0 成立,說明當前存在讀線程,返回 false,寫鎖獲取失敗
                //2. 如果 c != 0 && w != 0 && current != getExclusiveOwnerThread() 成立
                //說明當前寫鎖已經被持有了,且持有寫鎖的線程並非當前線程,返回 false,寫鎖獲取失敗
                if (w == 0 || current != getExclusiveOwnerThread())
                    return false;
                if (w + exclusiveCount(acquires) > MAX_COUNT)
                    throw new Error("Maximum lock count exceeded");
                // Reentrant acquire
                //能走到這一步,說明當前線程已經持有了寫鎖,由於寫鎖是可重入的
                //所以這裏這裏更新下寫鎖被持有的次數後就返回了 true
                setState(c + acquires);
                return true;
            }
            //能走到這一步,說明當前寫鎖還未被持有,則根據讀寫鎖的公平性與否來完成寫鎖的申請
            if (writerShouldBlock() ||
                !compareAndSetState(c, c + acquires))
                return false;
            setExclusiveOwnerThread(current);
            return true;
        }

線程在釋放寫鎖時,由於 state 值的高 16 位肯定全是 0 (即讀線程數量爲 0),而低 16 位肯定不全是 0,所以主要就是來更新當前寫鎖被持有的次數

        protected final boolean tryRelease(int releases) {
            if (!isHeldExclusively())
                 //如果當前線程並非寫鎖的持有線程,則拋出異常
                throw new IllegalMonitorStateException();
            int nextc = getState() - releases;
            //由於寫鎖是可重入的,所以這裏也要判斷線程是否已經撤銷了所有的申請操作
            boolean free = exclusiveCount(nextc) == 0;
            if (free)
                //只有在寫鎖已經撤銷了所有的申請操作後纔會真正釋放鎖
                setExclusiveOwnerThread(null);
            setState(nextc);
            return free;
        }

五、自旋鎖、適應性自旋鎖

在一個排他鎖已經被持有且鎖的持有線程只會佔用鎖一小段時間的情況下,如果此時將來申請同個鎖實例的線程均進行暫停運行處理的話,鎖在暫停和後續喚醒過程中所需的時間耗時甚至可能會長於鎖被佔用的時間,此時暫停線程就顯得很不值得了。而自旋鎖的實現出發點就基於這麼一種觀測結果:在很多時候鎖的持有線程只需要佔用鎖一小段時間

自旋鎖的實現前提是當前物理機器包含一個以上的處理器,即支持同時運行一個以上的線程,此時就可以讓後面來申請鎖的線程不放棄處理器時間片而是稍微“等一等”,看看鎖是否很快就會被釋放。讓線程“等一等”,就是通過讓線程反覆執行忙循環(也稱自旋,可以理解爲執行空操作,實現原理也是 CAS)來實現的。如果鎖被佔用的時間很短,此時採用自旋鎖的效果就會非常好,不會導致上下文切換。而如果鎖被佔用的時間比較長,自旋鎖就會浪費很多處理器時間,因此也必須爲自旋操作限定一個最大次數,當達到限定的最大次數後如果仍然沒有獲得鎖的話就還是需要將線程進行暫停運行處理

因此,自旋鎖適用於絕大多數線程對鎖的持有時間比較短的情況,這樣能夠避免上下文切換的資源開銷和過多的處理器時間開銷。而對於系統中絕大多數線程對鎖的持有時間比較長的情況,就還是採用直接暫停線程的策略比較適合

在自旋鎖出現的一開始,只能對 JVM 中的所有鎖設定一個固定的最大自旋次數。而在後續也引入了適應性自旋鎖。適應性意味着自旋的時間(次數)不再是固定的,而是由前一次在該鎖上的自旋時間及其當前持有線程的狀態來決定。對於某個鎖,如果其當前正在被某個已經通過自旋成功獲得鎖的線程持有的話,那麼 JVM 就會認爲其它來申請同個鎖的線程再次使用自旋也很能再次成功,進而將允許自旋等待相對更長的時間。如果對於某個鎖自旋很少成功獲得過,那麼在以後嘗試獲取這個鎖時將可能省略掉自旋過程,而是直接阻塞線程,避免浪費處理器資源

總的來說,通過採用自旋鎖,鎖的申請就並不一定會導致上下文切換了,自旋鎖的自適應性也進一步降低了發生線程上下文切換的概率

六、偏向鎖、輕量級鎖、重量級鎖

偏向鎖、輕量級鎖、重量級鎖可以看做是三種狀態值或者說是操作手段,用於描述 synchronized 所對應的內部鎖所處的狀態,在不同的狀態下獲取內部鎖的實現步驟也各不相同,在理解這三種狀態前需要先了解下什麼是對象頭

1、對象頭

在 HotSpot 虛擬機裏,對象在堆內存中的存儲佈局可以劃分爲三個部分:對象頭(Header)、實例數據(Instance Data)和對齊填充(Padding) 。當中,對象頭包含着對象自身的運行時數據,如 HashCode、GC 分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程ID、偏向時間戳等,這部分數據的長度在 32 位和 64 位的虛擬機(未開啓壓縮指針)中分別爲 32 個比特和 64 個比特,官方稱它爲“Mark Word”,它是實現偏向鎖和輕量級鎖的關鍵。Mark Word 被設計成一個有着動態定義的數據結構,在運行期間 Mark Word 裏存儲的數據會隨着鎖標誌位的變化而變化,即在不同的狀態下會分別存儲具有不同含義的數據

例如,在 32 位的 HotSpot 虛擬機中,如果對象處於未被鎖定狀態,Mark Word 的 32 個比特存儲空間中的 25 個比特會用於存儲對象哈希碼,4 個比特用於存儲對象分代年齡,1個比特固定爲 0,2 個比特用於存儲鎖標誌位

我們在使用 synchronized 時會顯式或隱式地指定關聯的同步對象(實例變量或者是 Class 對象),而 Java 平臺中的任何一個對象都有一個唯一與之關聯的鎖,被稱爲監視器(Monitor)或者內部鎖。同步對象的對象頭所包含的運行時數據的變化過程,就是其內部鎖在偏向鎖、輕量級鎖、重量級鎖這四種狀態下的切換過程

2、偏向鎖

JVM 在實現 monitorenter(申請鎖) 和 monitorexit(釋放鎖) 這兩個字節碼指令時需要藉助一個原子操作(CAS 操作),這個操作代價相對來說比較昂貴。而如果在一段時間內一個鎖實例先後只會由同一個線程來申請並使用的話,那麼該線程每次申請和釋放鎖的代價就會被放大,顯得很不值得了。而偏向鎖的實現出發點就基於這麼一種觀測結果:大多數鎖並沒有被爭用,並且在其整個生命週期內總是同一個線程來進行申請

偏向鎖的執行流程是這樣的:

  1. 當某個鎖第一次被線程申請到時,JVM 會把同步對象的 Mark Word 中的標誌位設置爲“01”,偏向模式設置爲“1”,表示該鎖進入了偏向模式。同時使用 CAS 操作把當前線程的 ID 記錄在對象的 Mark Word 之中,如果 CAS 操作成功,該線程就會被記錄爲同步對象的偏好線程(Biased Thread),然後執行步驟 4。如果 CAS 操作失敗,則直接步驟 3
  2. 當又有線程前來申請鎖時,如果判斷到偏向鎖指向的 Thread ID 即爲當前線程,則直接執行步驟 4,即偏向線程以後每次進入這個鎖相關的同步塊時,都不用再進行任何加鎖操作。如果偏向鎖指向的 Thread ID 並非當前線程,說明當前系統存在多個線程競爭偏向鎖,則通過 CAS 來競爭鎖。如果競爭成功,則將Mark Word 中的線程 ID 設置爲當前線程 ID,然後執行步驟 4;如果競爭失敗,則執行步驟 3
  3. 因爲線程不會主動去釋放偏向鎖,所以如果 CAS 獲取偏向鎖失敗,則表示當前存在多個線程一個競爭偏向鎖。當到達全局安全點(safepoint)時,會首先暫停擁有偏向鎖的線程,然後檢查持有偏向鎖的線程是否活着(因爲持有偏向鎖的線程可能已經執行完畢,但該線程並不會主動去釋放偏向鎖),如果線程不處於活動狀態,則將對象頭設置成無鎖狀態(標誌位爲“01”),然後重新偏向新的線程;如果線程仍然活躍,則撤銷偏向鎖,將其升級到輕量級鎖狀態(標誌位變爲“00”),此時輕量級鎖由原持有偏向鎖的線程繼續持有,讓其繼續執行同步代碼,而正在申請鎖的線程則通過自旋等待獲得該輕量級鎖
  4. 執行同步代碼

引入偏向鎖是爲了提高帶有 synchronized 同步操作但實際上無爭用的代碼塊的性能,因爲偏向線程在獲取到偏向鎖之後,每次進入這個鎖相關的同步塊時,都不用再進行任何同步操作(例如加鎖、解鎖及對 Mark Word 的更新操作等)。而且輕量級鎖的獲取及釋放依賴多次 CAS 原子指令,而偏向鎖只需要在置換 Thread ID 的時候執行一次 CAS 原子指令即可

偏向鎖適用於存在相當大一部分鎖並沒有被爭用的系統,如果系統中存在大量被爭用的鎖而沒有被爭用的鎖僅佔小部分,那麼就可以考慮關閉偏向鎖

3、輕量級鎖

輕量級鎖是 JDK 6 時加入的新型鎖機制。當某個鎖是偏向鎖時,如果該鎖被其它線程訪問了,此時偏向鎖就會升級爲輕量級鎖,其它線程會通過自旋的方式來嘗試獲取鎖,此時該線程不會阻塞,從而提高了性能

在線程進入同步塊的時候,如果同步對象鎖沒有被鎖定(鎖標誌位爲“01”),JVM 首先會在當前線程的棧幀中建立一個名爲鎖記錄(Lock Record)的空間,然後拷貝對象頭中的 Mark Word 到鎖記錄中。拷貝完成後,JVM 將使用 CAS 操作嘗試將對象的 Mark Word 值更新爲指向 Lock Record 的指針,並將 Lock Record 裏的 owner 指針指向對象的 Mark Word。如果這個更新動作成功了,即代表這個線程擁有了該對象鎖,並且 Mark Word 的鎖標誌位將變更爲爲 “00”,表示此對象處於輕量級鎖定狀態。如果這個更新操作失敗了,那就意味着至少存在一個線程與當前線程競爭獲取該對象鎖。JVM 會先檢查對象的 Mark Word 是否指向當前線程的棧幀,如果是,說明當前線程已經擁有了這個對象的鎖,那直接進入同步塊繼續執行就可以了,否則就說明這個鎖對象已經被其他線程搶佔了。如果出現兩個以上的線程爭用同一個鎖的情況,那輕量級鎖就不再有效,必須要升級爲重量級鎖,鎖標誌的狀態值變爲“10”,此時 Mark Word 中存儲的就是指向重量級鎖(互斥量)的指針,此後等待鎖的線程也必須進入阻塞狀態

輕量級鎖的實現出發點是基於這麼一種觀測結果:大多數鎖在整個同步週期內都是不存在競爭的。這裏需要和偏向鎖區分開,偏向鎖的理論基礎是:大多數鎖總是在其整個生命週期內被同一個線程所使用。而輕量級鎖的理論基礎是:鎖可能會先後被多個線程使用,但由於線程間的交叉使用,所以大多數線程在使用同步資源時是不存在競爭的。偏向鎖相對輕量級鎖會更加“樂觀”,所以輕量級鎖就需要比偏向鎖多出更多的“安全保障措施”

如果沒有競爭,輕量級鎖便通過 CAS 操作成功避免了使用互斥量的開銷;但如果確實存在鎖競爭,除了互斥量的本身開銷外,還額外產生了 CAS 操作的開銷。因此在有競爭的情況下輕量級鎖會比傳統的重量級鎖更加消耗資源

4、重量級鎖

升級爲重量級鎖時,Mark Word 中前 30 位存儲的是指向重量級鎖(互斥量)的指針,鎖標誌的狀態值變爲“10”,此後等待鎖的線程就必須進入阻塞狀態。重量級鎖是實現鎖申請操作最爲消耗資源的一種做法

5、概述

綜上,偏向鎖通過對比 Mark Word 解決了加鎖問題,避免執行 CAS 操作。而輕量級鎖是通過用 CAS 操作和自旋來解決加鎖問題,避免線程阻塞和喚醒而影響性能。重量級鎖則是直接將除了擁有鎖的線程以外的線程都阻塞

七、鎖消除

鎖消除是 JIT 編譯器對內部鎖的具體實現所做的一種優化。在動態編譯同步塊的時候,編譯器會藉助逃逸分析技術來判斷同步塊所使用的鎖對象是否只會被一個線程使用。如果判斷出該鎖對象的確只能被一個線程訪問,編譯器在編譯這個同步塊的時候就不會生成 synchronize 所表示的 monitorenter 和 monitorexit 兩個機器碼,而僅生成臨界區內的代碼所對應的機器碼,從而消除了鎖的使用。這種優化措施就稱爲鎖消除,鎖消除使得在特定情況下可以完全消除鎖的開銷

例如,對於以下方法。StringBuffer 類本身是線程安全的,其內部多個方法(例如,append 方法)都使用到了內部鎖,而在toJson()方法裏 StringBuffer 是作爲一個局部變量存在的,並不會存在多個線程同時訪問的情況,此時 append 方法所使用到的內部鎖就成了一種無謂的消耗。所以,編譯器在編譯 toJson 方法的時候就會將其調用的 StringBuffer.append 方法內聯到該方法之中,相當於把 StringBuffer.append 方法的方法體中的指令複製到 toJson 方法體中,此時就可以避免 append 方法所聲明的內部鎖所帶來的消耗

    public String toJson() {
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append("xxx");
        return stringBuffer.toString();
    }

鎖消除不一定會被編譯器實施,這和同步代碼塊是否能被內聯有關,所以雖然鎖消除技術可以使得編譯器爲我們消除一部分的鎖開銷,但是這也不意味着開發者就可以隨意使用內部鎖了

八、鎖粗化

鎖粗化是指 JIT 編譯器會將相鄰的幾個同步代碼塊合併爲一個大的同步代碼塊的一種優化措施。通過鎖粗化,可以避免一個線程反覆申請和釋放同一個鎖所導致的開銷,相應的也會導致一個線程持有一個鎖的時間變長,從而使得鎖的等待線程申請鎖所需要的時間也相應變長

例如,對於以下代碼。通過鎖粗化技術就可以將多個同步代碼塊合併爲一個,且同步代碼塊之間的代碼也會被合併在一起,使得臨界區的長度變長。而原本在鎖 lockX 的持有線程執行完第一個同步代碼塊之後(即釋放 lockX 後),其它等待線程是有機會獲得 lockX 的,但是經過鎖粗化後使得 lockX 的持有線程只有執行完全部同步代碼塊之後纔會釋放 lockX,使得等待線程申請 lockX 的時間相應變長了。因此,爲了避免一個線程持有鎖的時間過長,鎖粗化不會被應用到循環體內的相鄰同步代碼塊

    //鎖粗化前
    public void test() {
        synchronized (lockX) {
            doSomethind1();
        }
        x = 10;
        synchronized (lockX) {
            doSomethind2();
        }
        y = 10;
        synchronized (lockX) {
            doSomethind3();
        }
    }

    //鎖粗化後
    public void test() {
        synchronized (lockX) {
            doSomethind1();
            x = 10;
            doSomethind2();
            y = 10;
            doSomethind3();
        }
    }

九、優化對鎖的使用

以上所講的大部分優化措施都是在編譯器這個層次實施的,這一節再來介紹下如何在代碼層次對鎖進行優化

1、降低鎖的爭用程度

在之前的文章中有介紹過使用鎖帶來的主要開銷,而如果必須使用鎖且鎖帶來的開銷很難避免,那麼要降低鎖的開銷的思路之一就是降低鎖的爭用程度。鎖的爭用程度和程序中需要同時使用到該鎖實例的線程數量有關,如果可以儘量降低每個線程來申請鎖時該鎖實例還被其它線程持有的情況,那麼就可以降低鎖的爭用程度。降低鎖的爭用程度可以用兩種方式來實現:減少鎖被持有的時間降低鎖的申請頻率

  • 減少鎖被持有的時間即讓每個線程持有鎖的時間儘量短,從而減少當某個線程申請鎖時而鎖的持有線程還未執行完臨界區代碼的情況,而且也有利於 Java 虛擬機的適應性鎖發揮作用。可以通過減少臨界區長度來縮減鎖被持有的時間,例如:將不會導致競態的代碼(局部變量的訪問等)放到臨界區之外執行,使得每個線程在持有鎖的過程中需要執行的指令儘量少。此外,也需要避免在臨界區中執行阻塞式 IO 等阻塞操作,阻塞操作會導致線程被暫停和上下文切換,而在線程被暫停的過程中其持有的鎖也不會被釋放,這樣會增大鎖被爭用的可能性
  • 降低鎖的申請頻率可以通過減小鎖的粒度來實現。例如,多個線程間存在多個共享變量,而共享變量之間並沒有特定的關聯關係,此時就可以分別使用不同的鎖對象來保障不同的共享變量在多個線程間的線程安全性。假設多個線程間存在兩個共享變量 A 和 B,如果變量 A 和變量 B 之間並沒有關聯關係,那麼在訪問共享變量的時候就可以使用不同的鎖,線程 A 在訪問變量 A 的時候可以使用 Lock A 來保障安全性,而在線程 A 持有 Lock A 的過程中也不妨礙線程 B 申請 Lock B 對變量 B 進行訪問。通過這種使用不同的鎖來保障不同共享數據的安全性,從而減少鎖的爭用程度。但如果鎖的粒度過細也會增加鎖調度的開銷,需要在實際開發中衡量使用

2、使用可參數化鎖

如果一個方法或者類的內部所使用的鎖實例可以由其使用者來指定的話,那麼就可以說這個鎖是可參數化的,相應的這個鎖就被稱爲可參數化的鎖。使用可參數化鎖有助於減少線程需要申請的鎖實例的個數,從而減少鎖的開銷

例如,對於以下例子。假設 Printer 類是由第三方提供的工具類,其內部需要保障自身的線程安全性,所以使用到了內部鎖,其鎖實例默認是其本身變量實例(即 this) 。LogPrinter 類作爲客戶端/使用者,其內部也需要保障自身的線程安全性(例如:line++; ),所以也使用到了內部鎖。但由於 Printer 的所有方法均由 LogPrinter 已經保障了線程安全性的方法進行調用,此時 Printer 內部使用到的內部鎖就成了多餘配置,增加了無謂的鎖開銷

由於 Java 平臺中的鎖都是可重入的,且鎖的持有線程在未釋放鎖的情況下重複申請該鎖的開銷時所需要的開銷比較小,所以此時就可以依靠 Printer 類提供的可參數化鎖配置,將 LogPrinter 聲明的鎖實例 lock 作爲構造參數傳給 Printer,從而減少了鎖開銷

    class Printer {

        private final Object lock;

        public Printer(Object lock) {
            this.lock = lock;
        }

        public Printer() {
            this.lock = this;
        }

        public void print(String msg) {
            synchronized (lock) {
                System.out.println(msg);
            }
        }

    }

    class LogPrinter {

        private final Object lock = new Object();

        private final Printer printer = new Printer();

        private int line;

        public void print(String msg) {
            synchronized (lock) {
                line++;
                printer.print(msg);
            }
        }

    }

十、參考資料

  1. 不可不說的Java“鎖”事

一個人走得快,一羣人走得遠,寫了文章就只有自己看那得有多孤單,只希望對你有所幫助😂😂😂

查看更多文章請點擊關注:字節數組

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