【搞定Java併發程式設計】第5篇:synchronized關鍵字
多執行緒上篇:https://blog.csdn.net/pcwl1206/article/details/84837530
多執行緒下篇:https://blog.csdn.net/pcwl1206/article/details/84843170
本文轉發自:https://blog.csdn.net/javazejian/article/details/72828483
這是一篇值得多讀幾遍、細細揣摩其中深意的文章!
目 錄:
執行緒安全是併發程式設計中的重要關注點,應該注意到的是,造成執行緒安全問題的主要誘因有兩點,一是存在共享資料(也稱臨界資源),二是存在多條執行緒共同操作共享資料
在 Java 中,關鍵字 synchronized可以保證在同一個時刻,只有一個執行緒可以執行某個方法或者某個程式碼塊(主要是對方法或者程式碼塊中存在共享資料的操作),同時我們還應該注意到synchronized另外一個重要的作用,synchronized可保證一個執行緒的變化(主要是共享資料的變化)被其他執行緒所看到(保證可見性,完全可以替代Volatile功能),這點確實也是很重要的。
1、Synchronized的三種應用方式
synchronized關鍵字最主要有以下3種應用方式,下面分別介紹:
1、修飾例項方法,作用於當前例項加鎖,進入同步程式碼前要獲得當前例項的鎖;
2、修飾靜態方法,作用於當前類物件加鎖,進入同步程式碼前要獲得當前類物件的鎖;
3、修飾程式碼塊,指定加鎖物件,對給定物件加鎖,進入同步程式碼庫前要獲得給定物件的鎖。
1.1、synchronized作用於例項方法
所謂的例項物件鎖就是用synchronized修飾例項物件中的例項方法,注意是例項方法不包括靜態方法,如下:
public class AccountingSync implements Runnable {
// 共享資源(臨界資源)
static int i = 0;
public synchronized void increase(){
i++;
}
@Override
public void run() {
for(int j = 0; j < 10000; 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();
t1.join();
t2.join();
System.out.println(i); // 輸出結果20000
}
}
執行結果:
上述程式碼中,我們開啟兩個執行緒操作同一個共享資源即變數 i ,由於i++操作並不具備原子性,該操作是先讀取值,然後寫回一個新值,相當於原來的值加上1,分兩步完成,如果第二個執行緒在第一個執行緒讀取舊值和寫回新值期間讀取 i 的值,那麼第二個執行緒就會與第一個執行緒一起看到同一個值,並執行相同值的加1操作,這也就造成了執行緒安全失敗,因此對於increase方法必須使用synchronized修飾,以便保證執行緒安全。
此時我們應該注意到synchronized修飾的是例項方法increase,在這樣的情況下,當前執行緒的鎖便是例項物件instance,注意Java中的執行緒同步鎖可以是任意物件。從程式碼執行結果來看確實是正確的,倘若我們沒有使用synchronized關鍵字,其最終輸出結果就很可能小於20000,這便是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 < 10000; j++){
increase();
}
}
public static void main(String[] args) throws InterruptedException {
// new了兩個AccountingSyncBad物件
Thread t1 = new Thread(new AccountingSyncBad());
Thread t2 = new Thread(new AccountingSyncBad());
t1.start();
t2.start();
// join含義:當前執行緒等待thread執行緒終止之後才能從thread.join()返回
t1.join();
t2.join();
System.out.println(i);
}
}
執行結果:
上述程式碼與前面不同的是我們同時建立了兩個新例項AccountingSyncBad,然後啟動兩個不同的執行緒對共享變數 i 進行操作,但很遺憾操作結果是18825而不是期望結果20000,因為上述程式碼犯了嚴重的錯誤,雖然我們使用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;
// increase()方法是靜態方法,鎖是當前class物件,也就是AccountingSyncClass類對應的class物件
public static synchronized void increase(){
i++;
}
// 非靜態方法,訪問時鎖不一樣,所以不會發生互斥
public synchronized void increase2(){
i++;
}
@Override
public void run() {
for(int j = 0; j < 10000; j++){
increase();
}
}
public static void main(String[] args) throws InterruptedException {
// new兩個 AccountingSyncClass例項
Thread t1 = new Thread(new AccountingSyncClass());
Thread t2 = new Thread(new AccountingSyncClass());
// 啟動執行緒
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
執行結果:
由於synchronized關鍵字修飾的是靜態increase方法,與修飾例項方法不同的是,其鎖物件是當前類的class物件。注意程式碼中的increase2方法是例項方法,其物件鎖是當前例項物件,如果別的執行緒呼叫該方法,將不會產生互斥現象,畢竟鎖物件不同,但我們應該意識到這種情況下可能會發現執行緒安全問題(操作了共享靜態變數 i )。
1.3、synchronized同步程式碼塊
除了使用關鍵字修飾例項方法和靜態方法外,還可以使用同步程式碼塊,在某些情況下,我們編寫的方法體可能比較大,同時存在一些比較耗時的操作,而需要同步的程式碼又只有一小部分,如果直接對整個方法進行同步操作,可能會得不償失,此時我們可以使用同步程式碼塊的方式對需要同步的程式碼進行包裹,這樣就無需對整個方法進行同步操作了,同步程式碼塊的使用示例如下:
public class AccountingSyncBlock implements Runnable {
static AccountingSyncBlock instance = new AccountingSyncBlock();
static int i = 0;
@Override
public void run() {
// 省略其他耗時操作...
// 使用同步程式碼塊進行同步操作,鎖物件為instance
synchronized (instance) {
for(int j = 0; j < 10000; j++){
i++;
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(instance);
Thread t2 = new Thread(instance);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
執行結果:
從程式碼看出,將synchronized作用於一個給定的例項物件instance,即當前例項物件就是鎖物件,每次當執行緒進入synchronized包裹的程式碼塊時就會要求當前執行緒持有instance例項物件鎖,如果當前有其他執行緒正持有該物件鎖,那麼新到的執行緒就必須等待,這樣也就保證了每次只有一個執行緒執行 i++ 操作。當然除了instance作為物件外,我們還可以使用this物件(代表當前例項)或者當前類的class物件作為鎖,如下程式碼:
//this:當前例項物件鎖
synchronized(this){
for(int j=0;j<10000;j++){
i++;
}
}
// class物件鎖
synchronized(AccountingSync.class){
for(int j=0;j<10000;j++){
i++;
}
}
瞭解完synchronized的基本含義及其使用方式後,下面我們將進一步深入理解synchronized的底層實現原理。
2、synchronized底層語義原理
Java 虛擬機器中的同步(Synchronization)基於進入和退出管程(Monitor)物件實現, 無論是顯式同步(有明確的 monitorenter 和 monitorexit 指令,即同步程式碼塊)還是隱式同步都是如此。
在 Java 語言中,同步用的最多的地方可能是被 synchronized 修飾的同步方法。同步方法並不是由 monitorenter 和 monitorexit 指令來實現同步的,而是由方法呼叫指令讀取執行時常量池中方法的 ACC_SYNCHRONIZED 標誌來隱式實現的,關於這點,稍後詳細分析。下面先來了解一個概念:Java物件頭,這對深入理解synchronized實現原理非常關鍵。
2.1、理解Java物件頭與Monitor
在JVM中,物件在記憶體中的佈局分為三塊區域:物件頭、例項資料和對齊填充。如下:
例項變數:存放類的屬性資料資訊,包括父類的屬性資訊,如果是陣列的例項部分還包括陣列的長度,這部分記憶體按4位元組對齊。
填充資料:由於虛擬機器要求物件起始地址必須是8位元組的整數倍。填充資料不是必須存在的,僅僅是為了位元組對齊,這點了解即可。
Java頭物件:頂部位置,它是實現synchronized鎖物件的基礎。這點我們重點分析,一般而言,synchronized使用的鎖物件是儲存在Java物件頭裡的,JVM中採用2個字來儲存物件頭(如果物件是陣列則會分配3個字,多出來的1個字記錄的是陣列長度),其主要結構是由Mark Word 和 Class Metadata Address 組成,其結構說明如下表:
其中Mark Word在預設情況下儲存著物件的HashCode、分代年齡、鎖標記位等,以下是32位JVM的Mark Word預設儲存結構。
由於物件頭的資訊是與物件自身定義的資料沒有關係的額外儲存成本,因此考慮到JVM的空間效率,Mark Word 被設計成為一個非固定的資料結構,以便儲存更多有效的資料,它會根據物件本身的狀態複用自己的儲存空間,如32位JVM下,除了上述列出的Mark Word預設儲存結構外,還有如下可能變化的結構:
其中輕量級鎖和偏向鎖是 JDK6 對 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中的原因(關於這點稍後還會進行分析),ok~,有了上述知識基礎後,下面我們將進一步分析synchronized在位元組碼層面的具體語義實現。
2.2、synchronized程式碼塊底層原理
現在我們重新定義一個synchronized修飾的同步程式碼塊,在程式碼塊中操作共享變數i,如下
public class SyncCodeBlock {
public int i;
public void syncTask(){
//同步程式碼庫
synchronized (this){
i++;
}
}
}
編譯上述程式碼並使用javap反編譯後得到位元組碼如下(這裡省略一部分沒有必要的資訊):
Classfile /Users/zju/Downloads/Java8_Action/src/main/java/com/zju/concurrencys/SyncCodeBlock.class
Last modified 2017-6-2; size 426 bytes
MD5 checksum c80bc322c87b312de760942820b4fed5
Compiled from "SyncCodeBlock.java"
public class com.zju.concurrencys.SyncCodeBlock
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
//........省略常量池中資料
//建構函式
public com.zju.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 的指令。
2.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 /Users/zju/Downloads/Java8_Action/src/main/java/com/zju/concurrencys/SyncMethod.class
Last modified 2017-6-2; size 308 bytes
MD5 checksum f34075a8c059ea65e4cc2fa610e0cd94
Compiled from "SyncMethod.java"
public class com.zju.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效率低的原因。慶幸的是在JDK6之後Java官方對從JVM層面對synchronized較大優化,所以現在的synchronized鎖效率也優化得很不錯了,JDK6之後,為了減少獲得鎖和釋放鎖所帶來的效能消耗,引入了輕量級鎖和偏向鎖,接下來我們將簡單瞭解一下Java官方在JVM層面對synchronized鎖的優化。
3、Java虛擬機器對synchronized的優化
鎖的狀態總共有四種,無鎖狀態、偏向鎖、輕量級鎖和重量級鎖。隨著鎖的競爭,鎖可以從偏向鎖升級到輕量級鎖,再升級的重量級鎖,但是鎖的升級是單向的,也就是說只能從低到高升級,不會出現鎖的降級。
關於重量級鎖,前面我們已詳細分析過,下面我們將介紹偏向鎖和輕量級鎖以及JVM的其他優化手段,這裡並不打算深入到每個鎖的實現和轉換過程更多地是闡述Java虛擬機器所提供的每個鎖的核心優化思想,畢竟涉及到具體過程比較繁瑣,如需瞭解詳細過程可以查閱《深入理解Java虛擬機器原理》。
3.1、偏向鎖
偏向鎖是JDK6之後加入的新鎖,它是一種針對加鎖操作的優化手段,經過研究發現,在大多數情況下,鎖不僅不存在多執行緒競爭,而且總是由同一執行緒多次獲得,因此為了減少同一執行緒獲取鎖(會涉及到一些CAS操作,耗時)的代價而引入偏向鎖。
偏向鎖的核心思想是:如果一個執行緒獲得了鎖,那麼鎖就進入偏向模式,此時Mark Word 的結構也變為偏向鎖結構,當這個執行緒再次請求鎖時,無需再做任何同步操作,即獲取鎖的過程,這樣就省去了大量有關鎖申請的操作,從而也就提供程式的效能。
所以,對於沒有鎖競爭的場合,偏向鎖有很好的優化效果,畢竟極有可能連續多次是同一個執行緒申請相同的鎖。但是對於鎖競爭比較激烈的場合,偏向鎖就失效了,因為這樣場合極有可能每次申請鎖的執行緒都是不相同的,因此這種場合下不應該使用偏向鎖,否則會得不償失,需要注意的是,偏向鎖失敗後,並不會立即膨脹為重量級鎖,而是先升級為輕量級鎖。下面我們接著瞭解輕量級鎖。
3.2、輕量級鎖
倘若偏向鎖失敗,虛擬機器並不會立即升級為重量級鎖,它還會嘗試使用一種稱為輕量級鎖的優化手段(JDK1.6之後加入的)。此時Mark Word 的結構也變為輕量級鎖的結構。
輕量級鎖能夠提升程式效能的依據是“對絕大部分的鎖,在整個同步週期內都不存在競爭”,注意這是經驗資料。需要了解的是,輕量級鎖所適應的場景是執行緒交替執行同步塊的場合,如果存在同一時間訪問同一鎖的場合,就會導致輕量級鎖膨脹為重量級鎖。
3.3、自旋鎖
輕量級鎖失敗後,虛擬機器為了避免執行緒真實地在作業系統層面掛起,還會進行一項稱為自旋鎖的優化手段。
這是基於在大多數情況下,執行緒持有鎖的時間都不會太長,如果直接掛起作業系統層面的執行緒可能會得不償失,畢竟作業系統實現執行緒之間的切換時需要從使用者態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高。因此自旋鎖會假設在不久將來,當前的執行緒可以獲得鎖,因此虛擬機器會讓當前想要獲取鎖的執行緒做幾個空迴圈(這也是稱為自旋的原因)。一般不會太久,可能是50個迴圈或100迴圈,在經過若干次迴圈後,如果得到鎖,就順利進入臨界區。如果還不能獲得鎖,那就會將執行緒在作業系統層面掛起,這就是自旋鎖的優化方式,這種方式確實也是可以提升效率的。最後沒辦法也就只能升級為重量級鎖了。
3.4、鎖消除
消除鎖是虛擬機器另外一種鎖的優化,這種優化更徹底。Java虛擬機器在JIT編譯時(可以簡單理解為當某段程式碼即將第一次被執行時進行編譯,又稱即時編譯),通過對執行上下文的掃描,去除不可能存在共享資源競爭的鎖,通過這種方式消除沒有必要的鎖,可以節省毫無意義的請求鎖的時間。
如下,StringBuffer的append是一個同步方法,但是在add方法中的StringBuffer屬於一個區域性變數,並且不會被其他執行緒所使用,因此StringBuffer不可能存在共享資源競爭的情景,JVM會自動將其鎖消除。
public class StringBufferRemoveSync {
public void add(String str1, String str2) {
//StringBuffer是執行緒安全,由於sb只會在add方法中使用,不可能被其他執行緒引用
//因此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 < 100000; i++) {
rmsync.add("abc", "123");
}
}
}
4、關於synchronized 需要了解的關鍵點
4.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 < 10000; j++){
// this:當前例項物件鎖
synchronized (this) {
i++;
increase();
}
}
}
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();
t2.start();
t1.join();
t2.join();
System.out.println(i); // 輸出結果20000
}
}
執行結果:
正如程式碼所演示的,在獲取當前例項物件鎖後進入synchronized程式碼塊執行同步程式碼,並在程式碼塊中呼叫了當前例項物件的另外一個synchronized方法。再次請求當前例項鎖時,將被允許,進而執行方法體程式碼,這就是重入鎖最直接的體現。
需要特別注意另外一種情況,當子類繼承父類時,子類也是可以通過可重入鎖呼叫父類的同步方法。注意由於synchronized是基於monitor實現的,因此每次重入,monitor中的計數器仍會加1。
4.2、執行緒中斷與synchronized
4.2.1、執行緒中斷
正如“中斷”二字所表達的意義,線上程執行(run方法)中間打斷它,在Java中,提供了以下3個有關執行緒中斷的方法。
//中斷執行緒(例項方法)
public void Thread.interrupt();
//判斷執行緒是否被中斷(例項方法)
public boolean Thread.isInterrupted();
//判斷執行緒是否被中斷並清除當前中斷狀態(靜態方法)
public static boolean Thread.interrupted();
當一個執行緒處於被阻塞狀態或者試圖執行一個阻塞操作時,使用Thread.interrupt()
方式中斷該執行緒。注意此時將會丟擲一個InterruptedException的異常,同時中斷狀態將會被複位(由中斷狀態改為非中斷狀態),如下程式碼將演示該過程:
public class InterruptSleepThread {
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();
}
}
執行結果:
如上述程式碼所示,我們建立一個執行緒,並在執行緒中呼叫了sleep方法從而使用執行緒進入阻塞狀態。啟動執行緒後,呼叫執行緒例項物件的interrupt方法中斷阻塞異常,並丟擲InterruptedException異常,此時中斷狀態也將被複位。
這裡有些人可能會詫異,為什麼不用Thread.sleep(2000);而是用TimeUnit.SECONDS.sleep(2);其實原因很簡單,前者使用時並沒有明確的單位說明,而後者非常明確表達秒的單位,事實上後者的內部實現最終還是呼叫了Thread.sleep(2000);,但為了編寫的程式碼語義更清晰,建議使用TimeUnit.SECONDS.sleep(2);的方式,注意TimeUnit是個列舉型別。
除了阻塞中斷的情景,我們還可能會遇到處於執行期且非阻塞的狀態的執行緒,這種情況下,直接呼叫Thread.interrupt()中斷執行緒是不會得到任響應的,如下程式碼,將無法中斷非阻塞狀態下的執行緒:
public class InterruptedThread {
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(1);
t1.interrupt(); // 中斷
}
}
執行結果:
雖然我們呼叫了interrupt方法,但執行緒 t1 並未被中斷,因為處於非阻塞狀態的執行緒需要我們手動進行中斷檢測並結束程式,改進後代碼如下:
public class InterruptedThread {
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(1);
t1.interrupt(); // 中斷
}
}
執行結果:
我們在程式碼中使用了例項方法isInterrupted判斷執行緒是否已被中斷,如果被中斷將跳出迴圈以此結束執行緒,注意非阻塞狀態呼叫interrupt()並不會導致中斷狀態重置。
綜合所述,可以簡單總結一下中斷兩種情況:
1、當執行緒處於阻塞狀態或者試圖執行一個阻塞操作時,我們可以使用例項方法interrupt()進行執行緒中斷,執行中斷操作後將會丟擲interruptException異常(該異常必須捕捉無法向外丟擲)並將中斷狀態復位;
2、當執行緒處於執行狀態時,我們也可呼叫例項方法interrupt()進行執行緒中斷,但同時必須手動判斷中斷狀態,並編寫中斷執行緒的程式碼(其實就是結束run方法體的程式碼)。
有時我們在編碼時可能需要兼顧以上兩種情況,那麼就可以如下編寫:
public void run(){
try {
// 判斷當前執行緒是否已中斷,注意interrupted方法是靜態的,執行後會對中斷狀態進行復位
while (!Thread.interrupted()) {
TimeUnit.SECONDS.sleep(2);
}
} catch (InterruptedException e) {
// ...
}
}
4.2.2、中斷與synchronized
事實上執行緒的中斷操作對於正在等待獲取的鎖物件的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();
}
@Override
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(),但並不能中斷執行緒。
4.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方法呼叫的原因。
synchronized (obj) {
obj.wait();
obj.notify();
obj.notifyAll();
}
需要特別理解的一點是,與sleep方法不同的是wait方法呼叫完成後,執行緒將被暫停,但wait方法將會釋放當前持有的監視器鎖(monitor),直到有執行緒呼叫notify/notifyAll方法後方能繼續執行,而sleep方法只讓執行緒休眠並不釋放鎖。同時notify/notifyAll方法呼叫後,並不會馬上釋放監視器鎖,而是在相應的synchronized(){}/synchronized方法執行結束後才自動釋放鎖。
全文結束~~~
非常好的文章,很全面,講訴的也比較容易理解!值得多讀幾遍,細細揣摩!
多執行緒上篇:https://blog.csdn.net/pcwl1206/article/details/84837530
多執行緒下篇:https://blog.csdn.net/pcwl1206/article/details/84843170