前言
這篇博客,南國就Java併發編程經常會用到的synchronized關鍵字做一個深入的剖析。如果讀者對於Java併發的一些基礎知識沒有太多瞭解,歡迎查看我前段時間寫過的Java面試07——併發知識點彙總及源碼解析,在這篇博客中我對併發編程中的大部分知識有了一個基礎的概括和總結,適合剛入行Java或者準備面試的人羣進行復習。
這篇博客 ,結合網上其他大佬的技術文章 我主要就synchronized關鍵字進行詳細的闡述。在此特別緻敬以下博客的博主:
深入理解Java併發之synchronized實現原理
Java併發編程:Synchronized底層優化(偏向鎖、輕量級鎖)
Java併發編程:Synchronized及其實現原理
Synchronized的基本使用
Synchronized是Java中解決併發問題的一種最常用的方法,也是最簡單的一種方法。Synchronized的作用主要有三個:(1)確保線程互斥的訪問同步代碼(2)保證共享變量的修改能夠及時可見(3)有效解決重排序問題。從語法上講,Synchronized總共有三種用法:
(1)修飾普通方法
(2)修飾靜態方法
(3)修飾代碼塊
下面我們通過代碼來講解一下:
案例一
1.沒有同步的情況
package 併發多線程;
/**
* synchronized 關鍵字對普通方法的修飾作用
* @author xjh 2019.05.04
* 可通過不同的結果來查看 區別
*/
public class SynchronizedTest {
public void method1(){ //加synchronized關鍵字 即對類中的方法進行同步 加鎖
System.out.println("Method 1 start");
try {
System.out.println("Method 1 execute");
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 1 end");
}
public void method2(){
System.out.println("Method 2 start");
try {
System.out.println("Method 2 execute");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 2 end");
}
public static void main(String[] args) {
final SynchronizedTest test = new SynchronizedTest();
new Thread(new Runnable() {
@Override
public void run() {
test.method1();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
test.method2();
}
}).start();
}
}
執行結果 如下:
Method 1 start
Method 1 execute
Method 2 start
Method 2 execute
Method 2 end
Method 1 end
線程1和線程2同時進入執行狀態,線程2執行速度比線程1快,所以線程2先執行完成,這個過程中線程1和線程2是同時執行的。
2.synchronized對普通方法進行互斥同步
這裏也就是對上面的method1() 和method2()方法加上sybchronized關鍵字。
輸出結果:
Method 1 start
Method 1 execute
Method 1 end
Method 2 start
Method 2 execute
Method 2 end
可以很明顯的看出,線程2需要等待線程1執行完成後才能開始執行method2()。
3.靜態方法(類)同步
package 併發多線程;
/**
*synchronized對靜態方法的同步本質上是對類的同步(靜態方法本質上是屬於類的方法,而不是對象上的方法)
* 所以即使test和test2屬於不同的對象,但是它們都屬於SynchronizedTest類的實例,
* 所以也只能順序的執行method1和method2,不能併發執行。
* @author xjh 2019.05.04S
* 輸出結果爲:
*
*/
public class SynchronizedTest02 {
public static synchronized void method1(){
System.out.println("Method 1 start");
try {
System.out.println("Method 1 execute");
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 1 end");
}
public static synchronized void method2(){
System.out.println("Method 2 start");
try {
System.out.println("Method 2 execute");
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 2 end");
}
public static void main(String[] args) {
final SynchronizedTest02 test = new SynchronizedTest02();
final SynchronizedTest02 test2 = new SynchronizedTest02();
new Thread(new Runnable() {
@Override
public void run() {
test.method1();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
test2.method2();
}
}).start();
}
}
輸出結果:
Method 1 start
Method 1 execute
Method 1 end
Method 2 start
Method 2 execute
Method 2 end
我們知道 類中的靜態方法屬於類,而不再是隻屬於類中某個對象的單獨方法。所以,對類中靜態方法的同步實質上就是對類的同步,這裏即使test和test2屬於不同的對象,但是它們都屬於SynchronizedTest類的實例,所以也只能順序的執行method1和method2,不能併發執行。
4.代碼塊同步
package 併發多線程;
/**
* Synchronized 進行代碼塊同步
* @author xjh 2019.05.04
* 雖然線程1和線程2都進入了對應的方法開始執行,
* 但是線程2在進入同步塊之前,需要等待線程1中同步塊執行完成。
*/
public class SynchronizedTest03 {
public void method1(){
System.out.println("Method 1 start");
try {
synchronized (this) {
System.out.println("Method 1 execute");
Thread.sleep(3000);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 1 end");
}
public void method2(){
System.out.println("Method 2 start");
try {
synchronized (this) {
System.out.println("Method 2 execute");
Thread.sleep(1000);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 2 end");
}
public static void main(String[] args) {
SynchronizedTest03 test=new SynchronizedTest03();
new Thread(new Runnable() {
@Override
public void run() {
test.method1();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
test.method2();
}
}).start();
}
}
輸出結果:
Method 1 start
Method 1 execute
Method 2 start
Method 2 execute
Method 1 end
Method 2 end
雖然線程1和線程2都進入了對應的方法開始執行,但是線程2在進入同步塊之前,需要等待線程1中同步塊執行完成。
案例二
1.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<1000000;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);
}
}
輸出結果:2000000
分析:我們開啓兩個線程操作同一個共享資源即變量i,由於i++;操作並不具備原子性,該操作是先讀取值,然後寫回一個新值,相當於原來的值加上1,分兩步完成,如果第二個線程在第一個線程讀取舊值和寫回新值期間讀取i的域值,那麼第二個線程就會與第一個線程一起看到同一個值,並執行相同值的加1操作,這也就造成了線程安全失敗,因此對於increase方法必須使用synchronized修飾,以便保證線程安全。
此時我們應該注意到synchronized修飾的是實例方法increase,在這樣的情況下,當前線程的鎖便是實例對象instance,注意Java中的線程同步鎖可以是任意對象。從代碼執行結果來看確實是正確的,倘若我們沒有使用synchronized關鍵字,其最終輸出結果就很可能小於2000000,這便是synchronized關鍵字的作用。
這裏我們還需要意識到,當一個線程正在訪問一個對象的 synchronized 實例方法,那麼其他線程不能訪問該對象的其他 synchronized 方法,畢竟一個對象只有一把鎖,當一個線程獲取了該對象的鎖之後,其他線程無法獲取該對象的鎖,所以無法訪問該對象的其他synchronized實例方法,但是其他線程還是可以訪問該實例對象的其他非synchronized方法。
當然如果是一個線程 A 需要訪問實例對象 obj1 的 synchronized 方法 f1(當前對象鎖是obj1),另一個線程 B 需要訪問實例對象 obj2 的 synchronized 方法 f2(當前對象鎖是obj2),這樣是允許的,因爲兩個實例對象鎖並不同相同,此時如果兩個線程操作數據並非共享的,線程安全是有保障的,遺憾的是如果兩個線程操作的是共享數據,那麼線程安全就有可能無法保證了,如下所示:
//我們把上面的代碼略微做下修改,在main()實例化兩個不同的對象
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);
}
}
輸出結果:1402768
注意這個結果是不唯一的,即每次執行都有可能不同 但都會小於2000。雖然我們使用synchronized修飾了increase方法,但卻new了兩個不同的實例對象,這也就意味着存在着兩個不同的實例對象鎖,因此t1和t2都會進入各自的對象鎖,也就是說t1和t2線程使用的是不同的鎖,因此線程安全是無法保證的。
解決這種困境的的方式是將synchronized作用於靜態的increase方法,這樣對象鎖就當前類對象,由於無論創建多少個實例對象,但對於的類對象擁有隻有一個,所有在這樣的情況下對象鎖就是唯一的。代碼如下所示:
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();
t1.join();t2.join();
System.out.println(i);
}
}
輸出結果:2000000
由於synchronized關鍵字修飾的是靜態increase方法,與修飾實例方法不同的是,其鎖對象是當前類的class對象。注意代碼中的increase4Obj方法是實例方法,其對象鎖是當前實例對象,如果別的線程調用該方法,將不會產生互斥現象,畢竟鎖對象不同,但我們應該意識到這種情況下可能會發現線程安全問題(操作了共享靜態變量i)。
synchronized底層原理
Java 虛擬機中的同步(Synchronization)基於進入和退出管程(Monitor)對象實現, 無論是顯式同步(有明確的 monitorenter 和 monitorexit 指令,即同步代碼塊),還是隱式同步都是如此。
在 Java 語言中,同步用的最多的地方可能是被 synchronized 修飾的同步方法。同步方法 並不是由 monitorenter 和 monitorexit 指令來實現同步的,而是由方法調用指令讀取運行時常量池中方法的 ACC_SYNCHRONIZED 標誌來隱式實現的,關於這點,稍後詳細分析。下面先來了解一個概念Java對象頭,這對深入理解synchronized實現原理非常關鍵。
理解Java對象頭與Monitor
在JVM中,對象在內存中的佈局分爲三塊區域:對象頭、實例數據和對齊填充。如下:
實例變量:存放類的屬性數據信息,包括父類的屬性信息,如果是數組的實例部分還包括數組的長度,這部分內存按4字節對齊。
填充數據:由於虛擬機要求對象起始地址必須是8字節的整數倍。填充數據不是必須存在的,僅僅是爲了字節對齊,這點了解即可。
而對於頂部,則是Java頭對象,它實現synchronized的鎖對象的基礎,這點我們重點分析它,一般而言,synchronized使用的鎖對象是存儲在Java對象頭裏的,jvm中採用2個字來存儲對象頭(如果對象是數組則會分配3個字,多出來的1個字記錄的是數組長度),其主要結構是由Mark Word 和 Class Metadata Address 組成,其結構說明如下表:
其中輕量級鎖和偏向鎖是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 ;
}
首先解釋一下幾個核心組件的概念:
- Wait Set:哪些調用wait方法被阻塞的線程被放置在這裏;
- Contention List:競爭隊列,所有請求鎖的線程首先被放在這個競爭隊列中;
- Entry List:Contention List中那些有資格成爲候選資源的線程被移動到Entry List中;
- Owner:當前已經獲取到所資源的線程被稱爲Owner;
ObjectMonitor中有兩個隊列,_WaitSet 和 _EntryList,用來保存ObjectWaiter對象列表( 每個等待鎖的線程都會被封裝成ObjectWaiter對象),_owner指向持有ObjectMonitor對象的線程。
當多個線程同時訪問一段同步代碼時,首先會進入 _EntryList 集合,當線程獲取到對象的monitor 後進入 _Owner 區域並把monitor中的owner變量設置爲當前線程同時monitor中的計數器count加1,若線程調用 wait() 方法,將釋放當前持有的monitor,owner變量恢復爲null,count自減1。同時該線程進入 WaitSe t集合中等待被喚醒。若當前線程執行完畢也將釋放monitor(鎖)並復位變量的值,以便其他線程進入獲取monitor(鎖)。
synchronized同步代碼塊底層原理
我們回到前面案例一 synchronized代碼塊的代碼:
.....
public void method1(){
System.out.println("Method 1 start");
try {
synchronized (this) {
System.out.println("Method 1 execute");
Thread.sleep(3000);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 1 end");
}
.....
反編譯後的結果:
從字節碼中可知同步語句塊的實現使用的是monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步代碼塊的開始位置,monitorexit指令則指明同步代碼塊的結束位置。
1.monitorenter
每個對象有一個監視器鎖(monitor)。當monitor被佔用時就會處於鎖定狀態,線程執行monitorenter指令時嘗試獲取monitor的所有權,過程如下:
1、如果monitor的進入數爲0,則該線程進入monitor,然後將進入數設置爲1,該線程即爲monitor的所有者。
2、如果線程已經佔有該monitor,只是重新進入,則進入monitor的進入數加1.
3.如果其他線程已經佔用了monitor,則該線程進入阻塞狀態,直到monitor的進入數爲0,再重新嘗試獲取monitor的所有權。
2.monitorexit
執行monitorexit的線程必須是objectref所對應的monitor的所有者。
指令執行時,monitor的進入數減1,如果減1後進入數爲0,那線程退出monitor,不再是這個monitor的所有者。其他被這個monitor阻塞的線程可以嘗試去獲取這個 monitor 的所有權。
通過上面描述,我們應該能很清楚的看出Synchronized的實現原理,Synchronized的語義底層是通過一個monitor的對象來完成,其實wait/notify等方法也依賴於monitor對象,這就是爲什麼只有在同步的塊或者方法中才能調用wait/notify等方法,否則會拋出java.lang.IllegalMonitorStateException的異常的原因。
synchronized同步方法底層原理
....
public class SynchronizedMethod {
public synchronized void method() {
System.out.println("Hello World!");
}
....
反編譯後的結果:
從上圖看出,方法的同步並沒有通過指令monitorenter和monitorexit來完成(理論上其實也可以通過這兩條指令來實現),而是其常量池中多了ACC_SYNCHRONIZED標示符。JVM就是根據該標示符來實現方法的同步的:當方法調用時,調用指令將會檢查方法的 ACC_SYNCHRONIZED 訪問標誌是否被設置,如果設置了,執行線程將先獲取monitor,獲取成功之後才能執行方法體,方法執行完後再釋放monitor。在方法執行期間,其他任何線程都無法再獲得同一個monitor對象。 其實本質上沒有區別,只是方法的同步是一種隱式的方式來實現,無需通過字節碼來完成。
我們注意到在Java早期版本中,synchronized屬於重量級鎖,效率低下,因爲監視器鎖(monitor)是依賴於底層的操作系統的Mutex Lock來實現的,而操作系統實現線程之間的切換時需要從用戶態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,這也是爲什麼早期的synchronized效率低的原因。慶幸的是在Java 6之後Java官方對從JVM層面對synchronized較大優化,所以現在的synchronized鎖效率也優化得很不錯了,Java 6之後,爲了減少獲得鎖和釋放鎖所帶來的性能消耗,引入了輕量級鎖和偏向鎖,接下來我們將簡單瞭解一下Java官方在JVM層面對synchronized鎖的優化。
JVM 對 synchronized 的優化
鎖的狀態總共有四種,無鎖狀態、偏向鎖、輕量級鎖和重量級鎖。隨着鎖的競爭,鎖可以從偏向鎖升級到輕量級鎖,再升級的重量級鎖,但是鎖的升級是單向的,也就是說只能從低到高升級,不會出現鎖的降級,關於重量級鎖,前面我們已詳細分析過,下面我們將介紹偏向鎖和輕量級鎖以及JVM的其他優化手段,這裏並不打算深入到每個鎖的實現和轉換過程更多地是闡述Java虛擬機所提供的每個鎖的核心優化思想,畢竟涉及到具體過程比較繁瑣,如需瞭解詳細過程可以查閱《深入理解Java虛擬機原理》。
輕量級鎖
“輕量級”是相對於使用操作系統互斥量來實現的傳統鎖而言的。但是,首先需要強調一點的是,輕量級鎖並不是用來代替重量級鎖的,它的本意是在沒有多線程競爭的前提下,減少傳統的重量級鎖使用產生的性能消耗。在解釋輕量級鎖的執行過程之前,先明白一點,輕量級鎖所適應的場景是線程交替執行同步塊的情況,如果存在同一時間訪問同一鎖的情況,就會導致輕量級鎖膨脹爲重量級鎖。
1、輕量級鎖的加鎖過程
(1)在代碼進入同步塊的時候,如果同步對象鎖狀態爲無鎖狀態(鎖標誌位爲“01”狀態,是否爲偏向鎖爲“0”),虛擬機首先將在當前線程的棧幀中建立一個名爲鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的Mark Word的拷貝,官方稱之爲 Displaced Mark Word。這時候線程堆棧與對象頭的狀態如下圖所示。
(2)拷貝對象頭中的Mark Word複製到鎖記錄中。
(3)拷貝成功後,虛擬機將使用CAS操作嘗試將對象的Mark Word更新爲指向Lock Record的指針,並將Lock record裏的owner指針指向object mark word。如果更新成功,則執行步驟(3),否則執行步驟(4)。
(4)如果這個更新動作成功了,那麼這個線程就擁有了該對象的鎖,並且對象Mark Word的鎖標誌位設置爲“00”,即表示此對象處於輕量級鎖定狀態,這時候線程堆棧與對象頭的狀態如下圖所示。
(5)如果這個更新操作失敗了,虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧幀,如果是就說明當前線程已經擁有了這個對象的鎖,那就可以直接進入同步塊繼續執行。否則說明多個線程競爭鎖,輕量級鎖就要膨脹爲重量級鎖,鎖標誌的狀態值變爲“10”,Mark Word中存儲的就是指向重量級鎖(互斥量)的指針,後面等待鎖的線程也要進入阻塞狀態。 而當前線程便嘗試使用自旋來獲取鎖,自旋就是爲了不讓線程阻塞,而採用循環去獲取鎖的過程。
2、輕量級鎖的解鎖過程:
(1)通過CAS操作嘗試把線程中複製的Displaced Mark Word對象替換當前的Mark Word。
(2)如果替換成功,整個同步過程就完成了。
(3)如果替換失敗,說明有其他線程嘗試過獲取該鎖(此時鎖已膨脹),那就要在釋放鎖的同時,喚醒被掛起的線程。
偏向鎖
引入偏向鎖是爲了在無多線程競爭的情況下儘量減少不必要的輕量級鎖執行路徑,因爲輕量級鎖的獲取及釋放依賴多次CAS原子指令,而偏向鎖只需要在置換ThreadID的時候依賴一次CAS原子指令(由於一旦出現多線程競爭的情況就必須撤銷偏向鎖,所以偏向鎖的撤銷操作的性能損耗必須小於節省下來的CAS原子指令的性能消耗)。上面說過,輕量級鎖是爲了在線程交替執行同步塊時提高性能,而偏向鎖則是在只有一個線程執行同步塊時進一步提高性能。
1、偏向鎖獲取過程:
(1)訪問Mark Word中偏向鎖的標識是否設置成1,鎖標誌位是否爲01——確認爲可偏向狀態。
(2)如果爲可偏向狀態,則測試線程ID是否指向當前線程,如果是,進入步驟(5),否則進入步驟(3)。
(3)如果線程ID並未指向當前線程,則通過CAS操作競爭鎖。如果競爭成功,則將Mark Word中線程ID設置爲當前線程ID,然後執行(5);如果競爭失敗,執行(4)。
(4)如果CAS獲取偏向鎖失敗,則表示有競爭。當到達全局安全點(safepoint)時獲得偏向鎖的線程被掛起,偏向鎖升級爲輕量級鎖,然後被阻塞在安全點的線程繼續往下執行同步代碼。
(5)執行同步代碼。
2、偏向鎖的釋放:
偏向鎖的撤銷在上述第四步驟中有提到。偏向鎖只有遇到其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程纔會釋放鎖,線程不會主動去釋放偏向鎖。偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有字節碼正在執行),它會首先暫停擁有偏向鎖的線程,判斷鎖對象是否處於被鎖定狀態,撤銷偏向鎖後恢復到未鎖定(標誌位爲“01”)或輕量級鎖(標誌位爲“00”)的狀態。
3、重量級鎖、輕量級鎖和偏向鎖之間轉換
其他優化
1、適應性自旋(Adaptive Spinning):從輕量級鎖獲取的流程中我們知道,當線程在獲取輕量級鎖的過程中執行CAS操作失敗時,是要通過自旋來獲取重量級鎖的。問題在於,自旋是需要消耗CPU的,如果一直獲取不到鎖的話,那該線程就一直處在自旋狀態,白白浪費CPU資源。解決這個問題最簡單的辦法就是指定自旋的次數,例如讓其循環10次,如果還沒獲取到鎖就進入阻塞狀態。但是JDK採用了更聰明的方式——適應性自旋,簡單來說就是線程如果自旋成功了,則下次自旋的次數會更多,如果自旋失敗了,則自旋的次數就會減少。
2、鎖粗化(Lock Coarsening):鎖粗化的概念應該比較好理解,就是將多次連接在一起的加鎖、解鎖操作合併爲一次,將多個連續的鎖擴展成一個範圍更大的鎖。舉個例子:
package com.paddx.test.string;
public class StringBufferTest {
StringBuffer stringBuffer = new StringBuffer();
public void append(){
stringBuffer.append("a");
stringBuffer.append("b");
stringBuffer.append("c");
}
}
這裏每次調用stringBuffer.append方法都需要加鎖和解鎖,如果虛擬機檢測到有一系列連串的對同一個對象加鎖和解鎖操作,就會將其合併成一次範圍更大的加鎖和解鎖操作,即在第一次append方法時進行加鎖,最後一次append方法結束後進行解鎖。
3、鎖消除(Lock Elimination):鎖消除即刪除不必要的加鎖操作。根據代碼逃逸技術,如果判斷到一段代碼中,堆上的數據不會逃逸出當前線程,那麼可以認爲這段代碼是線程安全的,不必要加鎖。看下面這段程序:
package com.paddx.test.concurrent;
public class SynchronizedTest02 {
public static void main(String[] args) {
SynchronizedTest02 test02 = new SynchronizedTest02();
//啓動預熱
for (int i = 0; i < 10000; i++) {
i++;
}
long start = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
test02.append("abc", "def");
}
System.out.println("Time=" + (System.currentTimeMillis() - start));
}
public void append(String str1, String str2) {
StringBuffer sb = new StringBuffer();
sb.append(str1).append(str2);
}
}
雖然StringBuffer的append是一個同步方法,但是這段程序中的StringBuffer屬於一個局部變量,並且不會從該方法中逃逸出去,所以其實這過程是線程安全的,可以將鎖消除。
JDk中採用輕量級鎖和偏向鎖等對Synchronized的優化,但是這兩種鎖也不是完全沒缺點的,比如競爭比較激烈的時候,不但無法提升效率,反而會降低效率,因爲多了一個鎖升級的過程,這個時候就需要通過-XX:-UseBiasedLocking來禁用偏向鎖。下面是這幾種鎖的對比:
synchronized的一些重要特性
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<1000000;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();t2.start();
t1.join();t2.join();
System.out.println(i);
}
}
正如代碼所演示的,在獲取當前實例對象鎖後進入synchronized代碼塊執行同步代碼,並在代碼塊中調用了當前實例對象的另外一個synchronized方法,再次請求當前實例鎖時,將被允許,進而執行方法體代碼,這就是重入鎖最直接的體現,需要特別注意另外一種情況,當子類繼承父類時,子類也是可以通過可重入鎖調用父類的同步方法。注意由於synchronized是基於monitor實現的,因此每次重入,monitor中的計數器仍會加1。
線程中斷與synchronized
首先,我們講講Interrupt()結束線程:
中斷
Java中提供了以下3個有關線程中斷的方法:
//中斷線程(實例方法)
public void Thread.interrupt();
//判斷線程是否被中斷(實例方法)
public boolean Thread.isInterrupted();
//判斷是否被中斷並清除當前中斷狀態(靜態方法)
public static boolean Thread.interrupted();
當一個線程處於被阻塞狀態或者試圖執行一個阻塞操作時,使用Thread.interrupt()方式中斷該線程,注意此時將會拋出一個InterruptedException的異常,同時中斷狀態將會被複位(由中斷狀態改爲非中斷狀態),如下代碼將演示該過程:
package 併發多線程;
import java.util.concurrent.TimeUnit;
/**
* 線程中斷
* @author xjh 2019.05.04
*/
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) {
System.out.println("正常運行中1...");
//當前線程處於阻塞狀態,異常必須捕捉處理,無法往外拋出
TimeUnit.SECONDS.sleep(2);
System.out.println("正常運行中2...");
}
} catch (InterruptedException e) {
System.out.println("Interruted When Sleep");
boolean interrupt = this.isInterrupted();
//中斷狀態被複位
System.out.println("interrupt:"+interrupt);
}
}
};
t1.start(); //首次start()啓動線程,此時線程處於就緒狀態,並沒有運行
TimeUnit.SECONDS.sleep(2); //
//中斷處於阻塞狀態的線程
t1.interrupt();
}
}
/**
* 輸出結果:
正常運行中1...
正常運行中2...
正常運行中1...
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方法或者代碼塊並不起作用,也就是對於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();但並不能中斷線程。
等待喚醒與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方法執行結束後才自動釋放鎖。