值得儲存的 synchronized 關鍵字總結

wjaning發表於2021-09-09

該文已加入開源文件:JavaGuide(一份涵蓋大部分Java程式設計師所需要掌握的核心知識)。地址:github.com/Snailclimb/….

本文是對 synchronized 關鍵字使用、底層原理、JDK1.6之後的底層優化以及和ReenTrantLock對比做的總結。如果沒有學過 synchronized 關鍵字使用的話,閱讀起來可能比較費力。兩篇比較基礎的講解 synchronized 關鍵字的文章:

synchronized 關鍵字的總結

synchronized關鍵字最主要的三種使用方式的總結

  • 修飾例項方法,作用於當前物件例項加鎖,進入同步程式碼前要獲得當前物件例項的鎖
  • 修飾靜態方法,作用於當前類物件加鎖,進入同步程式碼前要獲得當前類物件的鎖 。也就是給當前類加鎖,會作用於類的所有物件例項,因為靜態成員不屬於任何一個例項物件,是類成員( static 表明這是該類的一個靜態資源,不管new了多少個物件,只有一份,所以對該類的所有物件都加了鎖)。所以如果一個執行緒A呼叫一個例項物件的非靜態synchronized 方法,而執行緒B需要呼叫這個例項物件所屬類的靜態 synchronized 方法,是允許的,不會發生互斥現象,因為訪問靜態 synchronized 方法佔用的鎖是當前類的鎖,而訪問非靜態 synchronized 方法佔用的鎖是當前例項物件鎖
  • 修飾程式碼塊,指定加鎖物件,對給定物件加鎖,進入同步程式碼庫前要獲得給定物件的鎖。 和 synchronized 方法一樣,synchronized(this)程式碼塊也是鎖定當前物件的。synchronized 關鍵字加到 static 靜態方法和 synchronized(class)程式碼塊上都是是給 Class 類上鎖。這裡再提一下:synchronized關鍵字加到非 static 靜態方法上是給物件例項上鎖。另外需要注意的是:儘量不要使用 synchronized(String a) 因為JVM中,字串常量池具有緩衝功能!

synchronized 關鍵字底層實現原理總結

  • synchronized 同步語句塊的實現使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步程式碼塊的開始位置,monitorexit 指令則指明同步程式碼塊的結束位置。 當執行 monitorenter 指令時,執行緒試圖獲取鎖也就是獲取 monitor(monitor物件存在於每個Java物件的物件頭中,synchronized 鎖便是通過這種方式獲取鎖的,也是為什麼Java中任意物件可以作為鎖的原因) 的持有權.當計數器為0則可以成功獲取,獲取後將鎖計數器設為1也就是加1。相應的在執行 monitorexit 指令後,將鎖計數器設為0,表明鎖被釋放。如果獲取物件鎖失敗,那當前執行緒就要阻塞等待,直到鎖被另外一個執行緒釋放為止。

  • synchronized 修飾的方法並沒有 monitorenter 指令和 monitorexit 指令,取得代之的確實是 ACC_SYNCHRONIZED 標識,該標識指明瞭該方法是一個同步方法,JVM 通過該 ACC_SYNCHRONIZED 訪問標誌來辨別一個方法是否宣告為同步方法,從而執行相應的同步呼叫。 在 Java 早期版本中,synchronized 屬於重量級鎖,效率低下,因為監視器鎖(monitor)是依賴於底層的作業系統的Mutex Lock 來實現的,Java 的執行緒是對映到作業系統的原生執行緒之上的。如果要掛起或者喚醒一個執行緒,都需要作業系統幫忙完成,而作業系統實現執行緒之間的切換時需要從使用者態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,這也是為什麼早期的 synchronized 效率低的原因。慶幸的是在 Java 6 之後 Java 官方對從 JVM 層面對synchronized 較大優化,所以現在的 synchronized 鎖效率也優化得很不錯了。JDK1.6對鎖的實現引入了大量的優化,如自旋鎖、適應性自旋鎖、鎖消除、鎖粗化、偏向鎖、輕量級鎖等技術來減少鎖操作的開銷。

    所有使用者程式都是執行在使用者態的, 但是有時候程式確實需要做一些核心態的事情, 例如從硬碟讀取資料, 或者從鍵盤獲取輸入等. 而唯一可以做這些事情的就是作業系統,

synchronized關鍵字底層優化總結

JDK1.6 對鎖的實現引入了大量的優化,如自旋鎖、適應性自旋鎖、鎖消除、鎖粗化、偏向鎖、輕量級鎖等技術來減少鎖操作的開銷。

鎖主要存在四中狀態,依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態、重量級鎖狀態,他們會隨著競爭的激烈而逐漸升級。注意鎖可以升級不可降級,這種策略是為了提高獲得鎖和釋放鎖的效率。

偏向鎖

引入偏向鎖的目的和引入輕量級鎖的目的很像,他們都是為了沒有多執行緒競爭的前提下,減少傳統的重量級鎖使用作業系統互斥量產生的效能消耗。但是不同是:輕量級鎖在無競爭的情況下使用 CAS 操作去代替使用互斥量。而偏向鎖在無競爭的情況下會把整個同步都消除掉

偏向鎖的“偏”就是偏心的偏,它的意思是會偏向於第一個獲得它的執行緒,如果在接下來的執行中,該鎖沒有被其他執行緒獲取,那麼持有偏向鎖的執行緒就不需要進行同步!關於偏向鎖的原理可以檢視《深入理解Java虛擬機器:JVM高階特性與最佳實踐》第二版的13章第三節鎖優化。

但是對於鎖競爭比較激烈的場合,偏向鎖就失效了,因為這樣場合極有可能每次申請鎖的執行緒都是不相同的,因此這種場合下不應該使用偏向鎖,否則會得不償失,需要注意的是,偏向鎖失敗後,並不會立即膨脹為重量級鎖,而是先升級為輕量級鎖。

輕量級鎖

倘若偏向鎖失敗,虛擬機器並不會立即升級為重量級鎖,它還會嘗試使用一種稱為輕量級鎖的優化手段(1.6之後加入的)。輕量級鎖不是為了代替重量級鎖,它的本意是在沒有多執行緒競爭的前提下,減少傳統的重量級鎖使用作業系統互斥量產生的效能消耗,因為使用輕量級鎖時,不需要申請互斥量。另外,輕量級鎖的加鎖和解鎖都用到了CAS操作。 關於輕量級鎖的加鎖和解鎖的原理可以檢視《深入理解Java虛擬機器:JVM高階特性與最佳實踐》第二版的13章第三節鎖優化。

輕量級鎖能夠提升程式同步效能的依據是“對於絕大部分鎖,在整個同步週期內都是不存在競爭的”,這是一個經驗資料。如果沒有競爭,輕量級鎖使用 CAS 操作避免了使用互斥操作的開銷。但如果存在鎖競爭,除了互斥量開銷外,還會額外發生CAS操作,因此在有鎖競爭的情況下,輕量級鎖比傳統的重量級鎖更慢!如果鎖競爭激烈,那麼輕量級將很快膨脹為重量級鎖!

自旋鎖和自適應自旋

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

互斥同步對效能最大的影響就是阻塞的實現,因為掛起執行緒/恢復執行緒的操作都需要轉入核心態中完成(使用者態轉換到核心態會耗費時間)。

一般執行緒持有鎖的時間都不是太長,所以僅僅為了這一點時間去掛起執行緒/恢復執行緒是得不償失的。 所以,虛擬機器的開發團隊就這樣去考慮:“我們能不能讓後面來的請求獲取鎖的執行緒等待一會而不被掛起呢?看看持有鎖的執行緒是否很快就會釋放鎖”。為了讓一個執行緒等待,我們只需要讓執行緒執行一個忙迴圈(自旋),這項技術就叫做自旋

百度百科對自旋鎖的解釋:

何謂自旋鎖?它是為實現保護共享資源而提出一種鎖機制。其實,自旋鎖與互斥鎖比較類似,它們都是為了解決對某項資源的互斥使用。無論是互斥鎖,還是自旋鎖,在任何時刻,最多隻能有一個保持者,也就說,在任何時刻最多隻能有一個執行單元獲得鎖。但是兩者在排程機制上略有不同。對於互斥鎖,如果資源已經被佔用,資源申請者只能進入睡眠狀態。但是自旋鎖不會引起呼叫者睡眠,如果自旋鎖已經被別的執行單元保持,呼叫者就一直迴圈在那裡看是否該自旋鎖的保持者已經釋放了鎖,"自旋"一詞就是因此而得名。

自旋鎖在 JDK1.6 之前其實就已經引入了,不過是預設關閉的,需要通過--XX:+UseSpinning引數來開啟。JDK1.6及1.6之後,就改為預設開啟的了。需要注意的是:自旋等待不能完全替代阻塞,因為它還是要佔用處理器時間。如果鎖被佔用的時間短,那麼效果當然就很好了!反之,相反!自旋等待的時間必須要有限度。如果自旋超過了限定次數任然沒有獲得鎖,就應該掛起執行緒。自旋次數的預設值是10次,使用者可以修改--XX:PreBlockSpin來更改

另外,在 JDK1.6 中引入了自適應的自旋鎖。自適應的自旋鎖帶來的改進就是:自旋的時間不在固定了,而是和前一次同一個鎖上的自旋時間以及鎖的擁有者的狀態來決定,虛擬機器變得越來越“聰明”了

鎖消除

鎖消除理解起來很簡單,它指的就是虛擬機器即使編譯器在執行時,如果檢測到那些共享資料不可能存在競爭,那麼就執行鎖消除。鎖消除可以節省毫無意義的請求鎖的時間。

鎖粗化

原則上,我們再編寫程式碼的時候,總是推薦將同步快的作用範圍限制得儘量小——只在共享資料的實際作用域才進行同步,這樣是為了使得需要同步的運算元量儘可能變小,如果存在鎖競爭,那等待執行緒也能儘快拿到鎖。

大部分情況下,上面的原則都是沒有問題的,但是如果一系列的連續操作都對同一個物件反覆加鎖和解鎖,那麼會帶來很多不必要的效能消耗。

ReenTrantLock 和 synchronized 關鍵字的總結

推薦一篇講解 ReenTrantLock 的使用比較基礎的文章:《Java多執行緒學習(六)Lock鎖的使用》

兩者都是可重入鎖

兩者都是可重入鎖。“可重入鎖”概念是:自己可以再次獲取自己的內部鎖。比如一個執行緒獲得了某個物件的鎖,此時這個物件鎖還沒有釋放,當其再次想要獲取這個物件的鎖的時候還是可以獲取的,如果不可鎖重入的話,就會造成死鎖。同一個執行緒每次獲取鎖,鎖的計數器都自增1,所以要等到鎖的計數器下降為0時才能釋放鎖。

synchronized 依賴於 JVM 而 ReenTrantLock 依賴於 API

synchronized 是依賴於 JVM 實現的,前面我們也講到了 虛擬機器團隊在 JDK1.6 為 synchronized 關鍵字進行了很多優化,但是這些優化都是在虛擬機器層面實現的,並沒有直接暴露給我們。ReenTrantLock 是 JDK 層面實現的(也就是 API 層面,需要 lock() 和 unlock 方法配合 try/finally 語句塊來完成),所以我們可以通過檢視它的原始碼,來看它是如何實現的。

ReenTrantLock 比 synchronized 增加了一些高階功能

相比synchronized,ReenTrantLock增加了一些高階功能。主要來說主要有三點:①等待可中斷;②可實現公平鎖;③可實現選擇性通知(鎖可以繫結多個條件)

  • ReenTrantLock提供了一種能夠中斷等待鎖的執行緒的機制,通過lock.lockInterruptibly()來實現這個機制。也就是說正在等待的執行緒可以選擇放棄等待,改為處理其他事情。
  • ReenTrantLock可以指定是公平鎖還是非公平鎖。而synchronized只能是非公平鎖。所謂的公平鎖就是先等待的執行緒先獲得鎖。 ReenTrantLock預設情況是非公平的,可以通過 ReenTrantLock類的ReentrantLock(boolean fair)構造方法來制定是否是公平的。
  • synchronized關鍵字與wait()和notify/notifyAll()方法相結合可以實現等待/通知機制,ReentrantLock類當然也可以實現,但是需要藉助於Condition介面與newCondition() 方法。Condition是JDK1.5之後才有的,它具有很好的靈活性,比如可以實現多路通知功能也就是在一個Lock物件中可以建立多個Condition例項(即物件監視器),執行緒物件可以註冊在指定的Condition中,從而可以有選擇性的進行執行緒通知,在排程執行緒上更加靈活。 在使用notify/notifyAll()方法進行通知時,被通知的執行緒是由 JVM 選擇的,用ReentrantLock類結合Condition例項可以實現“選擇性通知” ,這個功能非常重要,而且是Condition介面預設提供的。而synchronized關鍵字就相當於整個Lock物件中只有一個Condition例項,所有的執行緒都註冊在它一個身上。如果執行notifyAll()方法的話就會通知所有處於等待狀態的執行緒這樣會造成很大的效率問題,而Condition例項的signalAll()方法 只會喚醒註冊在該Condition例項中的所有等待執行緒。

如果你想使用上述功能,那麼選擇ReenTrantLock是一個不錯的選擇。

效能已不是選擇標準

在JDK1.6之前,synchronized 的效能是比 ReenTrantLock 差很多。具體表示為:synchronized 關鍵字吞吐量歲執行緒數的增加,下降得非常嚴重。而ReenTrantLock 基本保持一個比較穩定的水平。我覺得這也側面反映了, synchronized 關鍵字還有非常大的優化餘地。後續的技術發展也證明了這一點,我們上面也講了在 JDK1.6 之後 JVM 團隊對 synchronized 關鍵字做了很多優化。JDK1.6 之後,synchronized 和 ReenTrantLock 的效能基本是持平了。所以網上那些說因為效能才選擇 ReenTrantLock 的文章都是錯的!JDK1.6之後,效能已經不是選擇synchronized和ReenTrantLock的影響因素了!而且虛擬機器在未來的效能改進中會更偏向於原生的synchronized,所以還是提倡在synchronized能滿足你的需求的情況下,優先考慮使用synchronized關鍵字來進行同步!優化後的synchronized和ReenTrantLock一樣,在很多地方都是用到了CAS操作

參考

你若盛開,清風自來。 歡迎關注我的微信公眾號:“Java面試通關手冊”,一個有溫度的微信公眾號。公眾號後臺回覆關鍵字“1”,你可能看到想要的東西哦!

值得儲存的 synchronized 關鍵字總結

相關文章