本文是《深入理解多執行緒》的第五篇文章,前面幾篇文章中我們從synchronized的實現原理開始,一直介紹到了Monitor的實現原理。
前情提要
通過前面幾篇文章,我們已經知道:
1、同步方法通過ACC_SYNCHRONIZED
關鍵字隱式的對方法進行加鎖。當執行緒要執行的方法被標註上ACC_SYNCHRONIZED
時,需要先獲得鎖才能執行該方法。《深入理解多執行緒(一)——Synchronized的實現原理》
2、同步程式碼塊通過monitorenter
和monitorexit
執行來進行加鎖。當執行緒執行到monitorenter
的時候要先獲得所鎖,才能執行後面的方法。當執行緒執行到monitorexit
的時候則要釋放鎖。《深入理解多執行緒(四)—— Moniter的實現原理》
3、在HotSpot虛擬機器中,使用oop-klass模型來表示物件。每一個Java類,在被JVM載入的時候,JVM會給這個類建立一個instanceKlass
,儲存在方法區,用來在JVM層表示該Java類。當我們在Java程式碼中,使用new建立一個物件的時候,JVM會建立一個instanceOopDesc
物件,這個物件中包含了物件頭以及例項資料。《深入理解多執行緒(二)—— Java的物件模型》
4、物件頭中主要包含了GC分代年齡、鎖狀態標記、雜湊碼、epoch等資訊。物件的狀態一共有五種,分別是無鎖態、輕量級鎖、重量級鎖、GC標記和偏向鎖。《深入理解多執行緒(三)—— Java的物件頭》
在上一篇文章的最後,我們說過,事實上,只有在JDK1.6之前,synchronized
的實現才會直接呼叫ObjectMonitor
的enter
和exit
,這種鎖被稱之為重量級鎖。
高效併發是從JDK 1.5 到 JDK 1.6的一個重要改進,HotSpot虛擬機器開發團隊在這個版本中花費了很大的精力去對Java中的鎖進行優化,如適應性自旋、鎖消除、鎖粗化、輕量級鎖和偏向鎖等。這些技術都是為了線上程之間更高效的共享資料,以及解決競爭問題。
本文,主要先來介紹一下自旋、鎖消除以及鎖粗化等技術。
這裡簡單說明一下,本文要介紹的這幾個概念,以及後面要介紹的輕量級鎖和偏向鎖,其實對於使用他的開發者來說是遮蔽掉了的,也就是說,作為一個Java開發,你只需要知道你想在加鎖的時候使用synchronized就可以了,具體的鎖的優化是虛擬機器根據競爭情況自行決定的。
也就是說,在JDK 1.5 以後,我們即將介紹的這些概念,都被封裝在synchronized中了。
執行緒狀態
要想把鎖說清楚,一個重要的概念不得不提,那就是執行緒和執行緒的狀態。鎖和執行緒的關係是怎樣的呢,舉個簡單的例子你就明白了。
比如,你今天要去銀行辦業務,你到了銀行之後,要先取一個號,然後你坐在休息區等待叫號,過段時間,廣播叫到你的號碼之後,會告訴你去哪個櫃檯辦理業務,這時,你拿著你手裡的號碼,去到對應的櫃檯,找相應的櫃員開始辦理業務。當你辦理業務的時候,這個櫃檯和櫃檯後面的櫃員只能為你自己服務。當你辦完業務離開之後,廣播再喊其他的顧客前來辦理業務。
這個例子中,每個顧客是一個執行緒。 櫃檯前面的那把椅子,就是鎖。 櫃檯後面的櫃員,就是共享資源。 你發現無法直接辦理業務,要取號等待的過程叫做阻塞。 當你聽到叫你的號碼的時候,你起身去辦業務,這就是喚醒。 當你坐在椅子上開始辦理業務的時候,你就獲得鎖。 當你辦完業務離開的時候,你就釋放鎖。
對於執行緒來說,一共有五種狀態,分別為:初始狀態(New) 、就緒狀態(Runnable) 、執行狀態(Running) 、阻塞狀態(Blocked) 和死亡狀態(Dead) 。
自旋鎖
在前一篇文章中,我們介紹的synchronized
的實現方式中使用Monitor
進行加鎖,這是一種互斥鎖,為了表示他對效能的影響我們稱之為重量級鎖。
這種互斥鎖在互斥同步上對效能的影響很大,Java的執行緒是對映到作業系統原生執行緒之上的,如果要阻塞或喚醒一個執行緒就需要作業系統的幫忙,這就要從使用者態轉換到核心態,因此狀態轉換需要花費很多的處理器時間。
就像去銀行辦業務的例子,當你來到銀行,發現櫃檯前面都有人的時候,你需要取一個號,然後再去等待區等待,一直等待被叫號。這個過程是比較浪費時間的,那麼有沒有什麼辦法改進呢?
有一種比較好的設計,那就是銀行提供自動取款機,當你去銀行取款的時候,你不需要取號,不需要去休息區等待叫號,你只需要找到一臺取款機,排在其他人後面等待取款就行了。
之所以能這樣做,是因為取款的這個過程相比較之下是比較節省時間的。如果所有人去銀行都只取款,或者辦理業務的時間都很短的話,那也就可以不需要取號,不需要去單獨的休息區,不需要聽叫號,也不需要再跑到對應的櫃檯了。
而,在程式中,Java虛擬機器的開發工程師們在分析過大量資料後發現:共享資料的鎖定狀態一般只會持續很短的一段時間,為了這段時間去掛起和恢復執行緒其實並不值得。
如果物理機上有多個處理器,可以讓多個執行緒同時執行的話。我們就可以讓後面來的執行緒“稍微等一下”,但是並不放棄處理器的執行時間,看看持有鎖的執行緒會不會很快釋放鎖。這個“稍微等一下”的過程就是自旋。
自旋鎖在JDK 1.4中已經引入,在JDK 1.6中預設開啟。
很多人在對於自旋鎖的概念不清楚的時候可能會有以下疑問:這麼聽上去,自旋鎖好像和阻塞鎖沒啥區別,反正都是等著嘛。
-
對於去銀行取錢的你來說,站在取款機面前等待和去休息區等待叫號有一個很大的區別:
-
那就是如果你在休息區等待,這段時間你什麼都不需要管,隨意做自己的事情,等著被喚醒就行了。
-
如果你在取款機面前等待,那麼你需要時刻關注自己前面還有沒有人,因為沒人會喚醒你。
-
很明顯,這種直接去取款機前面排隊取款的效率是比較高。
-
所以呢,自旋鎖和阻塞鎖最大的區別就是,到底要不要放棄處理器的執行時間。對於阻塞鎖和自旋鎖來說,都是要等待獲得共享資源。但是阻塞鎖是放棄了CPU時間,進入了等待區,等待被喚醒。而自旋鎖是一直“自旋”在那裡,時刻的檢查共享資源是否可以被訪問。
由於自旋鎖只是將當前執行緒不停地執行迴圈體,不進行執行緒狀態的改變,所以響應速度更快。但當執行緒數不停增加時,效能下降明顯,因為每個執行緒都需要執行,佔用CPU時間。如果執行緒競爭不激烈,並且保持鎖的時間段。適合使用自旋鎖。
鎖消除
除了自旋鎖之後,JDK中還有一種鎖的優化被稱之為鎖消除。還拿去銀行取錢的例子說。
你去銀行取錢,所有情況下都需要取號,並且等待嗎?其實是不用的,當銀行辦理業務的人不多的時候,可能根本不需要取號,直接走到櫃檯前面辦理業務就好了。
能這麼做的前提是,沒有人和你搶著辦業務。
上面的這種例子,在鎖優化中被稱作“鎖消除”,是JIT編譯器對內部鎖的具體實現所做的一種優化。
在動態編譯同步塊的時候,JIT編譯器可以藉助一種被稱為逃逸分析(Escape Analysis)的技術來判斷同步塊所使用的鎖物件是否只能夠被一個執行緒訪問而沒有被髮布到其他執行緒。
如果同步塊所使用的鎖物件通過這種分析被證實只能夠被一個執行緒訪問,那麼JIT編譯器在編譯這個同步塊的時候就會取消對這部分程式碼的同步。
如以下程式碼:
public void f() {
Object hollis = new Object();
synchronized(hollis) {
System.out.println(hollis);
}
}
複製程式碼
程式碼中對hollis
這個物件進行加鎖,但是hollis
物件的生命週期只在f()
方法中,並不會被其他執行緒所訪問到,所以在JIT編譯階段就會被優化掉。優化成:
public void f() {
Object hollis = new Object();
System.out.println(hollis);
}
複製程式碼
這裡,可能有讀者會質疑了,程式碼是程式設計師自己寫的,程式設計師難道沒有能力判斷要不要加鎖嗎?就像以上程式碼,完全沒必要加鎖,有經驗的開發者一眼就能看的出來的。其實道理是這樣,但是還是有可能有疏忽,比如我們經常在程式碼中使用
StringBuffer
作為區域性變數,而StringBuffer
中的append
是執行緒安全的,有synchronized
修飾的,這種情況開發者可能會忽略。這時候,JIT就可以幫忙優化,進行鎖消除。
瞭解我的朋友都知道,一般到這個時候,我就會開始反編譯,然後拿出反編譯之後的程式碼來證明鎖優化確實存在。
但是,之前很多例子之所以可以用反編譯工具,是因為那些“優化”,如語法糖等,是在javac編譯
階段發生的,並不是在JIT編譯
階段發生的。而鎖優化,是JIT編譯器的功能,所以,無法使用現有的反編譯工具檢視具體的優化結果。(關於javac編譯和JIT編譯的關係和區別,我在我的知識星球中單獨發了一篇文章介紹。)
但是,如果讀者感興趣,還是可以看的,只是會複雜一點,首先你要自己build一個fasttest版本的jdk,然後在使用java命令對
.class
檔案進行執行的時候加上-XX:+PrintEliminateLocks
引數。而且jdk的模式還必須是server模式。
總之,讀者只需要知道,在使用synchronized
的時候,如果JIT經過逃逸分析之後發現並無執行緒安全問題的話,就會做鎖消除。
鎖粗化
很多人都知道,在程式碼中,需要加鎖的時候,我們提倡儘量減小鎖的粒度,這樣可以避免不必要的阻塞。
這也是很多人原因是用同步程式碼塊來代替同步方法的原因,因為往往他的粒度會更小一些,這其實是很有道理的。
還是我們去銀行櫃檯辦業務,最高效的方式是你坐在櫃檯前面的時候,只辦和銀行相關的事情。如果這個時候,你拿出手機,接打幾個電話,問朋友要往哪個賬戶裡面打錢,這就很浪費時間了。最好的做法肯定是提前準備好相關資料,在辦理業務時直接辦理就好了。
加鎖也一樣,把無關的準備工作放到鎖外面,鎖內部只處理和併發相關的內容。這樣有助於提高效率。
那麼,這和鎖粗化有什麼關係呢?可以說,大部分情況下,減小鎖的粒度是很正確的做法,只有一種特殊的情況下,會發生一種叫做鎖粗化的優化。
就像你去銀行辦業務,你為了減少每次辦理業務的時間,你把要辦的五個業務分成五次去辦理,這反而適得其反了。因為這平白的增加了很多你重新取號、排隊、被喚醒的時間。
如果在一段程式碼中連續的對同一個物件反覆加鎖解鎖,其實是相對耗費資源的,這種情況可以適當放寬加鎖的範圍,減少效能消耗。
當JIT發現一系列連續的操作都對同一個物件反覆加鎖和解鎖,甚至加鎖操作出現在迴圈體中的時候,會將加鎖同步的範圍擴散(粗化)到整個操作序列的外部。
如以下程式碼:
for(int i=0;i<100000;i++){
synchronized(this){
do();
}
複製程式碼
會被粗化成:
synchronized(this){
for(int i=0;i<100000;i++){
do();
}
複製程式碼
這其實和我們要求的減小鎖粒度並不衝突。減小鎖粒度強調的是不要在銀行櫃檯前做準備工作以及和辦理業務無關的事情。而鎖粗化建議的是,同一個人,要辦理多個業務的時候,可以在同一個視窗一次性辦完,而不是多次取號多次辦理。
總結
自Java 6/Java 7開始,Java虛擬機器對內部鎖的實現進行了一些優化。這些優化主要包括鎖消除(Lock Elision)、鎖粗化(Lock Coarsening)、偏向鎖(Biased Locking)以及適應性自旋鎖(Adaptive Locking)。這些優化僅在Java虛擬機器server模式下起作用(即執行Java程式時我們可能需要在命令列中指定Java虛擬機器引數“-server”以開啟這些優化)。
本文主要介紹了自旋鎖、鎖粗化和鎖消除的概念。在JIT編譯過程中,虛擬機器會根據情況使用這三種技術對鎖進行優化,目的是減少鎖的競爭,提升效能。