1. 程式人生 > 實用技巧 >05-多執行緒筆記-2-鎖-2-Synchronized

05-多執行緒筆記-2-鎖-2-Synchronized

1 使用方式

synchronized可以修飾靜態方法、成員函式,同時還可以直接定義程式碼塊,但是歸根結底它上鎖的資源只有兩類:一個是物件,一個是

  • 修飾一個程式碼塊,被修飾的程式碼塊稱為同步語句塊,其作用的範圍是大括號{}括起來的程式碼,作用的物件是呼叫這個程式碼塊的物件;
  • 修飾一個方法,被修飾的方法稱為同步方法,其作用的範圍是整個方法,作用的物件是呼叫這個方法的物件;
  • 修改一個靜態的方法,其作用的範圍是整個靜態方法,作用的物件是這個類的所有物件;
  • 修改一個類,其作用的範圍是synchronized後面括號括起來的部分,作用主的物件是這個類的所有物件。

2 特性

2.1 原子性

所謂原子性就是指一個操作或者多個操作,要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。
在Java中,對基本資料型別的變數的讀取和賦值操作是原子性操作,即這些操作是不可被中斷的,要麼執行,要麼不執行。但是像i++、i+=1等操作字元就不是原子性的,它們是分成讀取、計算、賦值幾步操作,原值在這些步驟還沒完成時就可能已經被賦值了,那麼最後賦值寫入的資料就是髒資料,無法保證原子性。
synchronized和volatile最大的區別就在於原子性,volatile不具備原子性

2.2 可見性

可見性是指多個執行緒訪問一個資源時,該資源的狀態、值資訊等對於其他執行緒都是可見的。
synchronized和volatile都具有可見性,其中synchronized對一個類或物件加鎖時,一個執行緒如果要訪問該類或物件必須先獲得它的鎖,而這個鎖的狀態對於其他任何執行緒都是可見的,並且在釋放鎖之前會將對變數的修改重新整理到主存當中,保證資源變數的可見性,如果某個執行緒佔用了該鎖,其他執行緒就必須在鎖池中等待鎖的釋放。
而volatile的實現類似,被volatile修飾的變數,每當值需要修改時都會立即更新主存,主存是共享的,所有執行緒可見,所以確保了其他執行緒讀取到的變數永遠是最新值,保證可見性。

2.3 有序性

有序性值程式執行的順序按照程式碼先後執行
synchronized和volatile都具有有序性,Java允許編譯器和處理器對指令進行重排,但是指令重排並不會影響單執行緒的順序,它影響的是多執行緒併發執行的順序性。synchronized保證了每個時刻都只有一個執行緒訪問同步程式碼塊,也就確定了執行緒執行同步程式碼塊是分先後順序的,保證了有序性

2.4 可重入性

synchronized和ReentrantLock都是可重入鎖。當一個執行緒試圖操作一個由其他執行緒持有的物件鎖的臨界資源時,將會處於阻塞狀態,但當一個執行緒再次請求自己持有物件鎖的臨界資源時,這種情況屬於重入鎖。通俗一點講就是說一個執行緒擁有了鎖仍然還可以重複申請鎖。

3 synchronized鎖的實現

synchronized有兩種形式上鎖,一個是對方法上鎖,一個是構造同步程式碼塊。他們的底層實現其實都一樣,在進入同步程式碼之前先獲取鎖,獲取到鎖之後鎖的計數器+1,同步程式碼執行完鎖的計數器-1,如果獲取失敗就阻塞式等待鎖的釋放。只是他們在同步塊識別方式上有所不一樣,從class位元組碼檔案可以表現出來,一個是通過方法flags標誌,一個是monitorenter和monitorexit指令操作。

3.1 加鎖方式

  • 方法

    方法加鎖,會在方法的flags裡面多了一個ACC_SYNCHRONIZED標誌,這標誌用來告訴JVM這是一個同步方法,在進入該方法之前先獲取相應的鎖,鎖的計數器加1,方法結束後計數器-1,如果獲取失敗就阻塞住,直到該鎖被釋放。

    • 靜態方法加鎖,鎖物件是類的Class物件,作用於類的所有例項;
    • 例項方法加鎖,鎖物件是例項物件,作用範圍為例項物件
  • 程式碼塊

    同步程式碼塊編譯成位元組碼檔案後,會在臨界區前後加上monitorentermonitorexit指令,在執行monitorenter之前需要嘗試獲取鎖,如果這個物件沒有被鎖定,或者當前執行緒已經擁有了這個物件的鎖,那麼就把鎖的計數器加1。當執行monitorexit指令時,鎖的計數器也會減1。

    • 靜態程式碼塊不指定鎖物件,作用範圍與靜態方法相同
    • 例項程式碼塊鎖物件一般都手動指定,作用範圍與例項方法加鎖相同

3.2 底層實現

  • JVM中物件資料結構

    在JVM中,物件是分成三部分存在的:物件頭、例項資料、對齊填充。

    • 物件頭

      • Mark Word

      第一部分Mark Word,用於儲存物件自身的執行時資料,如雜湊碼(HashCode)、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等,這部分資料的長度在32位和64位的虛擬機器(未開啟壓縮指標)中分別為32bit和64bit,官方稱它為“Mark Word”。

      • 型別指標

      物件頭的另外一部分是型別指標,即物件指向它的類元資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項.

      • 陣列長度(只有陣列物件有)

      如果物件是一個數組, 那在物件頭中還必須有一塊資料用於記錄陣列長度.

    • 物件實際資料

      物件真正儲存的有效資訊就是放在這裡的,也是在程式程式碼中所定義的各種型別的欄位內容。無論是從父類繼承下來的,還是在子類中定義的,都需要記錄起來。

    • 對齊填充

      對齊填充並不是必然存在的,也沒有特別的含義,它僅僅起著佔位符的作用。由於HotSpot VM的自動記憶體管理系統要求物件起始地址必須是8位元組的整數倍,換句話說,就是物件的大小必須是8位元組的整數倍。而物件頭部分正好是8位元組的倍數(1倍或者2倍),因此,當物件例項資料部分沒有對齊時,就需要通過對齊填充來補全。

  • Monitor

    每一個java物件都可以關聯一個Monitor物件(由C++實現),如果使用Synchronized給物件加鎖(重量級)之後,該物件的markword中就設定了指向Monitor的指標。其中monitor可以與物件一起建立、銷燬;亦或者當執行緒試圖獲取物件鎖時自動生成。而monitor是新增Synchronized關鍵字之後獨有的。synchronized同步塊使用了monitorenter和monitorexit指令實現同步,這兩個指令,本質上都是對物件的監視器(monitor)進行獲取,這個過程是排他的,也就是說同一時刻只能有一個執行緒獲取到由synchronized所保護物件的監視器。 執行緒執行到monitorenter指令時,會嘗試獲取物件所對應的monitor所有權,也就是嘗試獲取物件的鎖,而執行monitorexit,就是釋放monitor的所有權。
    當多個執行緒同時訪問一段同步程式碼時,這些執行緒會被放到一個Monitor的EntrySet集合中,處於阻塞狀態的執行緒都會被放到該列表當中。接下來,當執行緒獲取到物件的Monitor時,Monitor是依賴於底層作業系統的mutex lock來實現互斥的,執行緒獲取mutex成功,則會持有該mutex,這時其它執行緒就無法再獲取到該mutex.
    如果執行緒呼叫了wait()方法,那麼該執行緒就會釋放掉所持有的mutex,並且該執行緒會進入到Monitor的WaitSet集合(等待集合)中,等待下一次被其他執行緒呼叫notify/notifyAll喚醒。如果當前執行緒順利執行完畢方法,那麼它也會釋放掉所持有的mutex.
    總結一下:同步鎖在這種實現方式當中,因為Monitor是依賴於底層的作業系統實現,這樣就存在使用者態和核心態之間的切換,所以會增加效能開銷。

    • ObjectMonitor

      Java虛擬機器中,monitor是由ObjectMonitor實現的.ObjectMonitor主要有幾個需要關注的成員變數:

      • Owner:初始時為NULL表示當前沒有任何執行緒擁有該monitor record,當執行緒成功擁有該鎖後儲存執行緒唯一標識,當鎖被釋放時又設定為NULL。
      • EntryQ:關聯一個系統互斥鎖(semaphore),阻塞所有試圖鎖住monitor record失敗的執行緒。
      • RcThis:表示blocked或waiting在該monitor record上的所有執行緒的個數。
      • Nest:用來實現重入鎖的計數。HashCode:儲存從物件頭拷貝過來的HashCode值(可能還包含GC age)。
      • Candidate:用來避免不必要的阻塞或等待執行緒喚醒,因為每一次只有一個執行緒能夠成功擁有鎖,如果每次前一個釋放鎖的執行緒喚醒所有正在阻塞或等待的執行緒,會引起不必要的上下文切換(從阻塞到就緒然後因為競爭鎖失敗又被阻塞)從而導致效能嚴重下降。
        Candidate只有兩種可能的值0表示沒有需要喚醒的執行緒1表示要喚醒一個繼任執行緒來競爭鎖。

      多個執行緒同時訪問一段同步程式碼時,首先會進入_EntryList集合,進行阻塞等待, 當執行緒獲取到物件的monitor後進入owner區域,並把monitor中的_owner變數指向該執行緒,同時monitor中的計數器count自加一,若執行緒呼叫同步物件的wait()方法將釋放當前持有的monitor,_owner變數重置為null,count自減一,同時該執行緒進入_WaitSet中等待喚醒,執行緒執行完同步程式碼塊後,也將_Owner和count變數重置.

3.3. Synchronized優化

Synchronized 依賴Monitor加鎖,在持有Monitor和釋放Monitor時,都依賴作業系統進行,需要從使用者態切換到核心態,這種切換浪費效能;

在JDK1.6以前,使用synchronized就只有一種方式即重量級鎖(依賴Monitor),而在JDK1.6以後,引入了偏向鎖,輕量級鎖,重量級鎖,來減少競爭帶來的上下文切換。

  • (1)鎖升級

    Synchronized 同步鎖初始為偏向鎖,隨著執行緒競爭越來越激烈,偏向鎖升級到輕量級鎖,最終升級到重量級鎖
    ** 無鎖——>偏向鎖——>輕量級鎖——>重量級鎖**

    • 偏向鎖

      偏向鎖是Java 6之後加入的新鎖,它是一種針對加鎖操作的優化手段。經過研究發現,在大多數情況下,鎖不僅不存在多執行緒競爭,而且總是由同一執行緒多次獲得,因此為了減少同一執行緒獲取鎖(會涉及到一些CAS操作,耗時)的代價而引入偏向鎖。
      偏向鎖的核心思想是,如果一個執行緒獲得了無鎖狀態的鎖時,那麼鎖就進入偏向模式,此時Mark Word 的結構也變為偏向鎖結構,當這個執行緒再次請求鎖時,無需再做任何同步操作,即獲取鎖的過程,這樣就省去了大量有關鎖申請的操作,從而也就提供程式的效能。

      當執行緒1訪問程式碼塊並獲取鎖物件時,會在java物件頭和棧幀中記錄偏向的鎖的threadID,因為偏向鎖不會主動釋放鎖,因此以後執行緒1再次獲取鎖的時候,需要比較當前執行緒的threadID和Java物件頭中的threadID是否一致,如果一致(還是執行緒1獲取鎖物件),則無需使用CAS來加鎖、解鎖;如果不一致(其他執行緒,如執行緒2要競爭鎖物件,而偏向鎖不會主動釋放因此還是儲存的執行緒1的threadID),那麼需要檢視Java物件頭中記錄的執行緒1是否存活,如果沒有存活,那麼鎖物件被重置為無鎖狀態,其它執行緒(執行緒2)可以競爭將其設定為偏向鎖;如果存活,那麼立刻查詢該執行緒(執行緒1)的棧幀資訊,如果還是需要繼續持有這個鎖物件,那麼暫停當前執行緒1,撤銷偏向鎖,升級為輕量級鎖,如果執行緒1 不再使用該鎖物件,那麼將鎖物件狀態設為無鎖狀態,重新偏向新的執行緒。

    • 輕量級鎖

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

      執行緒1獲取輕量級鎖時會先把鎖物件的物件頭MarkWord複製一份到執行緒1的棧幀中建立的用於儲存鎖記錄的空間(稱為DisplacedMarkWord),然後使用CAS把物件頭中的內容替換為執行緒1儲存的鎖記錄(DisplacedMarkWord)的地址;
      如果線上程1複製物件頭的同時(線上程1CAS之前),執行緒2也準備獲取鎖,複製了物件頭到執行緒2的鎖記錄空間中,但是線上程2CAS的時候,發現執行緒1已經把物件頭換了,執行緒2的CAS失敗,那麼執行緒2就嘗試使用自旋鎖來等待執行緒1釋放鎖。
      但是如果自旋的時間太長也不行,因為自旋是要消耗CPU的,因此自旋的次數是有限制的,比如10次或者100次,如果自旋次數到了執行緒1還沒有釋放鎖,或者執行緒1還在執行,執行緒2還在自旋等待,這時又有一個執行緒3過來競爭這個鎖物件,那麼這個時候輕量級鎖就會膨脹為重量級鎖。重量級鎖把除了擁有鎖的執行緒都阻塞,防止CPU空轉。

      • 自旋鎖與自適應自旋

        Java的執行緒是對映到作業系統的原生執行緒之上的,如果要阻塞或喚醒一個執行緒,都需要作業系統來幫忙完成,這就需要從使用者態轉換到核心態中,因此狀態轉換需要耗費很多的處理器時間,對於程式碼簡單的同步塊(如被synchronized修飾的getter()和setter()方法),狀態轉換消耗的時間有可能比使用者程式碼執行的時間還要長。
        虛擬機器的開發團隊注意到在許多應用上,共享資料的鎖定狀態只會持續很短的一段時間,為了這段時間去掛起和恢復執行緒並不值得。如果物理機器有一個以上的處理器,能讓兩個或以上的執行緒同時並行執行,我們就可以讓後面請求鎖的那個執行緒“稍等一下“,但不放棄處理器的執行時間,看看持有鎖的執行緒是否很快就會釋放鎖。為了讓執行緒等待,我們只需讓執行緒執行一個忙迴圈(自旋),這項技術就是所謂的自旋鎖。
        自旋鎖在JDK1.4.2中引入,使用-XX:+UseSpinning來開啟。JDK1.6中已經變為預設開。自旋等待不能代替阻塞。自旋等待本身雖然避免了執行緒切換的開銷,但它是要佔用處理器時間的,因此,如果鎖被佔用的時間很短,自旋等待的效果就會非常好,反之,如果鎖被佔用的時間很長,那麼自旋的執行緒只會浪費處理器資源。因此,自旋等待的時間必須要有一定的限度,如果自旋超過了限定次數(預設是10次,可以使用-XX:PreBlockSpin來更改)沒有成功獲得鎖,就應當使用傳統的方式去掛起執行緒了。
        JDK1.6中引入自適應的自旋鎖,自適應意味著自旋的時間不在固定。而是有虛擬機器對程式鎖的監控與預測來設定自旋的次數。

    • 重量級鎖

      重量級鎖也就是通常說synchronized的物件鎖,鎖標識位為10,其中指標指向的是monitor物件(也稱為管程或監視器鎖)的起始地址。每個物件都存在著一個 monitor 與之關聯,物件與其 monitor 之間的關係有存在多種實現方式,如monitor可以與物件一起建立銷燬或當執行緒試圖獲取物件鎖時自動生成,但當一個 monitor 被某個執行緒持有後,它便處於鎖定狀態。

    • 總結

      偏向所鎖,輕量級鎖都是樂觀鎖,重量級鎖是悲觀鎖。
      一個物件剛開始例項化的時候,沒有任何執行緒來訪問它的時候。它是可偏向的,意味著,它現在認為只可能有一個執行緒來訪問它,所以當第一個執行緒來訪問它的時候,它會偏向這個執行緒,此時,物件持有偏向鎖。偏向第一個執行緒,這個執行緒在修改物件頭成為偏向鎖的時候使用CAS操作,並將物件頭中的ThreadID改成自己的ID,之後再次訪問這個物件時,只需要對比ID,不需要再使用CAS在進行操作。
      一旦有第二個執行緒訪問這個物件,因為偏向鎖不會主動釋放,所以第二個執行緒可以看到物件時偏向狀態,這時表明在這個物件上已經存在競爭了,檢查原來持有該物件鎖的執行緒是否依然存活,如果掛了,則可以將物件變為無鎖狀態,然後重新偏向新的執行緒,如果原來的執行緒依然存活,則馬上執行那個執行緒的操作棧,檢查該物件的使用情況,如果仍然需要持有偏向鎖,則偏向鎖升級為輕量級鎖,(偏向鎖就是這個時候升級為輕量級鎖的)。如果不存在使用了,則可以將物件回覆成無鎖狀態,然後重新偏向。
      輕量級鎖認為競爭存在,但是競爭的程度很輕,一般兩個執行緒對於同一個鎖的操作都會錯開,或者說稍微等待一下(自旋),另一個執行緒就會釋放鎖。 但是當自旋超過一定的次數,或者一個執行緒在持有鎖,一個在自旋,又有第三個來訪時,輕量級鎖膨脹為重量級鎖,重量級鎖使除了擁有鎖的執行緒以外的執行緒都阻塞,防止CPU空轉。
      https://www.cnblogs.com/deltadeblog/p/9559035.html

  • (2)鎖消除

    JIT編譯器(Just In Time編譯器)可以動態編譯同步程式碼時,適用於一種叫做逃逸分析的技術,來通過該項技術判別程式中所使用的鎖物件是否只被一個執行緒所使用,而沒有散佈到其他執行緒中;如果情況就是這樣的話,那麼JIT編譯器在編譯這個同步程式碼時就不會生成synchronized關鍵字所標識的鎖的申請與釋放機器碼,從而消除了鎖的使用流程。

  • (3)鎖粗化

    JIt編譯器在執行動態編譯時,若發現前後相鄰的synchronized塊使用的是同一個鎖物件,那麼它就會把這幾個synchronized塊合併為一個較大的同步塊,這樣做的好處在於執行緒在執行這些程式碼時,就不用頻繁的申請和釋放鎖了,從而達到申請與釋放鎖一次,就可以執行完全部的同步程式碼塊,從而提升了效能.

    如果虛擬機器探測到有這樣一串零碎的操作都對同一個物件加鎖,將會把加鎖同步的範圍擴充套件 (粗化)到整個操作序列的外部

參考文章