synchronized是Java多線程中元老級的鎖,也是面試的高頻考點,讓我們來詳細了解synchronized吧。
在Java中,synchronized
鎖可能是我們最早接觸的鎖了,在 JDK1.5之前synchronized是一個重量級鎖,相對於juc包中的Lock,synchronized
顯得比較笨重。
慶幸的是在 Java 6 之后 Java 官⽅對從 JVM 層⾯對synchronized
進行⼤優化,所以現在的 synchronized 鎖效率也優化得很不錯。
一、synchronized 使用
1、synchronized的作用
synchronized
的作用主要有三:
- (1)、原子性:所謂原子性就是指一個操作或者多個操作,要么全部執行並且執行的過程不會被任何因素打斷,要么就都不執行。被
synchronized
修飾的類或對象的所有操作都是原子的,因為在執行操作之前必須先獲得類或對象的鎖,直到執行完才能釋放。 - (2)、可見性:**可見性是指多個線程訪問一個資源時,該資源的狀態、值信息等對於其他線程都是可見的。 **synchronized和volatile都具有可見性,其中synchronized對一個類或對象加鎖時,一個線程如果要訪問該類或對象必須先獲得它的鎖,而這個鎖的狀態對於其他任何線程都是可見的,並且在釋放鎖之前會將對變量的修改刷新到共享內存當中,保證資源變量的可見性。
- (3)、有序性:有序性值程序執行的順序按照代碼先后執行。 synchronized和volatile都具有有序性,Java允許編譯器和處理器對指令進行重排,但是指令重排並不會影響單線程的順序,它影響的是多線程並發執行的順序性。synchronized保證了每個時刻都只有一個線程訪問同步代碼塊,也就確定了線程執行同步代碼塊是分先后順序的,保證了有序性。
2、synchronized的使用
Synchronized主要有三種用法:
-
(1)、修飾實例方法: 作用於當前對象實例加鎖,進入同步代碼前要獲得 當前對象實例的鎖
synchronized void method() { //業務代碼 }
-
(2)、修飾靜態方法: 也就是給當前類加鎖,會作用於類的所有對象實例 ,進入同步代碼前要獲得 當前 class 的鎖。因為靜態成員不屬於任何一個實例對象,是類成員( static 表明這是該類的一個靜態資源,不管 new 了多少個對象,只有一份)。所以,如果一個線程 A 調用一個實例對象的非靜態
synchronized
方法,而線程 B 需要調用這個實例對象所屬類的靜態synchronized
方法,是允許的,不會發生互斥現象,因為訪問靜態synchronized
方法占用的鎖是當前類的鎖,而訪問非靜態synchronized
方法占用的鎖是當前實例對象鎖。
synchronized void staic method() {
//業務代碼
}
- (3)、修飾代碼塊 :指定加鎖對象,對給定對象/類加鎖。
synchronized(this|object)
表示進入同步代碼庫前要獲得給定對象的鎖。synchronized(類.class)
表示進入同步代碼前要獲得 當前 class 的鎖
synchronized(this) {
//業務代碼
}
簡單總結一下:
synchronized
關鍵字加到 static
靜態方法和 synchronized(class)
代碼塊上都是是給 Class 類上鎖。
synchronized
關鍵字加到實例方法上是給對象實例上鎖。
接下來看一個 synchronized 使用經典實例—— 線程安全的單例模式:
public class Singleton {
//保證有序性,防止指令重排
private volatile static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
//先判斷對象是否已經實例過,沒有實例化過才進入加鎖代碼
if (uniqueInstance == null) {
//類對象加鎖
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
二、synchronized同步原理
數據同步需要依賴鎖,那鎖的同步又依賴誰?synchronized給出的答案是在軟件層面依賴JVM,而j.u.c.Lock給出的答案是在硬件層面依賴特殊的CPU指令。
1、synchronized 同步語句塊原理
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("synchronized 代碼塊");
}
}
}
通過 JDK 自帶的 javap
命令查看 SynchronizedDemo
類的相關字節碼信息:首先切換到類的對應目錄執行 javac SynchronizedDemo.java
命令生成編譯后的 .class 文件,然后執行javap -c -s -v -l SynchronizedDemo.class
。
從圖中可以看出:
synchronized
同步語句塊的實現使用的是 monitorenter
和 monitorexit
指令,其中 monitorenter
指令指向同步代碼塊的開始位置, monitorexit
指令則指明同步代碼塊的結束位置。**
當執行 monitorenter
指令時,線程試圖獲取鎖也就是獲取 對象監視器 monitor
的持有權。
在 Java 虛擬機(HotSpot)中,Monitor 是基於 C++實現的,由ObjectMonitor實現的。每個對象中都內置了一個
ObjectMonitor
對象。另外,
wait/notify
等方法也依賴於monitor
對象,這就是為什么只有在同步的塊或者方法中才能調用wait/notify
等方法,否則會拋出java.lang.IllegalMonitorStateException
的異常的原因。
在執行monitorenter
時,會嘗試獲取對象的鎖,如果鎖的計數器為 0 則表示鎖可以被獲取,獲取后將鎖計數器設為 1 也就是加 1。
在執行 monitorexit
指令后,將鎖計數器設為 0,表明鎖被釋放。如果獲取對象鎖失敗,那當前線程就要阻塞等待,直到鎖被另外一個線程釋放為止。
2、synchronized 修飾方法原理
public class SynchronizedDemo2 {
public synchronized void method() {
System.out.println("synchronized 方法");
}
}
反編譯一下:
synchronized
修飾的方法並沒有 monitorenter
指令和 monitorexit
指令,取得代之的確實是 ACC_SYNCHRONIZED
標識,該標識指明了該方法是一個同步方法。JVM 通過該 ACC_SYNCHRONIZED
訪問標志來辨別一個方法是否聲明為同步方法,從而執行相應的同步調用。
簡單總結一下:
synchronized
同步語句塊的實現使用的是 monitorenter
和 monitorexit
指令,其中 monitorenter
指令指向同步代碼塊的開始位置,monitorexit
指令則指明同步代碼塊的結束位置。
synchronized
修飾的方法並沒有 monitorenter
指令和 monitorexit
指令,取得代之的確實是 ACC_SYNCHRONIZED
標識,該標識指明了該方法是一個同步方法。
不過兩者的本質都是對對象監視器 monitor 的獲取。
三、synchronized同步概念
1、Java對象頭
在JVM中,對象在內存中的布局分為三塊區域:對象頭、實例數據和對齊填充。
synchronized
用的鎖是存在Java對象頭里的。
Hotspot 有兩種對象頭:
- 數組類型,如果對象是數組類型,則虛擬機用3個字寬 (Word)存儲對象頭
- 非數組類型:如果對象是非數組類型,則用2字寬存儲對象頭。
對象頭由兩部分組成
- Mark Word:存儲自身的運行時數據,例如 HashCode、GC 年齡、鎖相關信息等內容。
- Klass Pointer:類型指針指向它的類元數據的指針。
64 位虛擬機 Mark Word 是 64bit,在運行期間,Mark Word里存儲的數據會隨着鎖標志位的變化而變化。
2、監視器(Monitor)
任何一個對象都有一個Monitor與之關聯,當且一個Monitor被持有后,它將處於鎖定狀態。Synchronized在JVM里的實現都是 基於進入和退出Monitor對象來實現方法同步和代碼塊同步,雖然具體實現細節不一樣,但是都可以通過成對的MonitorEnter和MonitorExit指令來實現。
- MonitorEnter指令:插入在同步代碼塊的開始位置,當代碼執行到該指令時,將會嘗試獲取該對象Monitor的所有權,即嘗試獲得該對象的鎖;
- MonitorExit指令:插入在方法結束處和異常處,JVM保證每個MonitorEnter必須有對應的MonitorExit;
那什么是Monitor?可以把它理解為 一個同步工具,也可以描述為 一種同步機制,它通常被 描述為一個對象。
與一切皆對象一樣,所有的Java對象是天生的Monitor,每一個Java對象都有成為Monitor的潛質,因為在Java的設計中 ,每一個Java對象自打娘胎里出來就帶了一把看不見的鎖,它叫做內部鎖或者Monitor鎖。
也就是通常說Synchronized的對象鎖,MarkWord鎖標識位為10,其中指針指向的是Monitor對象的起始地址。在Java虛擬機(HotSpot)中,Monitor是由ObjectMonitor實現的。
四、synchronized優化
從JDK5引入了現代操作系統新增加的CAS原子操作( JDK5中並沒有對synchronized關鍵字做優化,而是體現在J.U.C中,所以在該版本concurrent包有更好的性能 ),從JDK6開始,就對synchronized的實現機制進行了較大調整,包括使用JDK5引進的CAS自旋之外,還增加了自適應的CAS自旋、鎖消除、鎖粗化、偏向鎖、輕量級鎖這些優化策略。由於此關鍵字的優化使得性能極大提高,同時語義清晰、操作簡單、無需手動關閉,所以推薦在允許的情況下盡量使用此關鍵字,同時在性能上此關鍵字還有優化的空間。
鎖主要存在四種狀態,依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態、重量級鎖狀態,鎖可以從偏向鎖升級到輕量級鎖,再升級的重量級鎖。但是鎖的升級是單向的,也就是說只能從低到高升級,不會出現鎖的降級。
1、偏向鎖
偏向鎖是JDK6中的重要引進,因為HotSpot作者經過研究實踐發現,在大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,為了讓線程獲得鎖的代價更低,引進了偏向鎖。
當一個線程訪問同步塊並獲取鎖時,會在對象頭和棧幀中的鎖記錄里存儲鎖偏向的線程ID,以后該線程在進入和退出同步塊時不需要進行CAS操作來加鎖和解鎖,只需簡單地測試一下對象頭的Mark Word里是否存儲着指向當前線程的偏向鎖。
如果測試成功,表示線程已經獲得了鎖。如果測試失敗,則需要再測試一下Mark Word中偏向鎖的標識是否設置成1(表示當前是偏向鎖):如果沒有設置,則使用CAS競爭鎖;如果設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前線程。
偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他線程嘗試競爭偏向鎖時, 持有偏向鎖的線程才會釋放鎖。
偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有正在執行的字節碼)。它會首先暫停擁有偏向鎖的線程,然后檢查持有偏向鎖的線程是否活着, 如果線程不處於活動狀態,則將對象頭設置成無鎖狀態;如果線程仍然活着,擁有偏向鎖的棧會被執行,遍歷偏向對象的鎖記錄,棧中的鎖記錄和對象頭的Mark Word要么重新偏向於其他線程,要么恢復到無鎖或者標記對象不適合作為偏向鎖,最后喚醒暫停的線程。
下圖中的線 程1演示了偏向鎖初始化的流程,線程2演示了偏向鎖撤銷的流程:
2、輕量級鎖
引入輕量級鎖的主要目的是 在沒有多線程競爭的前提下,減少傳統的重量級鎖使用操作系統互斥量產生的性能消耗。當關閉偏向鎖功能或者多個線程競爭偏向鎖導致偏向鎖升級為輕量級鎖,則會嘗試獲取輕量級鎖。
(1)輕量級鎖加鎖
線程在執行同步塊之前,JVM會先在當前線程的棧楨中創建用於存儲鎖記錄的空間,並將對象頭中的Mark Word復制到鎖記錄中,官方稱為Displaced Mark Word。然后線程嘗試使用 CAS將對象頭中的Mark Word替換為指向鎖記錄的指針。如果成功,當前線程獲得鎖,如果失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖。
(2)輕量級鎖解鎖
輕量級解鎖時,會使用原子的CAS操作將Displaced Mark Word替換回到對象頭,如果成 功,則表示沒有競爭發生。如果失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。
下圖是 兩個線程同時爭奪鎖,導致鎖膨脹的流程圖:
因為自旋會消耗CPU,為了避免無用的自旋(比如獲得鎖的線程被阻塞住了),一旦鎖升級成重量級鎖,就不會再恢復到輕量級鎖狀態。當鎖處於這個狀態下,其他線程試圖獲取鎖時, 都會被阻塞住,當持有鎖的線程釋放鎖之后會喚醒這些線程,被喚醒的線程就會進行新一輪的奪鎖之爭。
3、鎖的優缺點比較
各種鎖並不是相互代替的,而是在不同場景下的不同選擇,絕對不是說重量級鎖就是不合適的。每種鎖是只能升級,不能降級,即由偏向鎖->輕量級鎖->重量級鎖,而這個過程就是開銷逐漸加大的過程。
如果是單線程使用,那偏向鎖毫無疑問代價最小,並且它就能解決問題,連CAS都不用做,僅僅在內存中比較下對象頭就可以了;
如果出現了其他線程競爭,則偏向鎖就會升級為輕量級鎖;
如果其他線程通過一定次數的CAS嘗試沒有成功,則進入重量級鎖;
鎖的優缺點的對比如下表:
鎖 | 優點 | 缺點 | 適用場景 |
---|---|---|---|
偏向鎖 | 加鎖和解鎖不需要額外的消耗,和執行非同步方法僅有納米級的差距 | 如果線程間存在鎖的競爭,會帶來額外的鎖撤銷的消耗 | 適用於只有一個線程訪問的同步塊場景 |
輕量級鎖 | 競爭的線程不會阻塞,提高了程序的相應速度 | 如果始終得不到鎖競爭的線程,使用自旋會消耗CPU | 追求響應時間 同步響應非常快 |
重量級鎖 | 線程競爭不使用自旋,不會消耗CPU | 線程阻塞,響應時間緩慢 | 追求吞吐量 同步塊執行速度較長 |
參考:
【2】:方騰飛等編著 《Java並發編程的藝術》