線程安全和鎖

1、進程與線程的概念

先來複習一下操作系統中所講的進程、線程這兩個概念:

  • 進程:是併發執行的程序在執行過程中分配和管理資源的基本單位,是一個動態概念,競爭計算機系統資源的基本單位。
  • 線程:是進程的一個執行單元,是進程內可調度實體。比進程更小的獨立運行的基本單位。線程也被稱爲輕量級進程。

2、併發與並行的概念

  • 併發:指的是某個時間段內,多任務交替處理的能力。在某段執行時間內,每個CPU不可能只顧着執行某個進程,而讓其他進程一致處於等待狀態,所以CPU把可執行的時間均勻地分成若干份,每個進程執行一段時間後,記錄當前的工作狀態,釋放相關資源並進入等待狀態,讓其他進程來搶佔CPU資源。
  • 並行:指的是同時處理多任務的能力。目前的CPU已經發展到多核,可以同時執行多個互不依賴的指令及執行塊。

併發與並行的目的都是儘可能快地執行完所有任務。以醫生坐診爲例,某個科室有兩個專家同時出診,這就是兩個並行任務;其中一個醫生,時而問診,時而查看化驗單,然後又繼續問診,突然又中斷去處理病人的諮詢,這就是併發。

在併發的環境下,由於程序的封閉性被打破,出現了以下的特點:

  1. 併發程序之間有相互制約的關係。直接制約體現爲一個程序需要另一個程序的計算結果;間接制約則體現爲多個程序競爭共享資源,如處理器、緩衝區等。
  2. 併發程序的執行過程是斷斷續續的。程序需要記憶現場指令及執行點。
  3. 當併發數設置合理並且CPU擁有足夠的處理能力時,併發會提高程序的運行效率。

3、線程安全

線程是CPU調度和分派的基本單位,爲了更充分地利用CPU資源,一般會使用多線程進行處理。多線程的作用是提高任務的平均執行速度,但是會導致程序可理解性變差,編程難度較大。

例如,樓下有一車磚頭需要工人搬到6樓,如果10個人一起搬,速度肯定是要比一個人搬要快,完成任務的時間會大大降低。但是論單次的時間成本,相比一個人去搬,10個人勢必會造成樓道內更加擁擠堵塞,所以10個人一起上下樓梯的速度肯定要比1個人慢。如果無限制地增加人數,比如10000個人參與搬磚,反而會因爲樓道擁堵不堪導致變得更慢,所以合適的人數纔會使得工作效率最大化。同理,合適的線程數才能讓CPU資源被充分利用,線程數量並不是說越多越好

下圖爲計算機的資源監視數據,PID指的就是進程ID,平均CPU指的是進程所使用CPU在60秒內的平均百分比。

線程可以擁有自己的操作棧、程序計數器、局部變量表等資源,它與統一進程內的其他線程共享該進程的所有資源。線程在生命週期內存在多種狀態,有NEW(新建狀態)、RUNNABLE(就緒狀態)、RUNNING(運行狀態)、BLOCKED(阻塞狀態)、DEAD(中止狀態)這五種狀態。

  • NEW,即新建狀態,是線程被創建且未啓動的狀態。創建線程的方式有三種:第一種是繼承自Thread類,第二種是實現Runnable接口,第三種是實現Callable接口。相比第一種,推薦第二種方式,因爲繼承自Thread類往往不符合裏式代換原則(即父類適用的地方子類同樣適用),而實現Runnable接口可以使編程更加靈活,對外暴露的細節比較少,讓使用者專注於實現線程的run()方法上。第三種Callable接口的call()聲明如下:
    /**
     * Computes a result, or throws an exception if unable to do so.
     * @return computed result, V is generics value
     * @throows Exception if unable to compute a result
     */
     V call() throws Exception;
     /**
     * 由此可知,Callable與Runnable有兩點不同:
     * 第一,可以通過call()獲得返回值。前兩種方式都有一個共同的缺陷,即在任務執行完成後,無法直接獲取執行結果,需要藉助
     * 共享變量等獲取,而Callable和Future則很好地解決了這個問題。
     * 第二,call()可以拋出異常。而Runnable只能通過setDefaultUncaughtExceptionHandler()的方式才能在主線程中捕捉到子
     * 線程異常。
     */

     

  • RUNNABLE,即就緒狀態,是調用start()之後運行之前的狀態。線程的start()不能被多次調用,否則會拋出IllegaStateException異常。

  • RUNNING,即運行狀態,是run()正在執行時線程的狀態。線程可能會由於某些因素而退出RUNNING,如時間、異常、鎖、調度等。

  • BLOCKED,即阻塞狀態,進入此狀態,有以下三種情況:同步阻塞(鎖被其他線程佔用)、主動阻塞(調用Thread的某些方法,主動讓出CPU執行權,比如sleep()、join()等)、等待阻塞(執行了wait())。

  • DEAD,即中止狀態,是run()方法執行結束,或因異常退出後的狀態,此狀態不可逆轉

計算機線程處理過程當中,因爲各個線程輪流佔用CPU的計算資源,可能會出現某個線程還沒執行完就不得不中斷的情況,容易導致線程不安全。例如,在服務端某個高併發業務共享某用戶數據,首先A線程執行用戶數據查詢任務,但數據尚未返回就退出CPU時間片;然後B線程搶佔了CPU資源執行並覆蓋了該用戶數據,最後A返回中斷現場,直接將B線程處理後的用戶數據返回給前端,導致頁面顯示數據錯誤。爲保證線程安全,在多個線程併發地競爭共享資源時,通常採用同步機制協調各個線程的執行,以確保得到正確的結果

想要保證併發場景下的線程安全,可以從以下四個維度考量:

  1. 數據單線程內可見。單線程總是安全的,通過限制數據僅在單線程內可見,可以避免數據被其他線程篡改。最典型的就是線程局部變量,它存儲在獨立虛擬機棧幀的局部變量表中,與其他線程毫無瓜葛。ThreadLocal就是採用這種方式來實現線程安全的。
  2. 只讀對象。只讀對象總是安全的,它的特性是允許複製、拒絕寫入。最典型的只讀對象由String、Integer等。一個對象想要拒絕任何寫入,就必須滿足一下四個條件:使用final修飾類,避免被繼承;使用private final關鍵字來避免屬性被中途修改;沒有任何更新方法;返回值不能爲可變對象。
  3. 線程安全類。某些線程安全類的內部有非常明確的線程安全機制。比如StringBuffer就是一個線程安全類,它採用synchronized關鍵字來修飾相關方法。
  4. 同步與鎖機制。如果想對某個對象進行併發更新操作,但又不屬於上述三類,就需要開發工程師在代碼中實現安全的同步機制。雖然這個機制支持的併發場景很有價值,但非常複雜且容易出現問題。

線程安全的核心理念就是“要麼只讀,要麼加鎖”。要學會合理運用Java併發包(java.util.concurrent,即JUC)。JUC主要分成以下幾個類族:

  1. 線程同步類。這些類使線程間的協調更加容易,支持了更加豐富的線程協調場景,逐步淘汰了使用Object的wait()和notify()進行同步的方式。主要代表爲CountDownLatch、Semaphore、CyclicBarrier等。
  2. 併發集合類。集合併發操作的要求是執行速度快,提取數據準,最著名的類非ConcurrentHashMap莫屬,它不斷地優化,由剛開始的鎖分段到後來的CAS,不斷地提升併發性能,其他還有ConcurrentSkipListMap、CopyOnWriteArrayList、BlockingQueue等。
  3. 線程管理類。雖然Thread和ThreadLocal在JDK1.0中就已經引入,但是真正把Thread發揚光大的是線程池。根據實際場景的需要,提供了多種創建線程池的快捷方式,如使用Executors靜態工廠或者使用ThreadPoolExecutor等。另外通過ScheduledExecutorService來執行定時認識。
  4. 鎖相關類。鎖以Lock接口爲核心,派生出在一些實際場景中進行互斥操作的鎖相關類。最著名的是ReentrantLock。鎖的很多概念在弱化,是因爲鎖的實現在各種場景中已經通過類庫封裝進去。

4、什麼是鎖

在計算機信息世界裏,單線程時代沒有鎖的概念,但自從出現了資源競爭,人們才意識到需要對部分場景的執行現場加鎖,以此來昭告天下,表明自己的“短暫”擁有(其實對於任何有形或無形的東西,擁有都不可能是永恆的)。計算機的鎖也是從開始的悲觀鎖,發展到後來的樂觀鎖、偏向鎖、分段鎖等。鎖主要提供了兩種特性:互斥性和不可見性。因爲鎖的存在,某些操作對外界來說是黑箱(即外界不清楚具體細節)進行的,只有鎖的持有者才知道對變量進行了什麼修改。


什麼是悲觀鎖?什麼是樂觀鎖?

  • 悲觀鎖:總是假設最壞的情況,每次取數據時都認爲其他線程會修改,所以都加鎖(讀鎖、寫鎖、行鎖等),當其他線程想要訪問數據時,都需要阻塞掛起。可以依靠數據庫實現,如行鎖、讀鎖、寫鎖等,都是在操作之前加鎖。在Java中,synchrsynchronized的思想也是悲觀鎖。
  • 樂觀鎖:總是認爲不會產生併發問題,每次去取數據的時候總認爲不會有其他線程對數據進行修改,因此不會上鎖,但是在更新時會判斷其他線程在這之前有沒有對數據進行修改,一般會使用版本號機制或者CAS操作實現。(version方式:一般在數據表中加上一個數據版本號version字段,表示數據被修改的次數,當數據被修改時,version值加1。當線程A要更新數據時,在讀取數據的同時也會讀取version值,在提交更新時,若剛纔讀到的version值爲當前數據庫中的version值相等時才更新,否則重試更新操作,直到更新成功)

參考鏈接:樂觀鎖與悲觀鎖


下面通過對JUC包中基礎類的解析來說明鎖的本質和特性,Java中常用鎖實現的方式有兩種。

1、用併發包中的鎖類

併發包的類族中,Lock是JUC包的頂層接口,它的實現邏輯並未用到synchronized,而是利用了volatile的可見性(因爲線程的共享對象存在於堆中,各個線程都是在自己私有的內存區域中創造一個該共享對象的副本進行操作,操作完以後再同步到堆中的本體中去,這中間勢必會造成一個時間差,在這個時間差內,各個線程的操作是其他線程不可見的。被volatile修飾的數據不會被創建副本,任何操作都是在堆上對本體直接進行的。這就是volatile的可見性,詳細內容的請見我的另一篇博客線程同步)。先通過Lock來了解JUC包的一些基礎類,下圖爲Lock的繼承類圖:

ReentrantLock對於Lock接口的實現主要是依賴了Sync,而Sync繼承了AbstractQueuedSynchronizer(AQS),它是JUC包實現同步的基礎工具。在AQS中,定義了一個volatile int state變量作爲共享資源,如果線程獲取資源失敗,則進入同步FIFO隊列中等待;如果成功獲取資源就執行臨界區代碼。執行完釋放資源時,會通知同步隊列中的等待線程來獲取資源後出隊並執行。

AQS是抽象類,內置自旋鎖實現的同步隊列,封裝入隊和出隊的操作,提供獨佔、共享、中斷等特性的方法。AQS的子類可以定義不同的資源實現不同性質的方法。比如可重入鎖ReentrantLock,定義state爲0時可以獲取資源並置爲1。若已經獲得資源,state不斷加1,在釋放資源時state減1,直至爲0;CountDownLatch初始時定義了資源總量state=count,countDown()不斷將state減1,當state=0時才能獲得鎖,釋放後state就一直爲0。所有線程調用await()都不會等待,所以CountDownLatch是一次性的,用完後如果再想用就只能重新創建一個;如果希望循環使用,推薦使用基於ReentrantLock實現的CyclicBarrier。Semaphore與CountDownLatch略有不同,同樣也是定義了資源總量state=permits,當state>0時就能獲得鎖,並將state減1,當state=0時只能等待其他線程釋放鎖,當釋放鎖時state加1,當其他線程又能獲得這個鎖。當Semphore的permits定義爲1時,就是互斥鎖,當permits>1就是共享鎖。

JDK9提出了一個新的鎖:StampedLock,改進了讀寫鎖ReentrantReadWriteLock。這些新增的鎖相關類不斷豐富了JUC包的內容,降低了併發編程的難度,提高了鎖的性能和安全性。

2、利用同步代碼塊

同步代碼塊一般使用Java的synchronized關鍵字來實現(synchronized可以保證可見性和有序性,volatile只能保證可見性,而不能保證有序性),有兩種方式對方法進行加鎖操作:

  1. 在方法簽名處加synchronized關鍵字;
  2. 使用synchronized(對象或類)進行同步。

這裏的原則是鎖的範圍儘可能小,鎖的時間儘可能短,即能鎖對象,就不要鎖類;能鎖代碼塊,不就要鎖方法

synchronized鎖特性由JVM負責實現。在JDK的不斷優化迭代中,synchronized鎖的性能得到極大提升,特別是偏向鎖的實現,使得synchronized已經不再是昔日那個低性能且笨重的鎖了。JVM底層是通過監視鎖來實現synchronized同步的。監視鎖即monitor,是每個對象與生俱來的一個隱藏字段。使用synchronized時,JVM會根據synchronized的當前使用環境,找到對應對象的monitor,再根據monitor的狀態進行加、解鎖的判斷。例如,線程在進入同步方法或代碼塊時,會獲取該方法或代碼塊所屬對象的monitor,進行加鎖判斷。如果成功加鎖就成爲該monitor的唯一持有者。monitor在被釋放前,不能再被其他線程獲取。

JVM利用CAS在對象頭上設置線程ID,表示這個對象偏向於當前線程,這就是偏向鎖。偏向鎖是爲了在資源沒有被多線程競爭的情況下儘量減少鎖帶來的性能開銷。在鎖對象的對象頭中有一個ThreadId字段,當第一個線程訪問鎖時,如果該鎖沒有被其他線程訪問過,即ThreadId字段爲空,那麼JVM讓其持有偏向所,並將ThreadId字段的值設置爲該線程的ID。當下一次獲取鎖時,會判斷當前線程的ID是否與鎖對象的ThreadId一致。如果一致,那麼該線程不會再重複獲取鎖,從而提高了程序的運行效率。如果出現鎖的競爭情況,那麼偏向鎖會被撤銷並升級爲輕量級鎖。如果資源的競爭非常激烈,會升級爲重量級鎖。偏向鎖可以降低無競爭開銷,它不是互斥鎖,不存在線程競爭的情況,省去在此同步判斷的步驟,提升了性能。

5、synchronized的底層實現原理

synchronized修飾同步代碼塊的原理

示例代碼


public class SynchronizedDemo {
	public void method() {
		synchronized (this) {
			System.out.println("synchronized 代碼塊");
		}
	}
}

synchronized修飾同步代碼塊時,主要用到兩個指令:

  1. monitorenter:指向同步代碼塊開始的地方;
  2. monitorexit:指向同步代碼塊結束的地方;

每個對象的對象頭中都有一個monitor,即監視器鎖。當開始執行monitorenter指令的時候,線程就獲取了當前對象的monitor,此時計數器加1(原本計數器值爲0,表示該鎖可以獲取);當執行monitorexit指令的時候,釋放monitor,計數器減1。

其他線程向使用該同步代碼塊的話,要先檢查對象頭中的monitor狀態,如果計數器值爲1,則進入阻塞狀態,直到當前線程釋放鎖。

synchronized修飾方法的實現原理

示例代碼:

public class SynchronizedDemo2 {
	public synchronized void method() {
		System.out.println("synchronized 方法");
	}
}

synchronized修飾方法時,不再是使用monitorenter和monitorexit指令來實現,而是使用ACC_SYNCHRONIZED標識,這個標識表明了這個方法是一個同步方法。JVM通過檢查方法是否有ACC_SYNCHRONIZED標識來判斷方法是不是同步方法,從而執行相應的同步調用。

其實原理跟synchronized修飾同步代碼塊的原理是一樣的,都是獲取對象的monitor來達到同步,只是沒有使用字節碼指令而已。

6.鎖的四種狀態

從JDK1.6開始,對鎖的實現進行了極大程度的優化,如偏向鎖、輕量級鎖、自旋鎖、適應性自旋鎖、鎖消除、鎖粗化等技術來減少鎖的開銷。

鎖主要存在四種狀態:

  1. 無鎖狀態
  2. 偏向鎖狀態
  3. 輕量級鎖狀態
  4. 重量級鎖狀態

它們會隨着競爭的激烈而逐漸升級,注意!鎖只能升級而不能降級!這個策略是爲了提高獲得鎖和釋放鎖的效率!

需知知識點:對象頭中的Mark Word

HotSpot虛擬機的對象頭(Object Header)分爲兩部分,第一部分用於存儲對象自身的運行時數據,如哈希碼(hashCode)、GC分代年齡等,這部分數據的長度在32位和64位的虛擬機中分別爲32bit和64bit,官方稱它爲Mark Word,它是實現輕量級鎖和偏向鎖的關鍵;另一部分用於存儲指向方法區對象類型數據的指針;如果是數組對象的話,還會有一個額外的部分用於存儲數組長度。

在32bit的HotSpot中,Mark Word的存儲內容如下表所示:

存儲內容 標誌位(2bit) 狀態
對象哈希碼(25bit)、對象分代年齡(4bit) 01 未鎖定
指向鎖記錄的指針 00 輕量級鎖定
指向重量級鎖的指針 10 膨脹(重量級鎖定)
空,不記錄任何信息(1bit) 11 GC標記
偏向線程ID、偏向時間戳、對象分代年齡 01 可偏向

 

一、偏向鎖

引入偏向鎖的目的和引入輕量級鎖的目的很相似,都是爲了在沒有多線程競爭的前提下,減少傳統的重量級鎖使用操作系統互斥量產生的性能消耗。但是不同的是:輕量級鎖在無競爭的情況下會使用CAS操作去替代使用互斥量;而偏向鎖會在無競爭情況下把整個同步操作消除,連CAS操作都不做。

偏向鎖中的“偏”字就是偏心的“偏”,意思是它會偏向於第一個獲取它的線程,如果在接下來的執行過程中,該鎖沒有被其他線程獲取的話,那麼持有偏向鎖的線程就不需要進行同步;

如果虛擬機啓用了偏向鎖,那麼當線程第一次獲取這個鎖對象的時候,虛擬機會把對象頭Mark Word中的標誌位設爲01,即偏向模式。同時使用CAS操作把獲取到這個鎖的線程ID記錄在對象的Mark Word之中,如果CAS操作成功,持有偏向鎖的線程以後每次進入這個鎖相關的同步代碼塊時,都不需要再進行任何同步操作。

但是偏向鎖不適用於競爭激烈的場合,因爲當有另一個線程去嘗試獲取這個鎖時,偏向模式就宣告結束。根據鎖對象目前是否處於被鎖定的狀態,撤銷偏向後恢復到未鎖定(標誌位爲01)或輕量級鎖定(標誌位爲00)的狀態。

偏向鎖可以提高帶有同步但無競爭的程序性能,它同樣是一個帶有效益權衡性質的優化,也就是說,它不一定總是對程序運行有利,如果程序中大多數的鎖總是被多個不同線程訪問,那偏向鎖就是多餘的。在具體問題具體分析的情況下,有時候使用參數-XX:-UseBiasedLocking來禁止偏向鎖優化反而可以提升性能。

二、輕量級鎖

“輕量級”是相對於使用操作系統互斥量來實現的傳統鎖而言的,因此傳統鎖的鎖機制就成爲“重量級”鎖。首先要強調的是輕量級鎖不是用來代替重量級鎖的,它的本意是在沒有多線程競爭的前提下,減少傳統的重量級鎖使用操作系統互斥量而產生的性能消耗

在進入同步代碼塊的時候,如果此同步對象沒有被鎖定(鎖標誌位爲01狀態),虛擬機首先將在當前線程的棧幀中建立一個名爲鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的Mark Word的拷貝(Displace Mark Word)。然後虛擬機將使用CAS操作嘗試將對象的Mark Word更新爲指向Lock Record的指針。如果這個更新動作成功了,那麼這個線程就擁有了該對象的鎖,並且對象Mark Word的鎖標誌位將轉變爲00,表示此對象處於輕量級鎖定狀態;如果這個CAS操作失敗,虛擬機會先檢查對象的Mark Word是否指向當前線程的棧幀,如果只說明當前線程已經擁有了這個對象的鎖,那就可以直接進入同步快繼續執行,否則就說明這個鎖的對象已經被其他線程搶佔了。如果有兩條以上的線程爭用同一個鎖,那輕量級鎖就不再有效,要膨脹爲重量級鎖,鎖標誌的狀態值變爲10。

輕量級鎖提升程序同步性能的依據是“對於絕大部分的鎖,在整個同步週期內都是不存在競爭的”,這是一個經驗數據。如果沒有競爭,輕量級鎖使用CAS操作避免了使用互斥量的開銷,但如果存在鎖競爭,除了互斥量的開銷外,還額外發生了CAS操作,因此在有競爭的情況下,輕量級鎖會比傳統的重量級鎖更慢。

三、自旋鎖和自適應自旋

輕量級鎖失敗後,虛擬機爲了避免線程真實地在操作系統層面掛起,還會進行一項稱爲自旋鎖的優化手段。

互斥同步對性能最大的影響是阻塞的實現,掛起線程和恢復線程的操作都需要轉入內核態中完成,這些操作給系統的併發性能帶來了很大的壓力(用戶態轉換到內核態會消耗時間)。

一般線程持有鎖的時間不會太長,所以僅僅爲了這一點點時間去掛起/恢復線程是很得不償失的,所以虛擬機開發團隊就考慮:不讓線程掛起,而是讓他等待一段時間,看看持有鎖的線程會不很快地釋放鎖。爲了讓一個等待鎖的線程不被掛起,我們只需要讓線程執行一個忙循環(自旋),這項技術就叫自旋。

自旋鎖在JDK1.6以後,就被默認爲開啓了,但是要注意:自旋鎖不能完全替代阻塞,因爲自旋也會佔用處理器時間,如果鎖被佔用的時間很短,那麼效果自然就是最好的,反之!自旋等待的時間必須要有限度,如果自旋超過了限定的次數而依然沒有獲得鎖,那就應該掛起線程。線程自旋的次數默認爲10次,用戶可以通過--XX:PreBlockSpin來更改。

另外從JDK1.6開始,引入了自適應的自旋鎖。自適應的自旋鎖帶來的改進就是:自旋的時間不再固定了,而是由前一次同一個鎖上的自旋時間以及鎖的擁有者的狀態來決定。比如說在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,並且持有鎖的線程正在運行中,那麼虛擬機就會認爲這次自旋也很有可能再次成功,進而它將允許自旋等待持續相對更長的時間,比如100個循環;相反,如果對於某個鎖,自旋很少成功獲得過,那麼以後要獲取這個鎖時將可能省略掉自旋過程,以避免浪費處理器資源。

四、鎖消除

虛擬機即使編譯器在運行時,如果檢測到那些共享數據不可能存在競爭,那麼就執行鎖消除。鎖消除可以節省那些毫無意義的請求鎖的時間。

五、鎖粗化

原則上,我們在編寫代碼時,總是推薦將同步塊的作用範圍儘量縮小,能鎖對象就不鎖類;能鎖方法就不鎖對象;能鎖代碼塊就不鎖方法。爭取只在共享數據的實際作用域才進行同步,這樣是爲了使得需要同步的操作數量儘量變的最小,如果存在鎖競爭,那等待線程也能儘快拿到鎖。

大部分情況下,上面的原則都是正確的,但是如果一系列的連續操作都對同一個對象反覆加鎖和解鎖,甚至加鎖操作出現在循環體中,那即使沒有線程競爭,頻繁地進行互斥同步操作也會導致不必要的性能損耗。

public String concatString(String s1, String s2, String s3){
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}
/**
 *每個StringBuffer.append()方法中都有一個同步塊,鎖就是sb對象
 *虛擬機會觀察對象sb,發現它的所有引用永遠不會“逃逸”到concatString()方法之外,
 *其他外部線程都無法訪問到它,因此雖然append()方法中有鎖,但是虛擬機會安全的消除它或者進行鎖粗化
**/

上面示例代碼就屬於這類情況。如果虛擬機探測到有這樣一串零碎的操作都對同一個對象加鎖,將會把加鎖同步範圍擴展(粗化)到整個操作序列的外部,上面的代碼中就是擴展到第一個append()操作之前直至最後一個append()操作之後,這樣就只需加鎖一次就行了。

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