互斥同步
互斥同步(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方法拼接字符串的例子吧。
