synchronized使用及原理解析
修飾靜態方法、例項方法、程式碼塊
Synchronized修飾靜態方法,對類物件進行加鎖,是類鎖。
Synchronized修飾例項方法,對方法所屬物件進行加鎖,是物件鎖。
Synchronized修飾程式碼塊時,對一段程式碼塊進行加鎖,是物件鎖。
/** * synchronized示例 * 1、修飾靜態方法 * 2、修飾例項方法 * 3、修飾程式碼塊 */ public class SyncDemo2 { private static int num = 0; /** * 修飾靜態方法 */ public static synchronizedvoid count1() { for (int i = 0; i < 100000000; i++) { num++; } } /** * 修飾例項方法 */ public synchronized void count2() { for (int i = 0; i < 100000000; i++) { num++; } } /** * 修飾程式碼塊 * 效果與修飾靜態方法相同 */ public void count3() {synchronized(SyncDemo2.class) { for (int i = 0; i < 100000000; i++) { num++; } } } /** * 修飾程式碼塊 * 效果與修飾例項方法相同 */ public void count4() { synchronized(this) { for (int i = 0; i < 100000000; i++) { num++; } } }public static void main(String[] args) { //兩個執行緒執行一個類的兩個物件,執行類的靜態方法count1, //產生同步,num=200000000 //兩個執行緒執行一個類的兩個物件,執行類的例項方法count2 //因為呼叫的是不同的物件,並未產生同步,num<=200000000 SyncDemo2 syncDemo1 = new SyncDemo2(); SyncDemo2 syncDemo2 = new SyncDemo2(); //兩個執行緒執行一個物件,執行類的例項方法count2 //因為呼叫的是同一個物件,產生同步,num=200000000 //SyncDemo2 syncDemo3 = new SyncDemo2(); //syncDemo1 = syncDemo3; //syncDemo2 = syncDemo3; //啟動兩個執行緒進行運算 Thread thread1 = new Thread(new ThreadDemo(syncDemo1)); Thread thread2 = new Thread(new ThreadDemo(syncDemo2)); thread1.start(); thread2.start(); try { thread1.join(); thread2.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(SyncDemo2.num); } } class ThreadDemo implements Runnable { SyncDemo2 syncDemo2; public ThreadDemo(SyncDemo2 syncDemo2){ this.syncDemo2 = syncDemo2; } @Override public void run() { //syncDemo2.count1(); //syncDemo2.count2(); syncDemo2.count3(); //syncDemo2.count4(); } }
Synchronized底層實現原理
Java 虛擬機器中的同步(Synchronization)基於進入和退出管程(Monitor)物件實現,無論是顯式同步(有明確的 monitorenter 和 monitorexit 指令,即同步程式碼塊)還是隱式同步都是如此。在 Java 語言中,同步用的最多的地方可能是被 synchronized 修飾的同步方法。同步方法並不是由 monitorenter 和 monitorexit 指令來實現同步的,而是由方法呼叫指令讀取執行時常量池中方法的 ACC_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預設儲存結構外,還有如下可能變化的結構:
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 ; }
結構中幾個重要的欄位要關注,_count、_owner、_EntryList、_WaitSet。
count用來記錄執行緒進入加鎖程式碼的次數。
owner記錄當前持有鎖的執行緒,即持有ObjectMonitor物件的執行緒。
EntryList是想要持有鎖的執行緒的集合。
WaitSet 是加鎖物件呼叫wait()方法後,等待被喚醒的執行緒的集合。
每個等待鎖的執行緒都會被封裝成ObjectWaiter物件,當多個執行緒同時訪問一段同步程式碼(臨界區)時,首先會進入 _EntryList 集合,當執行緒獲取到物件的monitor 後進入 _Owner 區域並把monitor中的owner變數設定為當前執行緒,_owner指向持有ObjectMonitor物件的執行緒。同時monitor中的計數器count加1。
若執行緒呼叫 wait() 方法,將釋放當前持有的monitor,owner變數恢復為null,count自減1,同時該執行緒進入 WaitSet集合中等待被喚醒。
若當前執行緒執行完畢也將釋放monitor並復位變數的值,以便其他執行緒進入獲取monitor(鎖)。
(圖摘自:https://blog.csdn.net/javazejian/article/details/72828483)
Synchronized在jvm位元組碼上的體現
我們以之前的例子為例,使用javac編譯程式碼,然後使用javap進行反編譯。
反編譯後部分片段如下圖:
對於使用synchronized修飾的方法,反編譯後位元組碼中會有ACC_SYNCHRONIZED關鍵字。
而synchronized修飾的程式碼塊中,在程式碼塊的前後會有monitorenter、monitorexit關鍵字,此處的位元組碼中有兩個monitorexit是因為我們有try-catch語句塊,有兩個出口。
Synchronized與等待喚醒
等待喚醒是指呼叫物件的wait、notify、notifyAll方法。呼叫這三個方法時,物件必須被synchronized修飾,因為這三個方法在執行時,必須獲得當前物件的監視器monitor物件。
另外,與sleep方法不同的是wait方法呼叫完成後,執行緒將被暫停,但wait方法將會釋放當前持有的監視器鎖(monitor),直到有執行緒呼叫notify/notifyAll方法後方能繼續執行。而sleep方法只讓執行緒休眠並不釋放鎖。notify/notifyAll方法呼叫後,並不會馬上釋放監視器鎖,而是在相應的synchronized程式碼塊或synchronized方法執行結束後才自動釋放鎖。
Synchronized的可重入與中斷
重入
當多個執行緒請求同一個臨界資源,執行到同一個臨界區時會產生互斥,未獲得資源的執行緒會阻塞。而當一個已獲得臨界資源的執行緒再次請求此資源時並不會發生阻塞,仍能獲取到資源、進入臨界區,這就是重入。Synchronized是可重入的。
中斷
在Thread類中與執行緒中斷相關的類有三個:
/** * Interrupt設定一個執行緒為中斷狀態 * Interrupt操作的執行緒處於sleep,wait,join 阻塞等狀態的時候,清除“中斷”狀態,丟擲一個InterruptedException * Interrupt操作的執行緒在可中斷通道上因呼叫某個阻塞的 I/O 操作(serverSocketChannel. accept()、socketChannel.connect、socketChannel.open、 * socketChannel.read、socketChannel.write、fileChannel.read、fileChannel.write),會丟擲一個ClosedByInterruptException **/ public void interrupt(); /** * 判斷執行緒是否處於“中斷”狀態,然後將“中斷”狀態清除 **/ public static boolean interrupted(); /** * 判斷執行緒是否處於“中斷”狀態 **/ public boolean isInterrupted();
在實際使用中,當執行緒正處於呼叫sleep、wait、join方法後,呼叫interrupt會清除執行緒中斷狀態,並丟擲異常。而當執行緒已進入臨界區、正在執行,則需要isInterrupted()或interrupted()與interrupt()配合使用中斷執行中的執行緒。
Sychronized修飾的方法、程式碼塊被多個執行緒請求時,呼叫中斷。正在執行的執行緒響應中斷。正在阻塞的執行緒、執行中的執行緒都會標記中斷狀態,但阻塞的執行緒不會立刻處理中斷,而是在進入臨界區後再響應。
示例:中斷對執行synchronized方法執行緒的影響
import java.util.concurrent.TimeUnit; /** * 示例:中斷對執行synchronized方法執行緒的影響 * 正在執行的執行緒響應中斷 * 正在阻塞的執行緒、執行中的執行緒都會標記中斷狀態, * 但阻塞的執行緒不會立刻處理中斷,而是在進入臨界區後再響應。 */ public class SyncDemo3 { public static boolean flag = true; public static synchronized void m1() { System.out.println(Thread.currentThread().getName() + " hold resource!"); while (flag) { if (!Thread.currentThread().isInterrupted()) { //不用sleep,因為sleep會對中斷丟擲異常 Thread.yield(); } else { System.out.println(Thread.currentThread().getName() + " interrupted and release !"); return; } } } public static void main(String[] args) { SyncDemo3 syncDemo1 = new SyncDemo3(); SyncDemo3 syncDemo2 = new SyncDemo3(); //啟動兩個執行緒 Thread thread1 = new Thread(new ThreadDemo3(syncDemo1), "thread1"); Thread thread2 = new Thread(new ThreadDemo3(syncDemo2), "thread2"); thread1.start(); //休眠1秒,讓thread1獲取資源 try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } thread2.start(); //休眠1秒 try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } //thread1中斷 thread1.interrupt(); //thread2中斷 thread2.interrupt(); if (thread1.isInterrupted()) { System.out.println("thread1 interrupt!"); } if (thread2.isInterrupted()) { System.out.println("thread2 interrupt!"); } //休眠1秒,讓thread2獲取資源 try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } } } class ThreadDemo3 implements Runnable { SyncDemo3 syncDemo3; public ThreadDemo3(SyncDemo3 syncDemo3) { this.syncDemo3 = syncDemo3; } @Override public void run() { syncDemo3.m1(); } }
JDK6對Synchronized的優化
在JDK6以前synchronized的效能並不高,但在之後進行了優化,我們在之前的Mark Word的結構中可以看到,鎖的狀態總共有四種,無鎖狀態、偏向鎖、輕量級鎖和重量級鎖。隨著鎖的競爭,鎖可以從偏向鎖升級到輕量級鎖,再升級的重量級鎖,但是鎖的升級是單向的,也就是說只能從低到高升級,不會出現鎖的降級。
偏向鎖
偏向鎖是Java 6之後加入的新鎖,它是一種針對加鎖操作的優化手段。經過研究發現,在大多數情況下,鎖不僅不存在多執行緒競爭,而且總是由同一執行緒多次獲得,因此為了減少同一執行緒獲取鎖(會涉及到一些CAS操作,耗時)的代價而引入偏向鎖。
偏向鎖的核心思想是,如果一個執行緒獲得了鎖,那麼鎖就進入偏向模式,此時Mark Word 的結構也變為偏向鎖結構,當這個執行緒再次請求鎖時,無需再做任何同步操作,即獲取鎖的過程,這樣就省去了大量有關鎖申請的操作,從而也就提供程式的效能。
所以,對於沒有鎖競爭的場合,偏向鎖有很好的優化效果,畢竟極有可能連續多次是同一個執行緒申請相同的鎖。但是對於鎖競爭比較激烈的場合,偏向鎖就失效了,因為這樣場合極有可能每次申請鎖的執行緒都是不相同的,因此這種場合下不應該使用偏向鎖,否則會得不償失。但偏向鎖失敗後,並不會立即膨脹為重量級鎖,而是先升級為輕量級鎖。
輕量級鎖
若偏向鎖失敗,虛擬機器並不會立即升級為重量級鎖,它還會嘗試使用一種稱為輕量級鎖的優化手段,此時Mark Word 的結構也變為輕量級鎖的結構。輕量級鎖能夠提升程式效能的依據是“對絕大部分的鎖,在整個同步週期內都不存在競爭”,注意這是經驗資料。需要了解的是,輕量級鎖所適應的場景是執行緒交替執行同步塊的場合,如果存在同一時間訪問同一鎖的場合,就會導致輕量級鎖膨脹為重量級鎖。
自旋鎖
輕量級鎖失敗後,虛擬機器為了避免執行緒真實地在作業系統層面掛起,還會進行一項稱為自旋鎖的優化手段。這是基於在大多數情況下,執行緒持有鎖的時間都不會太長,如果直接掛起作業系統層面的執行緒可能會得不償失,畢竟作業系統實現執行緒之間的切換時需要從使用者態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,因此自旋鎖會假設在不久將來,當前的執行緒可以獲得鎖,因此虛擬機器會讓當前想要獲取鎖的執行緒做幾個空迴圈(這也是稱為自旋的原因),一般不會太久,可能是50個迴圈或100迴圈,在經過若干次迴圈後,如果得到鎖,就順利進入臨界區。如果還不能獲得鎖,那就會將執行緒在作業系統層面掛起,這就是自旋鎖的優化方式,這種方式確實也是可以提升效率的。最後沒辦法也就只能升級為重量級鎖了。
鎖消除
消除鎖是虛擬機器另外一種鎖的優化,這種優化更徹底,Java虛擬機器在JIT編譯時(可以簡單理解為當某段程式碼即將第一次被執行時進行編譯,又稱即時編譯),通過對執行上下文的掃描,去除不可能存在共享資源競爭的鎖,通過這種方式消除沒有必要的鎖,可以節省毫無意義的請求鎖時間。
鎖粗化
如果虛擬機器探測到有這樣一串零碎的操作都對同一個物件加鎖,將會把加鎖同步的範圍擴充套件到整個操作序列的外部,這樣就只需要加鎖一次就夠了。
參考:
《實戰Java高併發程式設計》 葛一鳴,郭超 著
https://blog.csdn.net/javazejian/article/details/72828483