看完這篇,有人問你synchronized 關鍵字就不要慫

寫在前面

鎖的出現,是多線程併發編程所需要的,如果程序在併發執行,同時對一個資源進行操作,這是很容易出現問題的:多個線程同時運行,就像是活在同一個地球上不同維度的生物,它們互相感知不到對方,卻在操作同一個東西,可能操作着操作着,突然東西就不見了,或者變多了。這是因爲它們在同時操作,而且操作的時候沒有互相告知。Java 原生的有兩種實現鎖的機制,一種是通過底層實現的 synchronized 關鍵字,另一種是 Doug Lea 在 JDK1.5 實現的 java.util.concurrent 包中的 Lock 類。這兩種方法一種是 Java 關鍵字,另一種是用對象的方式,兩種都實現了併發狀態下對公共資源的加鎖。

synchronized 原理鋪墊

synchronized 是一個 Java 的關鍵字,能夠對併發資源上鎖,它由 JVM 實現,也就是說 synchronized 跟底層有關係。synchronized 關鍵字從 JDK 1.0 就存在了,最開始是一種代價很大的保證線程安全的方法(但也是唯一一種),在 JDK 6 被重新設計,性能大大提升。這一性能提升,一方面歸功於軟件代碼設計的進步,另一方面也要歸功於硬件的發展。

最初的 synchronized 關鍵字

最開始的 synchronized 關鍵字,基於互斥同步的原理來實現。互斥同步的意思是說:如果一個線程在使用資源,另一個線程想要使用資源,就要等,等到能獲取資源爲止,這裏的等就是互斥的表現,一方使用,另一方就不準使用(即阻塞)。互斥同步是一種很消耗性能的操作,這是因爲實現互斥的方式:阻塞,是一種很消耗性能的操作。

這裏要提到操作系統的用戶態和內核態。主流的 Java 虛擬機對於 Java 線程的實現,是直接將 Java 線程映射到操作系統的原生內核線程之上的,因此實現線程阻塞和線程喚起,必須需要操作系統來幫忙完成,操作系統的用戶態和內核態之間進行轉換。一個線程嘗試獲取資源時,發生線程阻塞,這裏的阻塞是操作系統來幫助進行的,操作系統由用戶態轉爲內核態,在內核態狀態下將這一條線程阻塞住。

用戶態和內核態是很重要的操作系統的概念,這裏不多進行學習,只記住有這麼一回事就可以了。用戶態和內核態在進行轉換的過程中,需要保存上下文信息,將兩種狀態的信息都存儲下來,這是很消耗資源的。因此互斥同步是很很消耗性能的,用戶態和內核態之間進行轉換消耗的處理器時間,甚至比同步狀態下的代碼執行時間還要長,這是一種非常重量級的操作,由此造成了最初的 synchronized 關鍵字性能很差。

經過改進的 synchronized 關鍵字

最初 synchronized 關鍵字性能差的原因,是因爲互斥同步是通過線程阻塞來實現的,而線程阻塞必然導致操作系統在用戶態和內核態之間做轉換,因而性能差。如果 synchronized 關鍵字不通過互斥同步實現(不通過阻塞線程來實現安全),那麼性能說不定就會好很多。

阻塞,是一種無奈之舉,因爲不阻塞住線程,就不敢保證操作數據的過程是安全的。不安全最常見的現象是:一條線程讀取完數據進行操作,還沒保存,另一條線程就修改了數據,那麼這時再保存,就會無視剛剛修改的數據,換言之,另一條線程的操作被“無效化”了。

一種比較常見的處理辦法是,獲取資源時記錄下數據的值,在保存的時候,先比對數據是否還是當時的大小,如果是,就默認資源沒有問題,可以進行保存。這也就是併發中非常重要的概念:CAS(compare and swap - 比較後交換),先比較預期的數據,如果是預期的大小,就交換值(保存)。值得一提的是,CAS 是另一種實現線程安全的方式:JUC 包的核心邏輯。(但是這實際上還是有潛在問題的,比如我在早晨 10 點獲取到數據知道是 1,在下午 5 點發現數據還是 1,這並不能保證數據在這段時間中沒有被改過,有可能改了又改回來了,即“ABA 問題”,但好在大多數的情況下 ABA 問題不會影響程序併發的正確性)

如果通過 CAS 操作數據,就可以代替阻塞,性能提高。CAS 的英文原名是 compare & swap,這是指 compare 和 swap 必須在一起進行,執行完 compare 就必須接着執行 swap,即這兩個動作合在一起是原子性的,是不能拆開的。這也就是爲什麼 synchronized 關鍵字性能提升是需要藉助於硬件技術的提高的,因爲 CAS 必須由硬件執行,而不能是軟件(如果是軟件實現,那還是通過互斥同步的方式進行,這就沒有意義了),最初的 cpu 在硬件指令集中是沒有 CAS 操作的,之後纔出現這一指令,JDK 5 的 Java 類庫開始使用 CAS 操作,在 JDK 6 中使用該操作對 synchronized 進行了改造。

粗略地講,synchronized 通過 CAS 操作進行改造的原理,是分了兩種情況:如果只有一個線程使用資源(但在理論上有可能有別的線程搶資源),直接 CAS 保存數據就可以了,不需要阻塞線程;如果線程一多爭搶資源,那沒有辦法,乖乖地阻塞線程,通過互斥同步來實現線程安全。

補充

原始的 synchronized 通過互斥同步來實現線程安全,新的 synchronized 通過 CAS 操作來部分實現線程安全,這實際上也是兩種思路,兩種在面對併發風險時的思路。

  • 互斥同步的思路是,有可能發生併發風險,那麼我提前準備,一條線程使用,另一條線程就不準使用。
  • CAS 的思路是,有可能發生併發風險,不用提前準備,先進行 CAS 操作保存,真發現了數據不一樣再說。

一種是提前應對風險,將風險扼殺在搖籃中,另一種是不管風險先進行操作,產生了衝突再進行補償措施。這兩種思路實際上就是鎖機制當中的“樂觀鎖“和”悲觀鎖“的思路。樂觀和悲觀指的是面對併發風險時的態度:

  • 樂觀的話,先不管風險,幹了再說,有問題回來找補(對應於 CAS 操作)
  • 悲觀的話,先考慮風險,萬無一失,再進行數據處理(對應於互斥同步)

因此樂觀鎖回滾重試,悲觀鎖阻塞事務。JDK 6 之後的 synchronized 關鍵字就是先樂觀,樂觀不起來了再悲觀。

synchronized 原理

學習 synchronized 關鍵字需要對 JVM 中對象的內存佈局(尤其是對象頭部分)有所瞭解。對象頭的內容,在此不多贅述。
在這裏插入圖片描述
對象存儲在 JVM 堆裏,鑑於內存寸土寸金,需要儘可能地縮減對象頭的大小,因此對象頭有五種狀態,在不同的狀態下存儲不同的信息。上圖的前四種與 synchronized 關鍵字有關,分別在【沒有鎖】、【偏向鎖】、【輕量級鎖】和【重量級鎖】狀態下,存儲不同的信息。換個角度理解,這意味着 synchronized 也有四種場景。

synchronized 關鍵字的原理(改進之後),就像是開車掛擋,起步一檔,速度上來之後掛二檔,最後一腳油門上了三擋。

  • 如果只有一個線程在使用資源,那麼掛一檔:偏向鎖
  • 如果有少數幾個線程在使用資源,那麼掛二檔:輕量級鎖
  • 如果有好幾個線程在使用資源,那麼掛三擋:重量級鎖

這三種檔位是針對於 JDK 6 之後的 synchronized,在這之前起步直接三擋。

對應於這三個檔位(外加上空擋)一共有四種狀態,這四種狀態的標誌位如下:

偏向模式(1 bit) 鎖標誌位(2 bit)
無鎖 0 01
偏向鎖 1 01
輕量級鎖 (沒有該字段) 00
重量級鎖 (沒有該字段) 10

幾種狀態鎖

偏向鎖

對於上鎖的對象,有一個資源爭搶的升級過程。最開始的情況,是隻有一條線程在使用資源,這時並不存在競爭的情況。如果不存在競爭,上鎖是沒有必要的,或者說上重量級的鎖是沒有必要的,畢竟沒其他線程搶資源。

偏向鎖的偏向,是“偏心”的“偏”、“偏袒”的“偏”,其含義是偏向線程,偏向於第一個獲取到它的線程。如果之後一直沒有其他線程出現,則持有偏向鎖的線程永遠不需要進行同步。如果出現了新的線程,偏向鎖立即終止。

因此,如果只有一條線程使用資源,則使用偏向鎖。如果出現了第二條線程,不論這兩條線程是否存在競爭,鎖都會膨脹,偏向鎖即刻作廢。(還是有一些例外的示,如果前一條線程死亡了,新的線程來申請資源,還是能繼續使用偏向鎖的)在這種意義上,偏向鎖是不需要解鎖的,因爲它從始至終只會有一個鎖的主人,出現了第二個主人時,它就作廢了,沒有解鎖是偏向鎖相比於輕量級鎖、重量級鎖的一個區別。

偏向鎖的具體實現,實際上還是比較繁瑣的。總體上講,是把偏向線程的線程ID記錄在對象頭中,之後再此使用前比對線程ID,如果就是當前線程則無需同步,如果不是當前線程那麼偏向鎖立即停止使用。

細緻地講,偏向鎖的上鎖過程如下(自行對照上面對象頭示意圖):

確保可以上偏向鎖

首先對象應處於未上鎖狀態(鎖標誌位是 01),且對象應爲可偏向(偏向標誌位是 1),因此對象頭的標記部分應爲 101 結尾。由於無鎖和偏向鎖的鎖標誌位是相同的(都是 01),因此另用 1 bit 來表示對象是否可偏向。JDK 6 下的 HotSpot 虛擬機默認開啓偏向鎖,可以手動設置參數關閉。

參照上圖,對象頭在無鎖的狀態下會保存對象的哈希碼(hashcode),實際上這並不一定,如果對象沒有計算過哈希碼(例如調用 Object :: hashCode() 會計算哈希碼),那麼哈希碼將不會保存在對象頭中。但一旦計算過哈希碼,對象頭中就會儲存哈希碼,這個對象就再也不會進入偏向鎖狀態了,如需上鎖,它只會一步到位膨脹成重量級鎖。

(附上 64 位 JVM 的對象頭標記字段,在無鎖和偏向鎖狀態下的內容:)
|------------------------------------------------------------------------------|----------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 |     Normal     |(無鎖)
|------------------------------------------------------------------------------|----------------|
| thread:54 |       epoch:2        | unused:1 | age:4 | biased_lock:1 | lock:2 |     Biased     |(偏向鎖)
|------------------------------------------------------------------------------|----------------|

通過 CAS 嘗試上偏向鎖

在確保對象進入偏向模式的前提下,JVM 將會使用 CAS 操作把獲取到這個鎖的線程的ID記錄在對象的標記字段中(圖中的對象頭標記字段,在偏向模式下的偏向 ID 部分,32 位虛擬機佔 23 bit,64 位虛擬機佔 54 bit)。如果 CAS 記錄線程 ID 成功,那麼認爲偏向鎖上鎖成功,以後持有偏向鎖的線程每次進入這個鎖相關的同步塊時,都不需要進行任何同步操作。如果 CAS 記錄線程 ID 失敗,那麼偏向模式馬上就宣告結束。

  • 如果此時對象沒有上鎖,那麼該對象將先撤銷偏向(將偏向標誌位設置爲 0),再升級爲輕量級鎖(這一步的撤銷偏向是有一定的性能損耗的)

  • 如果此時對象已經上了偏向鎖,那麼該對象將繼續申請輕量級鎖

(下圖爲《深度理解 Java 虛擬機》的配圖,描述了偏向鎖膨脹到輕量級鎖的過程)
在這裏插入圖片描述

對象頭的標記字段中,還有一個字段:偏向時間戳(epoch)

這個字段的作用是統計重偏向次數。重偏向的概念是這樣的,如果有一個類實例化 20 個對象出來,這 20 個對象先經歷線程 1,再經歷線程 2,上鎖時需要發生 20 次的撤銷偏向,再升級到輕量級鎖的過程。偏向鎖的撤銷是比較昂貴的(原理暫不考究),如果這種現象多次出現,就意味着這個類不適合使用偏向鎖。

對於這種場景 JVM 單獨做了優化,類記錄了一個 epoch 值,對象在創建時也將有一個 epoch 值(創建時與類的相同)。如果類對象發生了一次大規模的撤銷偏向行爲,類的 epoch 值將加 1(以後創建的對象也會採用新的 epoch 值),如果類的 epoch 值超過某個閾值,則證明該類不適合使用偏向鎖,以後的對象也將不會再使用偏向鎖,直接使用輕量級鎖。

對象頭中的 epoch 值是爲了和類的 epoch 值對比用的,如果不一樣,則將直接膨脹到輕量級鎖。

輕量級鎖

當資源不再只被一條線程獲取,出現了兩個及以上的線程時,偏向鎖立即作廢,膨脹爲輕量級鎖。

輕量級鎖存在的意義是,如果有多個線程獲取資源,但是是交替獲取的,並沒有發生資源競爭的風險,那麼加一個輕量級鎖,保證其中一條線程在運行時另一條線程不會並行操作即可。因此輕量級鎖的目的,是爲了消除數據在無競爭情況下的同步原語,提高程序的運行性能。

(附上 64 位 JVM 的對象頭標記字段,在偏向鎖和輕量級鎖狀態下的內容:)
|---------------------------------------------------------------------|--------------------|
| thread:54 |   epoch:2   | unused:1 | age:4 | biased_lock:1 | lock:2 |       Biased       |(偏向鎖)
|---------------------------------------------------------------------|--------------------|
|                     ptr_to_lock_record                     | lock:2 | Lightweight Locked |(輕量級鎖)
|---------------------------------------------------------------------|--------------------|

無論是無鎖還是偏向鎖,這兩種狀態的鎖標誌位都是 01,都可以膨脹到輕量級鎖,將鎖標誌位修改爲 00。對於輕量級鎖而言,上鎖的對象,其對象頭的標記字段只有兩部分內容:分別是鎖標誌位(2 bit,值爲 00),以及正在佔有對象的線程ID。

無鎖膨脹到輕量級鎖的過程是這樣的(偏向鎖的話,要先撤銷偏向到無鎖狀態,再進行膨脹):

  • 確保對象沒有被鎖定,鎖標誌爲是 01。

  • 備份對象頭的標記字段
    將對象頭的標記字段(Mark Word)拷貝到當前線程的棧幀中。也就是把那 8 字節包含着哈希碼、分代年齡、偏向狀態、鎖標誌位等信息的標記字段,存儲在當前線程的 JVM 棧中。標記字段保存在線程棧幀的地址,叫做“鎖記錄”(Lock Record),換種表述方法,這塊 Lock Record 用來存儲對象目前的 Mark Word 的拷貝。

  • CAS 更新對象頭,上輕量級鎖
    虛擬機使用 CAS 操作嘗試將對象頭的 Mark Word 更新爲指向 Lock Record 的指針(就是上一步中,線程棧幀備份對象頭的地址),並將對象頭的鎖標記更新爲輕量級鎖(00)。
    如果這步 CAS 操作能夠成功,那麼輕量級鎖就上好了,如果沒有成功,則證明在同一時間有多個線程在競爭資源,輕量級鎖不再有效,鎖進一步膨脹爲重量級鎖。

如果對象已經上了輕量級鎖,當有線程再次申請資源時:

  • 如果是同一個線程,則是一次鎖重入。每次鎖重入依舊會在線程棧幀中創建一個 Lock Record,只不過重入創建的 Lock Record 的值爲 null,即它不再是對象頭標記字段的備份。
  • 如果是另一個線程,說明存在多個線程競爭鎖,鎖膨脹爲重量級鎖。

輕量級鎖有解鎖的操作,當線程操作完對象資源後,需要將輕量級鎖解除。解鎖的方法,是將對象頭的 Mark Word 和線程棧中的 Lock Record 通過 CAS 替換回來,如果 CAS 操作失敗代表有其他線程在競爭資源,鎖膨脹。

重量級鎖

當出現兩個或更多的線程,在同一時間操作資源時,會發生線程競爭,此時鎖膨脹爲最強的重量級鎖,採取互斥同步的方式,讓同一時間只有一個線程操作資源,其他線程阻塞等待。

重量級鎖通過一個 monitor 對象實現多線程競爭時的互斥同步,monitor(監視器)是併發設計中很重要的設計,在不同的語言中有不同的實現。monitor 作爲監視器,監視的是資源,每一個類或者每一個對象只能有一個 monitor 對象,這個 monitor 由 JVM 創建,能夠保證同一時間只會有一個線程使用資源,其他線程都乖乖阻塞。

在 JVM 中 monitor 是 ObjectMonitor 類的實例對象,該類源碼由 C++ 編寫,代碼如下:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //monitor進入數
    _waiters      = 0,
    _recursions   = 0;  //線程的重入次數
    _object       = NULL;
    _owner        = NULL; //標識擁有該monitor的線程
    _WaitSet      = NULL; //等待線程組成的雙向循環鏈表,_WaitSet是第一個節點
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ; //多線程競爭鎖進入時的單項鍊表
    FreeNext      = NULL ;
    _EntryList    = NULL ; //處於等待鎖block狀態的線程,會被加入到該列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
}

通過這個類實例化的 monitor 對象,一對一地監控着每一個需要互斥同步的類或對象,由 _owner 屬性記錄併發競爭成功的線程,執行完後換下一個線程,實現重量級鎖。如果有其他線程嘗試獲取 monitor,會由於線程重入次數不爲 0 而被迫阻塞。

synchronized 是以代碼塊的形式使用的,例如:

public void func() {
    synchronized (this) {
        // ...
    }
}

JVM 將 Java 代碼解釋成 CPU 原語時,解析 synchronized 關鍵字,會分別將代碼塊的開始和結束,解釋成 monitorenter 和 monitorexit,這兩個原語非常形象,就是進入 monitor 和離開 monitor。通過這兩個 CPU 原語,JVM 使每個線程都要去 monitor 處報到,等待重新調度。

synchronized 使用

synchronized 關鍵字有四種使用表現,分別是同步對象、類、方法、靜態方法,而同步方法和靜態方法,實際上還是在同步對象和類,因此從原理上 synchronized 關鍵字同步的是對象或類。

同步一個對象

對任意一個對象加 synchronized,代碼塊當中的代碼都會同步。

Object object = new Object();
synchronized (object) {
    // ...
}

例如下列代碼:實現一個 Runnable 接口,按順序打印 1-10

// r1不同步
Runnable r1 = () -> {
    for (int i = 1; i <= 10; i++) {
        System.out.print(i);
    }
};

// r2同步
Runnable r2 = () -> {
    synchronized (object) {
        for (int i = 1; i <= 10; i++) {
            System.out.print(i);
        }
    }
};

此時在線程池中分別跑不同步的 r1 和同步的 r2,每次線程池中跑兩個線程

ExecutorService executorService = Executors.newCachedThreadPool();

executorService.execute(r1);
executorService.execute(r1);
// 打印結果:1 1 2 2 3 4 5 6 7 8 9 10 3 4 5 6 7 8 9 10 

executorService.execute(r2);
executorService.execute(r2);
// 打印結果:1 2 3 4 5 6 7 8 9 10 1 2 3 4 5 6 7 8 9 10

一種非常常見的同步對象的方式,是在類方法中同步 this,即表示同步當前對象。

synchronized (this) {
    // ...
}

同步一個類

當 synchronized 同步一個類時,使用該類的所有線程,無論是在操作哪一個對象,都將進行同步。

public void func() {
    synchronized (SynchronizedExample.class) {
        // ...
    }
}

例如如下代碼:自定義一個 MyClass 類,該類只有一個按順序打印 1-10 的方法。生成兩個該類的對象,並調用兩個線程分別執行這兩個類的打印數字方法。

// 創建一個包含打印數字方法的類
class MyClass {
    void testSync() {
        for (int i = 1; i <= 10; i++) {
            System.out.print(i);
        }
    }
}

// 創建兩個類對象
MyClass clazz1 = new MyClass();
MyClass clazz2 = new MyClass();

// 在線程池中執行打印數字的方法
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> clazz1.testSync());
executorService.execute(() -> clazz2.testSync());

// 打印結果:1 2 3 4 5 1 2 3 4 5 6 7 8 9 10 6 7 8 9 10

如果對類方法進行 synchronized 同步,同步的內容是一個類(任意一個類都可以),則可實現線程間的同步。

void testSync() {
    synchronized (Object.class) {
        for (int i = 1; i <= 10; i++) {
            System.out.print(i);
        }
    }
}

// (其他代碼略)打印結果:1 2 3 4 5 6 7 8 9 10 1 2 3 4 5 6 7 8 9 10

同步一個方法

public synchronized void func () {
    // ...
}

它作用於同一個對象。(這就是 HashTable 不如 ConcurrentHashMap 的地方,因爲它在方法上同步,鎖住了整個對象,太過笨重)

同步一個靜態方法

public synchronized static void fun() {
    // ...
}

它作用於整個類。

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