文章目錄
- synchronized
- 使用示例
- 實現原理
- 鎖的升級
- synchronized與可見性
- synchronized與原子性
- synchronized與有序性
synchronized
synchronized
是Java提供的關鍵字譯為同步,是Java中用于實現線程同步的一種機制。它可以確保在同一時間只有一個線程能夠執行某段代碼,從而避免線程安全問題。當它修飾一個方法或者一個代碼塊的時候,同一時刻最多只有一個線程執行這段代碼。synchronized
關鍵字在需要原子性、可見性和有序性這三種特性的時候都可以作為其中一種解決方案,大部分并發控制操作都能使用synchronized
來完成。
synchronized
的作用:
- 互斥性:確保在同一時間只有一個線程可以執行被
synchronized
修飾的代碼塊或方法。 - 可見性:當一個線程退出
synchronized
代碼塊時,它所做的所有修改對于進入synchronized
代碼塊的其他線程是可見的。這是通過釋放和獲得監視器鎖來實現的。
使用示例
修飾的對象 | 作用范圍 | 作用對象 |
---|---|---|
同步一個實例方法 | 整個實例方法 | 調用此方法的對象 |
同步一個靜態方法 | 整個靜態方法 | 此類的所有對象 |
同步代碼塊-對象 | 整個代碼塊 | 調用此代碼塊的對象 |
同步代碼塊-類 | 整個代碼塊 | 此類的所有對象 |
- 同步一個實例方法。在這種情況下,
increment
方法被聲明為同步方法。當一個線程調用這個方法時,它會獲得該實例的監視器鎖,其他線程必須等待這個線程釋放鎖后才能調用這個方法。public synchronized void increment() {count++; }
- 同步一個靜態方法。當
synchronized
作用于靜態方法時,其鎖就是當前類的class
對象鎖。由于靜態成員不專屬于任何一個實例對象,而是類成員,因此通過class
對象鎖可以控制靜態成員的并發操作。public static synchronized void increment() {count++; }
- 同步代碼塊。在某些情況下,我們編寫的方法體可能比較大,同時存在一些比較耗時的操作,而需要同步的代碼又只有一小部分,如果直接對整個方法進行同步操作,這樣做就有點浪費。此時我們可以使用同步代碼塊的方式對需要同步的代碼進行包裹。
除了使用public void increment() {synchronized (this) {count++;} }
synchronized(this)
鎖定,當然靜態方法是沒有this對象的,也可以使用class
對象來做為鎖。public void increment() {synchronized (MainTest.class) {count++;} }
當如果沒有明確的對象作為鎖,只是想讓一段代碼同步時,可以創建一個特殊的對象來充當鎖。
private byte[] lock = new byte[0];
public void method(){synchronized(lock) {// .....}
}
零長度的byte
數組對象創建起來將比任何對象都經濟。查看編譯后的字節碼,生成零長度的byte[]
對象只需3條操作碼,而Object lock = new Object()
則需要7行操作碼。
byte[] emptyArray = new byte[0];0: iconst_0 // 將常量0推送到棧頂
1: newarray byte // 創建一個新的byte類型數組
3: astore_1 // 將引用類型的數據存儲到局部變量表中
Object lock = new Object();0: new #2 // 創建一個新的對象
3: dup // 復制棧頂的操作數棧頂的值,并將復制值壓入棧頂
4: invokespecial #1 // 調用實例初始化方法, 使用Object.<init>
7: astore_1 // 將引用類型的數據存儲到局部變量表中
實現原理
synchronized
關鍵字在Java中通過進入和退出一個監視器來實現同步。監視器本質上是一種鎖,它可以是類對象鎖或實例對象鎖。每個對象在JVM中都有一個與之關聯的監視器。當一個線程進入同步代碼塊或方法時,它會嘗試獲得對象的監視器。如果成功獲得鎖,線程就可以執行同步代碼;否則它將被阻塞,直到獲得鎖為止。
在Java中synchronized
鎖對象時,其實就是改變對象中的對象頭的markword
的鎖的標志位來實現的。用javap -v MainTest.class
命令反編譯下面代碼。
public class MainTest {synchronized void demo01() {System.out.println("demo 01");}void demo02() {synchronized (MainTest.class) {System.out.println("demo 02");}}}
synchronized void demo01();descriptor: ()Vflags: ACC_SYNCHRONIZEDCode:stack=2, locals=1, args_size=10: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;3: ldc #3 // String demo 015: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V8: return
// ...
void demo02();descriptor: ()Vflags:Code:stack=2, locals=3, args_size=10: ldc #5 // class content/posts/rookie/MainTest2: dup3: astore_14: monitorenter5: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;8: ldc #6 // String demo 0210: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V13: aload_114: monitorexit15: goto 2318: astore_219: aload_120: monitorexit21: aload_222: athrow23: return
// ...
通過反編譯后代碼可以看出:
- 對于同步方法,JVM采用
ACC_SYNCHRONIZED
標記符來實現同步; - 對于同步代碼塊,JVM采用
monitorenter
、monitorexit
兩個指令來實現同步;
其中同步代碼塊,有兩個monitorexit
指令的原因是為了保證拋異常的情況下也能釋放鎖,所以javac
為同步代碼塊添加了一個隱式的try-finally
,在finally
中會調用monitorexit
命令釋放鎖。
官方文檔中關于同步方法和同步代碼塊的實現原理描述:
方法級的同步是隱式的。同步方法的常量池中會有一個
ACC_SYNCHRONIZED
標志。當某個線程要訪問某個方法的時候,會檢查是否有ACC_SYNCHRONIZED
,如果有設置,則需要先獲得監視器鎖,然后開始執行方法,方法執行之后再釋放監視器鎖。這時如果其他線程來請求執行方法,會因為無法獲得監視器鎖而被阻斷住。值得注意的是,如果在方法執行過程中,發生了異常,并且方法內部并沒有處理該異常,那么在異常被拋到方法外面之前監視器鎖會被自動釋放。
同步代碼塊使用monitorenter
和monitorexit
兩個指令實現。可以把執行monitorenter
指令理解為加鎖,執行monitorexit
理解為釋放鎖。 每個對象維護著一個記錄著被鎖次數的計數器。未被鎖定的對象的該計數器為0,當一個線程獲得鎖(執行monitorenter
)后,該計數器自增變為 1 ,當同一個線程再次獲得該對象的鎖的時候,計數器再次自增。當同一個線程釋放鎖(執行monitorexit
指令)的時候,計數器再自減。當計數器為0的時候。鎖將被釋放,其他線程便可以獲得鎖。
其實無論是ACC_SYNCHRONIZED
還是monitorenter
、monitorexit
都是基于Monitor
實現的,每一個鎖都對應一個monitor
對象。在Java虛擬機(HotSpot)中,Monitor
是基于C++實現的,由ObjectMonitor
實現。在/hotspot/src/share/vm/runtime/objectMonitor.hpp
中有ObjectMonitor
的實現。
// initialize the monitor, exception the semaphore, all other fields
// are simple integers or pointers
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 ;}
_owner
:指向持有ObjectMonitor
對象的線程;_WaitSet
:存放處于wait
狀態的線程隊列;_EntryList
:存放處于等待鎖block
狀態的線程隊列;_recursions
:鎖的重入次數;_count
:用來記錄該線程獲取鎖的次數;
當多個線程同時訪問一段同步代碼時,首先會進入_EntryList
隊列中,當某個線程獲取到對象的monitor
后進入_Owner
區域,并把monitor
中的_owner
變量設置為當前線程,同時monitor
中的計數器_count
加1,即獲得對象鎖。
若此時持有monitor
的線程調用wait()
方法,將釋放當前對象持有的monitor
,_owner
變量恢復為null
,_count
自減1,同時該線程進入_WaitSet
集合中等待被喚醒。若當前線程執行完畢也將釋放monitor
并復位變量的值,以便其他線程進入獲取monitor
。
ObjectMonitor
中其他方法:
bool try_enter (TRAPS) ;void enter(TRAPS);void exit(bool not_suspended, TRAPS);void wait(jlong millis, bool interruptable, TRAPS);void notify(TRAPS);void notifyAll(TRAPS);
sychronized
加鎖的時候,會調用objectMonitor
的enter
方法,解鎖的時候會調用exit
方法。在JDK1.6之前,synchronized
的實現直接調用ObjectMonitor
的enter
和exit
,這種鎖被稱之為重量級鎖,這也是早期synchronized
效率低的原因。所以,在JDK1.6中出現對鎖進行了很多的優化,進而出現輕量級鎖,偏向鎖,鎖消除,適應性自旋鎖,鎖粗化。
早期的
synchronized
效率低的原因:
Java的線程是映射到操作系統原生線程之上的,如果要阻塞或喚醒一個線程就需要操作系統的幫忙,監視器鎖monitor
是依賴于底層的操作系統的Mutex Lock
來實現的,而操作系統實現線程之間的切換時需要從用戶態轉換到核心態。因此狀態轉換需要花費很多的處理器時間。
對于代碼簡單的同步塊(如被synchronized
修飾的get
、set
方法)狀態轉換消耗的時間有可能比用戶代碼執行的時間還要長,所以說synchronized
是Java語言中一個重量級的操作。也是為什么早期的synchronized
效率低的原因。
鎖的升級
在JDK1.6之前,使用synchronized
被稱作重量級鎖,它的實現是基于底層操作系統的mutex
互斥原語的,這個開銷是很大的。所以在JDK1.6時JVM對synchronized
做了優化。synchronized
鎖對象時,其實就是改變對象中的對象頭的markword
的鎖的標志位來實現的。對象頭中markword
鎖狀態的表示:
鎖狀態 | markword 鎖標志位 |
---|---|
無鎖狀態 | 01 |
偏向鎖狀態 | 01 |
輕量級鎖狀態 | 00 |
重量級鎖狀態 | 10 |
被垃圾回收器標記 | 11 |
對象的鎖狀態,可以分為4種,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態。其中這幾個鎖只有重量級鎖是需要使用操作系統底層mutex
互斥原語來實現,其他的鎖都是使用對象頭來實現的。
- 無鎖狀態:
markword
鎖的標志位0,偏向鎖的標志位為1;例如:剛被創建出來的對象。 - 偏向鎖:如果一個線程獲取了鎖,此時
markword
的結構變為偏向鎖結構,當這個線程再次請求鎖時,無需再做任何同步操作,直接可以獲取鎖。
省去了大量有關鎖申請的操作,從而也就提供程序的性能。 - 輕量級鎖:當鎖是偏向鎖的時候,被另外的線程所訪問,偏向鎖就會升級為輕量級鎖,其他線程會通過自旋的形式嘗試獲取鎖,不會阻塞從而提高性能。
- 重量級鎖:升級為重量級鎖時,鎖標志的狀態值變為“10”,此時
MarkWord
中存儲的是指向重量級鎖的指針,此時等待鎖的線程都會進入阻塞狀態,所以開銷是很大。
隨著鎖的競爭,鎖從偏向鎖升級到輕量級鎖,再升級的重量級鎖。鎖升級過程:
- 無鎖狀態升級為偏向鎖:一個對象剛開始實例化的時候,沒有任何線程來訪問它的時候,它是可偏向的,意味著它現在認為只可能有一個線程來訪問它,所以當第一個線程來訪問它的時候,它會偏向這個線程。此時對象持有偏向鎖。偏向第一個線程,這個線程在修改對象頭成為偏向鎖的時候使用CAS操作,并將對象頭中的
ThreadID
改成自己的ID,之后再次訪問這個對象時,只需要對比ID,就不需要再使用CAS在進行操作。 - 偏向鎖升級為輕量級鎖:一旦有第二個線程訪問這個對象,因為偏向鎖不會主動釋放,所以第二個線程可以看到對象的偏向狀態。這時表明在這個對象上已經存在競爭了,JVM會檢查原來持有該對象鎖的線程是否依然存活,如果不存活,則可以將對象變為無鎖狀態,然后重新偏向新的線程。如果原來的線程依然存活,則馬上執行這個線程的操作棧,檢查該對象的使用情況,如果仍然需要持有偏向鎖,則偏向鎖升級為輕量級鎖。
- 輕量級鎖升級為重量級鎖:輕量級鎖認為競爭存在,但是競爭的程度很輕,一般兩個線程對于同一個鎖的操作都會錯開,或者說稍微等待一下,另一個線程就會釋放鎖。但是當自旋超過一定的次數,或者一個線程在持有鎖,一個在自旋,又有第三個來訪時,輕量級鎖膨脹為重量級鎖,重量級鎖使除了擁有鎖的線程以外的線程都阻塞。當持有鎖的線程退出同步塊或方法時,會執行
monitorexit
指令釋放鎖。如果有其他線程在等待該鎖,它們會被喚醒并競爭鎖的所有權。
在所有的鎖都啟用的情況下,線程進入臨界區時會先獲取偏向鎖,如果已經存在偏向鎖了,則會嘗試獲取輕量級鎖,啟用自旋鎖。如果自旋也沒有獲取到鎖,則使用重量級鎖,將沒有獲取到鎖的線程阻塞掛起,直到持有鎖的線程執行完同步塊喚醒他們。
偏向鎖是在無鎖爭用的情況下使用的,也就是同步代碼塊在當前線程沒有執行完之前,沒有其它線程會執行該同步塊。一旦有了第二個線程的爭用,偏向鎖就會升級為輕量級鎖,如果輕量級鎖自旋到達閾值后,沒有獲取到鎖,就會升級為重量級鎖。
鎖可以升級,但是不可以降級,有的觀點認為不會進行鎖降級。實際上,鎖降級確實是會發生的,當JVM進入安全點的時候,會檢查是否有閑置的`Monitor,然后試圖進行降級。也就是說,僅僅是發生在STW的時候,只有垃圾回收線程能夠觀測到它,在我們正常使用的過程中是不會發生鎖降級的,只有在GC的時候才會降級。
安全點:程序執行時并非在所有地方都能停頓下來開始GC,只有在特定的位置才能停頓下來開始GC,這些位置稱為安全點。
synchronized與可見性
可見性是指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。
Java內存模型規定了所有的變量都存儲在主內存中,每條線程還有自己的工作內存,線程的工作內存中保存了該線程中是用到的變量的主內存副本拷貝,線程對變量的所有操作都必須在工作內存中進行,而不能直接讀寫主內存。不同的線程之間也無法直接訪問對方工作內存中的變量,線程間變量的傳遞均需要自己的工作內存和主存之間進行數據同步進行。所以就可能出現線程1改了某個變量的值,但是線程2不可見的情況。
被synchronized
修飾的代碼,在開始執行時會加鎖,執行完成后會進行解鎖。但是為了保證可見性,有一條規則是這樣的,“對一個變量解鎖之前,必須先把此變量同步回主存中”,這樣解鎖后,后續線程就可以訪問到被修改后的值。所以synchronized
關鍵字鎖住的對象,其值是具有可見性的。
public class VisibilityExample {private boolean flag = false;public synchronized void toggleFlag() {// 修改共享變量并確保可見性flag = !flag;// 其他操作}public synchronized boolean isFlag() {// 讀取共享變量并確保可見性return flag;}
}
synchronized與原子性
原子性是指一個操作是不可中斷的,要全部執行完成,要不就都不執行。
線程是CPU調度的基本單位,CPU有時間片的概念,會根據不同的調度算法進行線程調度。當一個線程獲得時間片之后開始執行,在時間片耗盡之后,就會失去CPU使用權。所以在多線程場景下,由于時間片在線程間輪換,就會發生原子性問題。在Java中,為了保證原子性,提供了兩個高級的字節碼指令monitorenter
和monitorexit
,這兩個字節碼指令,在Java中對應的關鍵字就是synchronized
。通過monitorexit
和monitorexit
指令,可以保證被synchronized
修飾的代碼在同一時間只能被一個線程訪問,在鎖未釋放之前,無法被其他線程訪問到。因此在Java中可以使用synchronized
來保證方法和代碼塊內的操作是原子性的。
舉個例子,線程1在執行monitorenter
指令的時候,會對Monitor
進行加鎖,加鎖后其他線程無法獲得鎖,除非線程1主動解鎖。即使在執行過程中,由于某種原因,比如CPU時間片用完,線程1放棄了CPU,但是它并沒有進行解鎖。而由于synchronized
的鎖是可重入的,下一個時間片還是只能被他自己獲取到,還是會繼續執行代碼,直到所有代碼執行完,這就保證了原子性。
public class AtomicityExample {private int count = 0;public synchronized void increment() {// 原子性的遞增操作count++;}public synchronized void decrement() {// 原子性的遞減操作count--;}public synchronized int getCount() {// 原子性的讀取操作return count;}public static void main(String[] args) {AtomicityExample example = new AtomicityExample();// 線程1:遞增操作Thread thread1 = new Thread(() -> {for (int i = 0; i < 1000; i++) {example.increment();}});// 線程2:遞減操作Thread thread2 = new Thread(() -> {for (int i = 0; i < 1000; i++) {example.decrement();}});// 啟動線程thread1.start();thread2.start();try {// 等待兩個線程執行完成thread1.join();thread2.join();} catch (InterruptedException e) {e.printStackTrace();}// 輸出最終的計數結果System.out.println("Final Count: " + example.getCount());}
}
synchronized與有序性
有序性即程序執行的順序按照代碼的先后順序執行。
除了引入了時間片以外,由于處理器優化和指令重排等,CPU還可能對輸入代碼進行亂序執行,比如load->add->save
有可能被優化成load->save->add
這就是可能存在有序性問題。這里需要注意的是,synchronized
是無法禁止指令重排和處理器優化的,也就是說synchronized
無法避免上述提到的問題。那synchronized
是如何保證有序性的?
synchronized
通過兩個主要機制來保證有序性。synchronized
的主要特性是互斥性,意味著在同一時刻只有一個線程可以進入同步塊,既然是單線程就需要遵守as-if-serial
語義,那么就可以認為單線程程序是按照順序執行的。
as-if-serial
語義:不管怎么重排序(編譯器和處理器為了提高并行度),單線程程序的執行結果都不能被改變。編譯器和處理器無論如何優化,都必須遵守as-if-serial
語義。
第二個保證就是內存屏障。編譯器和CPU在執行代碼時,可能會為了優化性能進行指令重排,但synchronized
塊內的指令不會被重排。原因就是Java內存模型通過在進入和退出synchronized
塊時插入內存屏障,來保證這些操作在多線程環境下的順序執行。在進入synchronized
塊時,會插入一個LoadLoad
屏障和一個LoadStore
屏障,確保在鎖被獲取后,前面的所有讀操作和寫操作都已經完成。在退出synchronized
塊時,會插入一個StoreStore
屏障和一個StoreLoad
屏障,確保在鎖被釋放前,所有的寫操作都已經完成,并且這些寫操作對其他線程可見。