0. 前言
造成執行緒安全問題的主要誘因有兩點,一是存在共享資料(也稱臨界資源),二是存在多個執行緒共同操作共享資料。因此為了解決執行緒安全問題,我們可能需要這樣一個方案,當存在多個執行緒操作共享資料時,需要保證同一時刻有且只有一個執行緒在操作共享資料,其他執行緒必須等到該執行緒處理完資料後再進行,這種方式有個高尚的名稱叫互斥,即能達到互斥訪問的目的。換句話說當一個共享資料被當前正在訪問的執行緒加上互斥鎖後,在同一個時刻,其他執行緒只能處於等待的狀態,直到當前執行緒處理完畢釋放該鎖。在 Java 中,關鍵字 synchronized可以保證在同一個時刻,只有一個執行緒可以執行某個方法或者某個程式碼塊(主要是對方法或者程式碼塊中存在共享資料的操作),同時我們還應該注意到synchronized另外一個重要的作用,synchronized可保證一個執行緒的變化(主要是共享資料的變化)被其他執行緒所看到(保證可見性,完全可以替代Volatile功能),這點確實也是很重要的。
1. synchronized的三種應用方式
synchronized關鍵字最主要有以下3種應用方式,下面分別介紹
- 修飾例項方法,作用於當前例項加鎖,進入同步程式碼前要獲得當前例項的鎖
- 修飾靜態方法,作用於當前類物件加鎖,進入同步程式碼前要獲得當前類物件的鎖
- 修飾程式碼塊,指定加鎖物件,對給定物件加鎖,進入同步程式碼塊前要獲得給定物件的鎖
1.1 synchronized作用於例項方法
所謂的例項物件鎖就是用synchronized修飾例項物件中的例項方法,注意例項方法不包括靜態方法
public class AccountingSync implements Runnable{
//共享資源(臨界資源)
static int i=0;
/**
* synchronized 修飾例項方法
*/
public synchronized void increase(){
i++;
}
@Override
public void run() {
for(int j=0;j<100;j++){
increase();
}
}
public static void main(String[] args) throws InterruptedException {
AccountingSync instance=new AccountingSync();
Thread t1=new Thread(instance);
Thread t2=new Thread(instance);
t1.start();
t2.start();
System.out.println(i);//200
}
}
複製程式碼
上述程式碼中,我們開啟兩個執行緒操作同一個共享資源即變數i,由於i++操作並不具備原子性,該操作是先讀取值,然後寫回一個新值,相當於原來的值加上1,分兩步完成。如果第二個執行緒在第一個執行緒讀取舊值和寫回新值期間讀取i的值,那麼第二個執行緒就會與第一個執行緒一起看到同一個值,並執行相同值的加1操作,這也就造成了執行緒安全失敗。因此對於increase方法必須使用synchronized修飾,以便保證執行緒安全。此時我們應該注意到synchronized修飾的是例項方法increase,在這樣的情況下,當前執行緒的鎖便是例項物件,注意Java中的執行緒同步鎖可以是任意物件。從程式碼執行結果來看確實是正確的,倘若我們沒有使用synchronized關鍵字,其最終輸出結果就很可能小於200,這便是synchronized關鍵字的作用。這裡我們還需要意識到,當一個執行緒正在訪問一個物件的 synchronized 例項方法,那麼其他執行緒不能訪問該物件的其他 synchronized 方法,畢竟一個物件只有一把鎖,當一個執行緒獲取了該物件的鎖之後,其他執行緒無法獲取該物件的鎖,所以無法訪問該物件的其他synchronized例項方法,但是其他執行緒還是可以訪問該例項物件的其他非synchronized方法。當然如果是一個執行緒 A 需要訪問例項物件 obj1 的 synchronized 方法 f1(當前物件鎖是obj1),另一個執行緒 B 需要訪問例項物件 obj2 的 synchronized 方法 f2(當前物件鎖是obj2),這樣是允許的,因為兩個例項物件鎖並不同相同,此時如果兩個執行緒運算元據並非共享的,執行緒安全是有保障的,遺憾的是如果兩個執行緒操作的是共享資料,那麼執行緒安全就有可能無法保證了,如下程式碼將演示出該現象:
public class AccountingSyncBad implements Runnable{
static int i=0;
public synchronized void increase(){
i++;
}
@Override
public void run() {
for(int j=0;j<1000000;j++){
increase();
}
}
public static void main(String[] args) throws InterruptedException {
//new新例項
Thread t1=new Thread(new AccountingSyncBad());
//new新例項
Thread t2=new Thread(new AccountingSyncBad());
t1.start();
t2.start();
System.out.println(i);//146
}
}
複製程式碼
上述程式碼與前面不同的是我們同時建立了兩個新例項AccountingSyncBad,然後啟動兩個不同的執行緒對共享變數i進行操作,但很遺憾操作結果是146而不是期望結果200,因為上述程式碼犯了嚴重的錯誤,雖然我們使用synchronized修飾了increase方法,但卻new了兩個不同的例項物件,這也就意味著存在著兩個不同的例項物件鎖,因此t1和t2都會進入各自的物件鎖,也就是說t1和t2執行緒使用的是不同的鎖,因此執行緒安全是無法保證的。解決這種困境的的方式是將synchronized作用於靜態的increase方法,這樣的話,物件鎖就當前類物件,由於無論建立多少個例項物件,但對於的類物件只有一個,所有在這樣的情況下物件鎖就是唯一的。下面我們看看如何使用將synchronized作用於靜態的increase方法。
1.2 synchronized作用於靜態方法
當synchronized作用於靜態方法時,其鎖就是當前類的class物件鎖。由於靜態成員不專屬於任何一個例項物件,是類成員,因此通過class物件鎖可以控制靜態 成員的併發操作。需要注意的是如果一個執行緒A呼叫一個例項物件的非static synchronized方法,而執行緒B需要呼叫這個例項物件所屬類的靜態 synchronized方法,是允許的,不會發生互斥現象,因為訪問靜態 synchronized 方法佔用的鎖是當前類的class物件,而訪問非靜態 synchronized 方法佔用的鎖是當前例項物件鎖,看如下程式碼
public class AccountingSyncClass implements Runnable{
static int i=0;
/**
* 作用於靜態方法,鎖是當前class物件,也就是
* AccountingSyncClass類對應的class物件
*/
public static synchronized void increase(){
i++;
}
/**
* 非靜態,訪問時鎖不一樣不會發生互斥
*/
public synchronized void increase4Obj(){
i++;
}
@Override
public void run() {
for(int j=0;j<1000000;j++){
increase();
}
}
public static void main(String[] args) throws InterruptedException {
//new新例項
Thread t1=new Thread(new AccountingSyncClass());
//new新例項
Thread t2=new Thread(new AccountingSyncClass());
//啟動執行緒
t1.start();
t2.start();
System.out.println(i);
}
}
複製程式碼
由於synchronized關鍵字修飾的是靜態increase方法,與修飾例項方法不同的是,其鎖物件是當前類的class物件。注意程式碼中的increase4Obj方法是例項方法,其物件鎖是當前例項物件,如果別的執行緒呼叫該方法,將不會產生互斥現象,畢竟鎖物件不同,但我們應該意識到這種情況下可能會發現執行緒安全問題(操作了共享靜態變數i)。
1.3 synchronized同步程式碼塊
除了使用關鍵字修飾例項方法和靜態方法外,還可以修飾同步程式碼塊,在某些情況下,我們編寫的方法體可能比較大,同時存在一些比較耗時的操作,而需要同步的程式碼又只有一小部分,如果直接對整個方法進行同步操作,可能會得不償失,此時我們可以使用同步程式碼塊的方式對需要同步的程式碼進行包裹,這樣就無需對整個方法進行同步操作了,同步程式碼塊的使用示例如下:
public class AccountingSync implements Runnable{
static AccountingSync instance=new AccountingSync();
static int i=0;
@Override
public void run() {
//省略其他耗時操作....
//使用同步程式碼塊對變數i進行同步操作,鎖物件為instance
synchronized(instance){
for(int j=0;j<1000000;j++){
i++;
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(instance);
Thread t2=new Thread(instance);
t1.start();
t2.start();
System.out.println(i);
}
}
複製程式碼
從程式碼看出,將synchronized作用於一個給定的例項物件instance,即當前例項物件就是鎖物件,每次當執行緒進入synchronized包裹的程式碼塊時就會要求當前執行緒持有instance例項物件鎖,如果當前有其他執行緒正持有該物件鎖,那麼新到的執行緒就必須等待,這樣也就保證了每次只有一個執行緒執行i++;操作。當然除了instance作為物件外,我們還可以使用this物件(代表當前例項)或者當前類的class物件作為鎖,如下程式碼:
//this,當前例項物件鎖
synchronized(this){
for(int j=0;j<100;j++){
i++;
}
}
//class物件鎖
synchronized(AccountingSync.class){
for(int j=0;j<100;j++){
i++;
}
}
複製程式碼
3. synchronized底層語義原理
Java 虛擬機器中的同步(Synchronization)基於進入和退出管程(Monitor)物件實現, 無論是顯式同步(有明確的 monitorenter 和 monitorexit 指令,即同步程式碼塊)還是隱式同步都是如此。在 Java 語言中,同步用的最多的地方可能是被 synchronized 修飾的同步方法。同步方法 並不是由 monitorenter 和 monitorexit 指令來實現同步的,而是由方法呼叫指令讀取執行時常量池中方法的 ACC_SYNCHRONIZED 標誌來隱式實現的,關於這點,稍後詳細分析。下面先來了解一個概念Java物件頭,這對深入理解synchronized實現原理非常關鍵。
3.1 理解Java物件頭與Monitor
在JVM中,物件在記憶體中的佈局分為三塊區域:物件頭、例項資料和對齊填充。如下:
- 例項資料:存放類的屬性資料資訊,包括父類的屬性資訊。
- 對齊填充:由於虛擬機器要求物件起始地址必須是8位元組的整數倍。填充資料不是必須存在的,僅僅是為了位元組對齊。
- 物件頭:Java物件頭一般佔有2個機器碼(在32位虛擬機器中,1個機器碼等於4位元組,也就是32bit,在64位虛擬機器中,1個機器碼是8個位元組,也就是64bit),但是如果物件是陣列型別,則需要3個機器碼,因為JVM虛擬機器可以通過Java物件的後設資料資訊確定Java物件的大小,但是無法從陣列的後設資料來確認陣列的大小,所以用一塊來記錄陣列長度。
為了表示物件的屬性、方法等資訊,不得不需要結構描述。Hotspot VM 使用物件頭部的一個指標指向 Class 區域的方式來找到物件的 Class 描述,以及內部的方法、屬性入口。如下圖所示:
虛擬機器位數 | 頭物件結構 | 說明 |
---|---|---|
32/64bit | Mark Word | 儲存物件自身的執行時資料,如雜湊碼(HashCode)、GC 分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒 ID、偏向時間戳、物件分代年齡等;Mark Word 被設計成一個非固定的資料結構以便在極小的空間記憶體儲儘量多的資訊,它會根據自己的狀態複用自己的儲存空間 |
32/64bi | Class Metadata Address | 型別指標指向物件的類後設資料,JVM通過這個指標確定該物件是哪個類的例項。 |
32/64bi | Array Length | 如果物件是一個 Java 陣列,那在物件頭中還必須有一塊用於記錄陣列長度的資料。因為虛擬機器可以通過普通 Java 物件的後設資料資訊確定 Java 物件的大小,但是從陣列的後設資料中無法確定陣列的大小。這部分資料的長度在 32 位和 64 位的虛擬機器(未開啟壓縮指標)中分別為 32bit 和 64bit。 |
例如,在 32 位的 HotSpot 虛擬機器中,如果物件處於未被鎖定的狀態下,那麼 Mark Word 的 32bit 空間中的 25bit 用於儲存物件雜湊碼,4bit 用於儲存物件分代年齡,2bit 用於儲存鎖標誌位,1bit 固定為 0,如下表所示:
由於物件頭的資訊是與物件自身定義的資料沒有關係的額外儲存成本,因此考慮到JVM的空間效率,Mark Word 被設計成為一個非固定的資料結構,以便儲存更多有效的資料,它會根據物件本身的狀態複用自己的儲存空間,如32位JVM下,除了上述列出的Mark Word預設儲存結構外,還有如下可能變化的結構:
在64位虛擬機器下,Mark Word是64bit大小的,其儲存結構如下:
物件頭的最後兩位儲存了鎖的標誌位,01是初始狀態,未加鎖,其物件頭裡儲存的是物件本身的雜湊碼,隨著鎖級別的不同,物件頭裡會儲存不同的內容。偏向鎖儲存的是當前佔用此物件的執行緒ID;而輕量級則儲存指向執行緒棧中鎖記錄的指標。從這裡我們可以看到,“鎖”這個東西,可能是個鎖記錄+物件頭裡的引用指標(判斷執行緒是否擁有鎖時將執行緒的鎖記錄地址和物件頭裡的指標地址比較),也可能是物件頭裡的執行緒ID(判斷執行緒是否擁有鎖時將執行緒的ID和物件頭裡儲存的執行緒ID比較)。
其中輕量級鎖和偏向鎖是Java 6 對 synchronized 鎖進行優化後新增加的,稍後我們會簡要分析。這裡我們主要分析一下重量級鎖也就是通常說synchronized的物件鎖,鎖標識位為10,其中指標指向的是monitor物件(也稱為管程或監視器鎖)的起始地址。每個物件都存在著一個 monitor 與之關聯,物件與其 monitor 之間的關係有存在多種實現方式,如monitor可以與物件一起建立銷燬或當執行緒試圖獲取物件鎖時自動生成,但當一個 monitor 被某個執行緒持有後,它便處於鎖定狀態。在Java虛擬機器(HotSpot)中,monitor是由ObjectMonitor實現的,其主要資料結構如下(位於HotSpot虛擬機器原始碼ObjectMonitor.hpp檔案,C++實現的)
ObjectMonitor() {
_header = NULL;
_count = 0; //記錄個數
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //處於wait狀態的執行緒,會被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //處於等待鎖block狀態的執行緒,會被加入到該列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
複製程式碼
ObjectMonitor中有兩個佇列,_WaitSet 和 _EntryList,用來儲存ObjectWaiter物件列表( 每個等待鎖的執行緒都會被封裝成ObjectWaiter物件),_owner指向持有ObjectMonitor物件的執行緒,當多個執行緒同時訪問一段同步程式碼時,首先會進入 _EntryList 集合,當執行緒獲取到物件的monitor 後進入 _Owner 區域並把monitor中的owner變數設定為當前執行緒同時monitor中的計數器count加1,若執行緒呼叫 wait() 方法,將釋放當前持有的monitor,owner變數恢復為null,count自減1,同時該執行緒進入 WaitSet集合中等待被喚醒。若當前執行緒執行完畢也將釋放monitor(鎖)並復位變數的值,以便其他執行緒進入獲取monitor(鎖)。如下圖所示
由此看來,monitor物件存在於每個Java物件的物件頭中(儲存的指標的指向),synchronized鎖便是通過這種方式獲取鎖的,也是為什麼Java中任意物件可以作為鎖的原因,同時也是notify/notifyAll/wait等方法存在於頂級物件Object中的原因(關於這點稍後還會進行分析),有了上述知識基礎後,下面我們將進一步分析synchronized在位元組碼層面的具體語義實現。
3.2 synchronized程式碼塊底層原理
現在我們重新定義一個synchronized修飾的同步程式碼塊,在程式碼塊中操作共享變數i,如下
public class SyncCodeBlock {
public int i;
public void syncTask(){
//同步程式碼塊
synchronized (this){
i++;
}
}
}
複製程式碼
編譯上述程式碼並使用javap反編譯後得到位元組碼如下(這裡我們省略一部分沒有必要的資訊):
Classfile /***/src/main/java/com/zejian/concurrencys/SyncCodeBlock.class
Last modified 2018-07-25; size 426 bytes
MD5 checksum c80bc322c87b312de760942820b4fed5
Compiled from "SyncCodeBlock.java"
public class com.hc.concurrencys.SyncCodeBlock
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
//........省略常量池中資料
//建構函式
public com.hc.concurrencys.SyncCodeBlock();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 7: 0
//===========主要看看syncTask方法實現================
public void syncTask();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter //注意此處,進入同步方法
4: aload_0
5: dup
6: getfield #2 // Field i:I
9: iconst_1
10: iadd
11: putfield #2 // Field i:I
14: aload_1
15: monitorexit //注意此處,退出同步方法
16: goto 24
19: astore_2
20: aload_1
21: monitorexit //注意此處,退出同步方法
22: aload_2
23: athrow
24: return
Exception table:
//省略其他位元組碼.......
}
SourceFile: "SyncCodeBlock.java"
複製程式碼
我們主要關注位元組碼中的如下程式碼
3: monitorenter //進入同步方法
//..........省略其他
15: monitorexit //退出同步方法
16: goto 24
//省略其他.......
21: monitorexit //退出同步方法
複製程式碼
從位元組碼中可知同步語句塊的實現使用的是monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步程式碼塊的開始位置,monitorexit指令則指明同步程式碼塊的結束位置,當執行monitorenter指令時,當前執行緒將試圖獲取 objectref(即物件鎖) 所對應的 monitor 的持有權,當 objectref 的 monitor 的進入計數器為 0,那執行緒可以成功取得 monitor,並將計數器值設定為 1,取鎖成功。如果當前執行緒已經擁有 objectref 的 monitor 的持有權,那它可以重入這個 monitor (關於重入性稍後會分析),重入時計數器的值也會加 1。倘若其他執行緒已經擁有 objectref 的 monitor 的所有權,那當前執行緒將被阻塞,直到正在執行執行緒執行完畢,即monitorexit指令被執行,執行執行緒將釋放 monitor(鎖)並設定計數器值為0 ,其他執行緒將有機會持有 monitor 。值得注意的是編譯器將會確保無論方法通過何種方式完成,方法中呼叫過的每條 monitorenter 指令都有執行其對應 monitorexit 指令,而無論這個方法是正常結束還是異常結束。為了保證在方法異常完成時 monitorenter 和 monitorexit 指令依然可以正確配對執行,編譯器會自動產生一個異常處理器,這個異常處理器宣告可處理所有的異常,它的目的就是用來執行 monitorexit 指令。從位元組碼中也可以看出多了一個monitorexit指令,它就是異常結束時被執行的釋放monitor 的指令。
3.3 synchronized方法底層原理
方法級的同步是隱式,即無需通過位元組碼指令來控制的,它實現在方法呼叫和返回操作之中。JVM可以從方法常量池中的方法表結構(method_info Structure) 中的 ACC_SYNCHRONIZED 訪問標誌區分一個方法是否同步方法。當方法呼叫時,呼叫指令將會 檢查方法的 ACC_SYNCHRONIZED訪問標誌是否被設定,如果設定了,執行執行緒將先持有monitor(虛擬機器規範中用的是管程一詞),然後再執行方法,最後再方法完成(無論是正常完成還是非正常完成)時釋放monitor。在方法執行期間,執行執行緒持有了monitor,其他任何執行緒都無法再獲得同一個monitor。如果一個同步方法執行期間丟擲了異常,並且在方法內部無法處理此異常,那這個同步方法所持有的monitor將在異常拋到同步方法之外時自動釋放。下面我們看看位元組碼層面如何實現:
public class SyncMethod {
public int i;
public synchronized void syncTask(){
i++;
}
}
複製程式碼
使用javap反編譯後的位元組碼如下:
Classfile /***/src/main/java/com/zejian/concurrencys/SyncMethod.class
Last modified 2017-6-2; size 308 bytes
MD5 checksum f34075a8c059ea65e4cc2fa610e0cd94
Compiled from "SyncMethod.java"
public class com.hc.concurrencys.SyncMethod
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool;
//省略沒必要的位元組碼
//==================syncTask方法======================
public synchronized void syncTask();
descriptor: ()V
//方法標識ACC_PUBLIC代表public修飾,ACC_SYNCHRONIZED指明該方法為同步方法
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
2: getfield #2 // Field i:I
5: iconst_1
6: iadd
7: putfield #2 // Field i:I
10: return
LineNumberTable:
line 12: 0
line 13: 10
}
SourceFile: "SyncMethod.java"
複製程式碼
從位元組碼中可以看出,synchronized修飾的方法並沒有monitorenter指令和monitorexit指令,取得代之的確實是ACC_SYNCHRONIZED標識,該標識指明瞭該方法是一個同步方法,JVM通過該ACC_SYNCHRONIZED訪問標誌來辨別一個方法是否宣告為同步方法,從而執行相應的同步呼叫。這便是synchronized鎖在同步程式碼塊和同步方法上實現的基本原理。同時我們還必須注意到的是在Java早期版本中,synchronized屬於重量級鎖,效率低下,因為監視器鎖(monitor)是依賴於底層的作業系統的Mutex Lock(互斥鎖)來實現的,而作業系統實現執行緒之間的切換時需要從使用者態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,這也是為什麼早期的synchronized效率低的原因。慶幸的是在Java6之後Java官方對從JVM層面對synchronized較大優化,所以現在的synchronized鎖效率也優化得很不錯了,Java6之後,為了減少獲得鎖和釋放鎖所帶來的效能消耗,引入了輕量級鎖和偏向鎖,接下來我們將簡單瞭解一下Java官方在JVM層面對synchronized鎖的優化。
4. Java虛擬機器對synchronized的優化
鎖的狀態總共有四種,無鎖狀態、偏向鎖、輕量級鎖和重量級鎖。隨著鎖的競爭,鎖可以從偏向鎖升級到輕量級鎖,再升級的重量級鎖,但是鎖的升級是單向的,也就是說只能從低到高升級,不會出現鎖的降級,關於重量級鎖,前面我們已詳細分析過,下面我們將介紹偏向鎖和輕量級鎖以及JVM的其他優化手段,這裡並不打算深入到每個鎖的實現和轉換過程更多地是闡述Java虛擬機器所提供的每個鎖的核心優化思想,畢竟涉及到具體過程比較繁瑣。
4.1 偏向鎖
偏向鎖是Java 6之後加入的新鎖,它是一種針對加鎖操作的優化手段,經過研究發現,在大多數情況下,鎖不僅不存在多執行緒競爭,而且總是由同一執行緒多次獲得,因此為了減少同一執行緒獲取鎖(會涉及到一些CAS操作,耗時)的代價而引入偏向鎖。偏向鎖的核心思想是,如果一個執行緒獲得了鎖,那麼鎖就進入偏向模式,此時Mark Word 的結構也變為偏向鎖結構,當這個執行緒再次請求鎖時,無需再做任何同步操作,即獲取鎖的過程,這樣就省去了大量有關鎖申請的操作,從而也就提供程式的效能。所以,對於沒有鎖競爭的場合,偏向鎖有很好的優化效果,畢竟極有可能連續多次是同一個執行緒申請相同的鎖。但是對於鎖競爭比較激烈的場合,偏向鎖就失效了,因為這樣場合極有可能每次申請鎖的執行緒都是不相同的,因此這種場合下不應該使用偏向鎖,否則會得不償失,需要注意的是,偏向鎖失敗後,並不會立即膨脹為重量級鎖,而是先升級為輕量級鎖。下面我們接著瞭解輕量級鎖。
4.2 輕量級鎖
倘若偏向鎖失敗,虛擬機器並不會立即升級為重量級鎖,它還會嘗試使用一種稱為輕量級鎖的優化手段(1.6之後加入的),此時Mark Word 的結構也變為輕量級鎖的結構。輕量級鎖能夠提升程式效能的依據是“對絕大部分的鎖,在整個同步週期內都不存在競爭”,注意這是經驗資料。需要了解的是,輕量級鎖所適應的場景是執行緒交替執行同步塊的場合,如果存在同一時間訪問同一鎖的場合,就會導致輕量級鎖膨脹為重量級鎖。
4.3 自旋鎖
輕量級鎖失敗後,虛擬機器為了避免執行緒真實地在作業系統層面掛起,還會進行一項稱為自旋鎖的優化手段。這是基於在大多數情況下,執行緒持有鎖的時間都不會太長,如果直接掛起作業系統層面的執行緒可能會得不償失,畢竟作業系統實現執行緒之間的切換時需要從使用者態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,因此自旋鎖會假設在不久將來,當前的執行緒可以獲得鎖,因此虛擬機器會讓當前想要獲取鎖的執行緒做幾個空迴圈(這也是稱為自旋的原因),一般不會太久,可能是50個迴圈或100迴圈,在經過若干次迴圈後,如果得到鎖,就順利進入臨界區。如果還不能獲得鎖,那就會將執行緒在作業系統層面掛起,這就是自旋鎖的優化方式,這種方式確實也是可以提升效率的。最後沒辦法也就只能升級為重量級鎖了。
4.4 適應性自旋鎖
DK 1.6引入了更加聰明的自旋鎖,即自適應自旋鎖。所謂自適應就意味著自旋的次數不再是固定的,它是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。
執行緒如果自旋成功了,那麼下次自旋的次數會更加多,因為虛擬機器認為既然上次成功了,那麼此次自旋也很有可能會再次成功,那麼它就會允許自旋等待持續的次數更多。反之,如果對於某個鎖,很少有自旋能夠成功,那麼在以後要或者這個鎖的時候自旋的次數會減少甚至省略掉自旋過程,以免浪費處理器資源。
4.5 鎖消除
消除鎖是虛擬機器另外一種鎖的優化,這種優化更徹底,Java虛擬機器在JIT編譯時(可以簡單理解為當某段程式碼即將第一次被執行時進行編譯,又稱即時編譯),通過對執行上下文的掃描,去除不可能存在共享資源競爭的鎖,通過這種方式消除沒有必要的鎖,可以節省毫無意義的請求鎖時間,如下StringBuffer的append是一個同步方法,但是在add方法中的StringBuffer屬於一個區域性變數,並且不會被其他執行緒所使用,因此StringBuffer不可能存在共享資源競爭的情景,JVM會自動將其鎖消除。
public class StringBufferRemoveSync {
public void add(String str1, String str2) {
//StringBuffer是執行緒安全,由於sb只會在append方法中使用,不可能被其他執行緒引用
//因此sb屬於不可能共享的資源,JVM會自動消除內部的鎖
StringBuffer sb = new StringBuffer();
sb.append(str1).append(str2);
}
public static void main(String[] args) {
StringBufferRemoveSync rmsync = new StringBufferRemoveSync();
for (int i = 0; i < 100; i++) {
rmsync.add("abc", "123");
}
}
}
複製程式碼
4.6 偏向鎖、輕量級鎖、重量級鎖之間的狀態轉換
5. 關於synchronized 可能需要了解的關鍵點
5.1 synchronized的可重入性
從互斥鎖的設計上來說,當一個執行緒試圖操作一個由其他執行緒持有的物件鎖的臨界資源時,將會處於阻塞狀態,但當一個執行緒再次請求自己持有物件鎖的臨界資源時,這種情況屬於重入鎖,請求將會成功,在java中synchronized是基於原子性的內部鎖機制,是可重入的,因此在一個執行緒呼叫synchronized方法的同時在其方法體內部呼叫該物件另一個synchronized方法,也就是說一個執行緒得到一個物件鎖後再次請求該物件鎖,是允許的,這就是synchronized的可重入性。如下:
public class AccountingSync implements Runnable{
static AccountingSync instance=new AccountingSync();
static int i=0;
static int j=0;
@Override
public void run() {
for(int j=0;j<100;j++){
//this,當前例項物件鎖
synchronized(this){
i++;
increase();//synchronized的可重入性
}
}
}
public synchronized void increase(){
j++;
}
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(instance);
Thread t2=new Thread(instance);
t1.start();
t1.join();
t2.start();
t2.join();
System.out.println(i);
}
}
複製程式碼
正如程式碼所演示的,在獲取當前例項物件鎖後進入synchronized程式碼塊執行同步程式碼,並在程式碼塊中呼叫了當前例項物件的另外一個synchronized方法,再次請求當前例項鎖時,將被允許,進而執行方法體程式碼,這就是重入鎖最直接的體現,需要特別注意另外一種情況,當子類繼承父類時,子類也是可以通過可重入鎖呼叫父類的同步方法。注意由於synchronized是基於monitor實現的,因此每次重入,monitor中的計數器仍會加1。
重入進一步提升了加鎖行為的封裝性,因此簡化了物件導向併發程式碼的開發。分析如下程式:
public class Father
{
public synchronized void doSomething(){
// do something...
}
}
public class Child extends Father
{
public synchronized void doSomething(){
// do something...
super.doSomething();
}
}
複製程式碼
子類覆寫了父類的同步方法,然後呼叫父類中的方法,此時如果沒有可重入的鎖,那麼這段程式碼將產生死鎖。
由於Father和Child中的doSomething方法都是synchronized方法,因此每個doSomething方法在執行前都會獲取Child物件例項上的鎖。如果內建鎖不是可重入的,那麼在呼叫super.doSomething時將無法獲得該Child物件上的互斥鎖,因為這個鎖已經被持有,從而執行緒會永遠阻塞下去,一直在等待一個永遠也無法獲取的鎖。重入則避免了這種死鎖情況的發生。
同一個執行緒在呼叫本類中其他synchronized方法、塊或父類中的synchronized方法/塊時,都不會阻礙該執行緒的執行,因為互斥鎖是可重入的。
5.2 執行緒中斷與synchronized
正如中斷二字所表達的意義,線上程執行(run方法)中間打斷它,在Java中,提供了以下3個有關執行緒中斷的方法
//中斷執行緒(例項方法)
public void Thread.interrupt();
//判斷執行緒是否被中斷(例項方法)
public boolean Thread.isInterrupted();
//判斷是否被中斷並清除當前中斷狀態(靜態方法)
public static boolean Thread.interrupted();
複製程式碼
當一個執行緒處於被阻塞狀態或者試圖執行一個阻塞操作時,使用Thread.interrupt()方式中斷該執行緒,注意此時將會丟擲一個InterruptedException的異常,同時中斷狀態將會被複位(由中斷狀態改為非中斷狀態),如下程式碼將演示該過程:
public class InterruputSleepThread {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread() {
@Override
public void run() {
//while在try中,通過異常中斷就可以退出run迴圈
try {
while (true) {
//當前執行緒處於阻塞狀態,異常必須捕捉處理,無法往外丟擲
TimeUnit.SECONDS.sleep(2);
}
} catch (InterruptedException e) {
System.out.println("Interruted When Sleep");
boolean interrupt = this.isInterrupted();
//中斷狀態被複位
System.out.println("interrupt:"+interrupt);
}
}
};
t1.start();
TimeUnit.SECONDS.sleep(2);
//中斷處於阻塞狀態的執行緒
t1.interrupt();
/**
* 輸出結果:
Interruted When Sleep
interrupt:false
*/
}
}
複製程式碼
如上述程式碼所示,我們建立一個執行緒,並線上程中呼叫了sleep方法從而使用執行緒進入阻塞狀態,啟動執行緒後,呼叫執行緒例項物件的interrupt方法中斷阻塞異常,並丟擲InterruptedException異常,此時中斷狀態也將被複位。這裡有些人可能會詫異,為什麼不用Thread.sleep(2000);而是用TimeUnit.SECONDS.sleep(2);其實原因很簡單,前者使用時並沒有明確的單位說明,而後者非常明確表達秒的單位,事實上後者的內部實現最終還是呼叫了Thread.sleep(2000);,但為了編寫的程式碼語義更清晰,建議使用TimeUnit.SECONDS.sleep(2);的方式,注意TimeUnit是個列舉型別。除了阻塞中斷的情景,我們還可能會遇到處於執行期且非阻塞的狀態的執行緒,這種情況下,直接呼叫Thread.interrupt()中斷執行緒是不會得到任響應的,如下程式碼,將無法中斷非阻塞狀態下的執行緒:
public class InterruputThread {
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(){
@Override
public void run(){
while(true){
System.out.println("未被中斷");
}
}
};
t1.start();
TimeUnit.SECONDS.sleep(2);
t1.interrupt();
/**
* 輸出結果(無限執行):
未被中斷
未被中斷
未被中斷
......
*/
}
}
複製程式碼
雖然我們呼叫了interrupt方法,但執行緒t1並未被中斷,因為處於非阻塞狀態的執行緒需要我們手動進行中斷檢測並結束程式,改進後程式碼如下:
public class InterruputThread {
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(){
@Override
public void run(){
while(true){
//判斷當前執行緒是否被中斷
if (this.isInterrupted()){
System.out.println("執行緒中斷");
break;
}
}
System.out.println("已跳出迴圈,執行緒中斷!");
}
};
t1.start();
TimeUnit.SECONDS.sleep(2);
t1.interrupt();
/**
* 輸出結果:
執行緒中斷
已跳出迴圈,執行緒中斷!
*/
}
}
複製程式碼
我們在程式碼中使用了例項方法isInterrupted判斷執行緒是否已被中斷,如果被中斷將跳出迴圈以此結束執行緒,注意非阻塞狀態呼叫interrupt()並不會導致中斷狀態重置。綜合所述,可以簡單總結一下中斷兩種情況,一種是當執行緒處於阻塞狀態或者試圖執行一個阻塞操作時,我們可以使用例項方法interrupt()進行執行緒中斷,執行中斷操作後將會丟擲interruptException異常(該異常必須捕捉無法向外丟擲)並將中斷狀態復位,另外一種是當執行緒處於執行狀態時,我們也可呼叫例項方法interrupt()進行執行緒中斷,但同時必須手動判斷中斷狀態,並編寫中斷執行緒的程式碼(其實就是結束run方法體的程式碼)。有時我們在編碼時可能需要兼顧以上兩種情況,那麼就可以如下編寫:
public void run(){
try {
//判斷當前執行緒是否已中斷,注意interrupted方法是靜態的,執行後會對中斷狀態進行復位
while (!Thread.interrupted()) {
TimeUnit.SECONDS.sleep(2);
}
} catch (InterruptedException e) {
}
}
複製程式碼
事實上執行緒的中斷操作對於正在等待獲取的鎖物件的synchronized方法或者程式碼塊並不起作用,也就是對於synchronized來說,如果一個執行緒在等待鎖,那麼結果只有兩種,要麼它獲得這把鎖繼續執行,要麼它就儲存等待,即使呼叫中斷執行緒的方法,也不會生效。演示程式碼如下:
public class SynchronizedBlocked implements Runnable{
public synchronized void f() {
System.out.println("Trying to call f()");
while(true) // Never releases lock
Thread.yield();
}
/**
* 在構造器中建立新執行緒並啟動獲取物件鎖
*/
public SynchronizedBlocked() {
//該執行緒已持有當前例項鎖
new Thread() {
public void run() {
f(); // Lock acquired by this thread
}
}.start();
}
public void run() {
//中斷判斷
while (true) {
if (Thread.interrupted()) {
System.out.println("中斷執行緒!!");
break;
} else {
f();
}
}
}
public static void main(String[] args) throws InterruptedException {
SynchronizedBlocked sync = new SynchronizedBlocked();
Thread t = new Thread(sync);
//啟動後呼叫f()方法,無法獲取當前例項鎖處於等待狀態
t.start();
TimeUnit.SECONDS.sleep(1);
//中斷執行緒,無法生效
t.interrupt();
}
}
複製程式碼
我們在SynchronizedBlocked建構函式中建立一個新執行緒並啟動獲取呼叫f()獲取到當前例項鎖,由於SynchronizedBlocked自身也是執行緒,啟動後在其run方法中也呼叫了f(),但由於物件鎖被其他執行緒佔用,導致t執行緒只能等到鎖,此時我們呼叫了t.interrupt();但並不能中斷執行緒。
5.3 等待喚醒機制與synchronized
所謂等待喚醒機制本篇主要指的是notify/notifyAll和wait方法,在使用這3個方法時,必須處於synchronized程式碼塊或者synchronized方法中,否則就會丟擲IllegalMonitorStateException異常,這是因為呼叫這幾個方法前必須拿到當前物件的監視器monitor物件,也就是說notify/notifyAll和wait方法依賴於monitor物件,在前面的分析中,我們知道monitor 存在於物件頭的Mark Word 中(儲存monitor引用指標),而synchronized關鍵字可以獲取 monitor ,這也就是為什麼notify/notifyAll和wait方法必須在synchronized程式碼塊或者synchronized方法呼叫的原因。
需要特別理解的一點是,與sleep方法不同的是wait方法呼叫完成後,執行緒將被暫停,但wait方法將會釋放當前持有的監視器鎖(monitor),直到有執行緒呼叫notify/notifyAll方法後方能繼續執行,而sleep方法只讓執行緒休眠並不釋放鎖。同時notify/notifyAll方法呼叫後,並不會馬上釋放監視器鎖,而是在相應的synchronized(){}/synchronized方法執行結束後才自動釋放鎖。