Java虛擬機器13:互斥同步、鎖優化及synchronized和volatile

五月的倉頡發表於2015-12-02

互斥同步

互斥同步(Mutual Exclusion & Synchronization)是常見的一種併發正確性保證手段。同步是指子啊多個執行緒併發訪問共享資料時,保證共享資料在同一時刻只能被一個(或者是一些,使用訊號量的時候)執行緒使用。而互斥是實現同步的一種手段,臨界區(Critial Section)、互斥量(Mutex)和訊號量(Semaphore)都是主要的互斥實現方式。因此,在這四個字裡面,互斥是因,同步是果;互斥是方法,同步是目的。

 

synchronized的實現

在Java中,大家都知道,synchronized關鍵字是最基本的互斥同步手段。看一段簡單的程式碼:

public static void main(String[] args)
{
    synchronized (TestMain.class)
    {
        
    }
}

這段程式碼被編譯之後是這樣的:

 1 public static void main(java.lang.String[]);
 2   flags: ACC_PUBLIC, ACC_STATIC
 3   Code:
 4     stack=2, locals=1, args_size=1
 5        0: ldc           #1                  // class com/xrq/test53/TestMain
 6        2: dup
 7        3: monitorenter
 8        4: monitorexit
 9        5: return
10     LineNumberTable:
11       line 7: 0
12       line 11: 5
13     LocalVariableTable:
14       Start  Length  Slot  Name   Signature
15              0       6     0  args   [Ljava/lang/String;

關鍵就在第7行和第8行,在原始碼被編譯之後,Java虛擬機器會利用monitorenter和monitorexit條位元組碼指令來處理synchronized這個關鍵字。

根據虛擬機器規範的要求,在執行monitorenter指令時,首先要嘗試獲取物件的鎖,如果這個物件沒有被鎖定,或者當前執行緒已經擁有了那個物件的鎖,把鎖的計數器加1,相應地,在執行monitorexit指令時會將鎖計數器減1,當計數器為0時,鎖就會被釋放。如果獲取物件鎖失敗,那當前執行緒就要阻塞等待,直到物件鎖被另外一個執行緒釋放為止。

關於monitorenter和monitorexit,有兩點是要特別注意的:

1、synchronized同步塊對同一條執行緒來說是可重入的,不會出現把自己鎖死的問題

2、同步塊在已進入的執行緒執行完之前,會阻塞後面其它執行緒的進入

因為Java的執行緒是對映到作業系統的原生執行緒之上的,如果要阻塞或者喚醒一個執行緒,都需要作業系統來幫忙完成,這就需要從使用者態轉換到核心態中,因此狀態轉換需要耗費很多的處理器時間,對於程式碼簡單的同步塊,狀態轉換消耗的時間有可能比使用者程式碼執行的時間還長,所以synchronized是Java語言中一個重量級(Heavyweight)鎖,有經驗的程式設計師都會在確實必要的情況下才使用這種操作。

順便看一下HotSpot虛擬機器物件頭Mark Word:

存 儲 內 存 標 識 位 狀    態
物件雜湊嗎、物件分代年齡 01 未鎖定
指向鎖記錄的指標 00 輕量級鎖定
指向重量級鎖的指標 10 膨脹(重量級鎖定)
空,不需要記錄資訊 11 GC標記
偏向執行緒ID、偏向時間戳、物件分代年齡 01 可偏向

看到有一個重量級鎖定,指的就是重量級鎖。

 

volatile的實現

對於volatile關鍵字,一個被volatile關鍵字修飾的變數,在生成組合語言之後,大致會多出這麼一條指令:

0x01a3de24:lock addl $0x0,(%esp)      ;...f0830424 00

這個操作相當於是一個記憶體屏障,只有一個CPU訪問記憶體時,並不需要記憶體屏障;但如果有兩個或者更多CPU訪問同一塊記憶體時,且其中一個在觀測另外一個,就需要記憶體屏障來保證一致性了。這句指令中的"addl $0x0,(%esp)"(把esp暫存器的值加0)顯然是一個空操作(採用這個空操作而不是空指令nop是因為IA32手冊規定lock字首不允許配合nop指令使用),關鍵在於lock字首,查詢IA32手冊,它的作用是使得本CPU的Cache寫入了記憶體,該寫入動作也會引起別的CPU或者別的核心無效化其Cache,這種操作相當於對Cache中的變數做了一次"store和write"操作,所以通過這樣一個空操作,可讓前面volatile變數的修改對其他CPU立即可見。

 

自旋鎖與自適應自旋

互斥同步,對效能影響最大的是阻塞的實現,掛起執行緒和恢復執行緒的操作都需要轉入核心狀態完成,這些操作給系統的併發效能帶來了很大的壓力。同時,虛擬機器開發團隊也注意到很多應用上,共享資料的鎖定狀態只會持續很短的一段時間,為了這段時間去掛起和恢復執行緒並不值得。如果物理機上有一個以上的處理器,能讓兩個或兩個以上的執行緒同時並行執行,我們就可以讓後面請求鎖的那個執行緒"稍等一下",但不放棄處理器的執行時間,看看持有鎖的執行緒是否很快就會釋放鎖。為了讓執行緒等待,我們只需要讓執行緒執行一個忙迴圈(自旋),這項技術就是所謂的自旋鎖

在JDK1.4.2就已經引入了自旋鎖,只不過預設是關閉的。自旋不能代替阻塞,且先不說處理器數量的要求,自旋等待本身雖然避免了執行緒切換的開銷,但是它是要佔據處理器時間的,因此如果鎖被佔用的時間很短,自旋等待的效果就非常好;反之,如果鎖被佔用的時間很長,那麼自旋的執行緒只會白白消耗處理器資源,而不會做任何有用的工作,反而會帶來效能上的浪費。因此自選等待必須有一定的限度,如果自旋超過了限定的次數仍然沒有成功獲得鎖,就應當使用傳統的方式去掛起執行緒了,自旋次數的預設值是10。

在JDK1.6之後引入了自適應的自旋鎖。自適應意味著自旋的時間不再固定了,而是由前一次在同一個鎖上自旋的時間以及鎖的擁有者的狀態來決定。如果在同一個鎖物件上,自旋等待剛剛獲得過鎖,並且持有鎖的執行緒正在執行中,那麼虛擬機器就會認為這次自旋也很有可能再次成功,進而它將允許自旋等待持續相對更長的時間,比如100個迴圈。另外如果對於某一個鎖,自旋很少成功獲得過,那麼在以後要獲得這個鎖時將可能忽略掉自旋過程,以避免浪費處理器資源。有了自適應自旋,隨著程式執行和效能監控資訊的不斷完善,虛擬機器對程式鎖的狀況預測就會越來越準確。

 

鎖消除

鎖消除是指虛擬機器即時編譯器在執行時,對一些程式碼上要求同步,但是被檢測到不可能存在共享資料競爭的鎖進行消除。鎖消除的主要判定依據來源於逃逸分析的支援,如果判斷在一段程式碼中,堆上所有資料都不會逃逸出去從而被其他執行緒訪問到,那就可以把它們當做棧上資料對待,認為它們是執行緒私有的,同步加鎖自然無需進行。

 

鎖粗化

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

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

如果這麼說不夠直觀,那麼想想某段程式碼反覆使用StringBuffer的append方法拼接字串的例子吧。

相關文章