原創文章&經驗總結&從校招到A廠一路陽光一路滄桑
詳情請戳www.codercc.com
1. synchronized簡介
在學習知識前,我們先來看一個現象:
public class SynchronizedDemo implements Runnable {
private static int count = 0;
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(new SynchronizedDemo());
thread.start();
}
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("result: " + count);
}
@Override
public void run() {
for (int i = 0; i < 1000000; i++)
count++;
}
}
開啟了10個線程,每個線程都累加了1000000次,如果結果正確的話自然而然總數就應該是10 * 1000000 = 10000000。可就運行多次結果都不是這個數,而且每次運行結果都不一樣。這是為什么了?有什么解決方案了?這就是我們今天要聊的事情。
在上一篇博文中我們已經了解了java內存模型的一些知識,并且已經知道出現線程安全的主要來源于JMM的設計,主要集中在主內存和線程的工作內存而導致的內存可見性問題,以及重排序導致的問題,進一步知道了happens-before規則。線程運行時擁有自己的棧空間,會在自己的棧空間運行,如果多線程間沒有共享的數據也就是說多線程間并沒有協作完成一件事情,那么,多線程就不能發揮優勢,不能帶來巨大的價值。那么共享數據的線程安全問題怎樣處理?很自然而然的想法就是每一個線程依次去讀寫這個共享變量,這樣就不會有任何數據安全的問題,因為每個線程所操作的都是當前最新的版本數據。那么,在java關鍵字synchronized就具有使每個線程依次排隊操作共享變量的功能。很顯然,這種同步機制效率很低,但synchronized是其他并發容器實現的基礎,對它的理解也會大大提升對并發編程的感覺,從功利的角度來說,這也是面試高頻的考點。好了,下面,就來具體說說這個關鍵字。
2. synchronized實現原理
在java代碼中使用synchronized可是使用在代碼塊和方法中,根據synchronized用的位置可以有如表3.1這些使用場景:
使用位置 | 作用范圍 | 被鎖的對象 | 示例代碼 |
---|---|---|---|
方法 | 實例方法 | 類的實例對象 | public synchronized void method() { .......} |
靜態方法 | 類對象 | public static synchronized void method1() { .......} | |
代碼塊 | 實例對象 | 類的實例對象 | synchronized (this) { .......} |
class對象 | 類對象 | synchronized (SynchronizedScopeDemo.class) { .......} | |
任意實例對象object | 實例對象object | final String lock = "";synchronized (lock) { .......} |
synchronized可以用在方法上也可以使用在代碼塊中,方法是實例方法和靜態方法分別鎖的是該類的實例對象和該類的對象。而使用在代碼塊中根據鎖的目標對象
也可以分為三種,具體的可以看表數據。這里的需要注意的是如果鎖的是類對象的話,盡管new多個實例對象,依然會被鎖住。synchronized的使用起來很簡單,那么背后的原理以及實現機制是怎樣的呢?
1 對象鎖(monitor)機制
現在來進一步分析synchronized的具體底層實現,有如下一個簡單的示例代碼:
public class SynchronizedDemo {
public static void main(String[] args) {
synchronized (SynchronizedDemo.class) {
System.out.println("hello synchronized!");
}
}
}
上述代碼通過synchronized“鎖住”當前類對象來進行同步,將java代碼進行編譯之后通過javap -v SynchronizedDemo .class來查看對應的main方法字節碼如下:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: ldc #2 // class com/codercc/chapter3/SynchronizedDemo
2: dup
3: astore_1
4: **monitorenter**
5: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
8: ldc #4 // String hello synchronized!
10: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
13: aload_1
14: **monitorexit**
15: **goto** 23
18: astore_2
19: aload_1
20: **monitorexit**
21: aload_2
22: **athrow**
23: **return
重要的字節碼已經在原字節碼文件中進行了標注,再進入到synchronized同步塊中,需要通過monitorenter指令獲取到對象的monitor(也通常稱之為對象鎖)后才能往下進行執行,在處理完對應的方法內部邏輯之后通過monitorexit指令來釋放所持有的monitor,以供其他并發實體進行獲取。代碼后續執行到第15行goto語句進而繼續到第23行return指令,方法成功執行退出。另外當方法異常的情況下,如果monitor不進行釋放,對其他阻塞對待的并發實體來說就一直沒有機會獲取到了,系統會形成死鎖狀態很顯然這樣是不合理。
因此針對異常的情況,會執行到第20行指令通過monitorexit釋放monitor鎖,進一步通過第22行字節碼athrow拋出對應的異常。從字節碼指令分析也可以看出在使用synchronized是具備隱式加鎖和釋放鎖的操作便利性的,并且針對異常情況也做了釋放鎖的處理。
每個對象都存在一個與之關聯的monitor,線程對monitor持有的方式以及持有時機決定了synchronized的鎖狀態以及synchronized的狀態升級方式。monitor是通過C++中ObjectMonitor實現,代碼可以通過openjdk hotspot鏈接(http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/ )進行下載openjdk中hotspot版本的源碼,具體文件路徑在src\share\vm\runtime\objectMonitor.hpp,具體源碼為:
// 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;
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
**_EntryList** = NULL ;
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
從ObjectMonitor的結構中可以看出主要維護_WaitSet以及_EntryList兩個隊列來保存ObjectWaiter 對象,當每個阻塞等待獲取鎖的線程都會被封裝成ObjectWaiter對象來進行入隊,與此同時如果獲取到鎖資源的話就會出隊操作。另外_owner則指向當前持有ObjectMonitor對象的線程。等待獲取鎖以及獲取鎖出隊的示意圖如下圖所示:
當多個線程進行獲取鎖的時候,首先都會進行_EntryList
隊列,其中一個線程獲取到對象的monitor后,對monitor而言就會將_owner
變量設置為當前線程,并且monitor維護的計數器就會加1。如果當前線程執行完邏輯并退出后,monitor中_owner
變量就會清空并且計數器減1,這樣就能讓其他線程能夠競爭到monitor。另外,如果調用了wait()方法后,當前線程就會進入到_WaitSet中等待被喚醒,如果被喚醒并且執行退出后,也會對狀態量進行重置,也便于其他線程能夠獲取到monitor。
從線程狀態變化的角度來看,如果要想進入到同步塊或者執行同步方法,都需要先獲取到對象的monitor,如果獲取不到則會變更為BLOCKED狀態,具體過程如下圖所示:
從上圖可以看出任意線程對Object的訪問,首先要獲得Object的monitor,如果獲取失敗,該線程就會進入到同步隊列中,線程狀態變為BLOCKED。當monitor持有者釋放后,在同步隊列中的線程才會有機會重新獲取monitor,才能繼續執行。
2 synchronized的happens-before關系
在第2章中分析過happens-before規則,其中有一條就是監視器鎖規則:對同一個監視器的解鎖happens-before于對該監視器的加鎖。為了進一步了解synchronized的并發語義,通過示例代碼分析這條happens-before規則,示例代碼如下:
public class MonitorDemo {
private int a = 0;
public synchronized void writer() { // 1
a++; // 2
} // 3
public synchronized void reader() { // 4
int i = a; // 5
} // 6
}
在并發時,第5步操作中讀取到的變量a的值是多少呢?這就需要通過happens-before規則來進行分析,示例代碼的happens-before關系如下圖所示:
上圖中每一個箭頭連接的兩個節點就代表之間的happens-before關系,黑色的是通過程序順序規則推導出來,通過監視器鎖規則可以推導出線程A釋放鎖happens-before線程B加鎖,即紅色線表示。藍色的線則是通過傳遞性規則進一步推導的happens-before關系。最終得到的結論就是操作2 happens-before 5,通過這個關系可以得出什么?
根據happens-before的定義中的一條:如果A happens-before B,則A的執行結果對B可見。那么在該示例代碼中,線程A先對共享變量A進行加1,由2 happens-before 5關系可知線程A的執行結果對線程B可見即線程B所讀取到的a的值為1。
3 鎖獲取和鎖釋放的內存語義
在第2章中總結果JMM核心為兩個部分:happens-before規則以及內存抽象模型。在分析完synchronized的happens-before關系后還是不太完整的,接下來看看基于java內存抽象模型的synchronized的內存語義,具體過程如下圖所示:
針對線程A的操作而言,從上圖可以看出線程A會首先先從主內存中讀取共享變量a=0的值然后將該變量拷貝到線程本地內存。然后基于該值進行數據操作后變量a變為1,然后會將值寫入到主內存中。
對線程B而言執行流程如上圖所示。線程B獲取鎖的時候會強制從主內存中共享變量a的值,而此時變量a已經是最新值了。接下來線程B會將該值拷貝到工作內存中進行操作,同樣的執行完操作后也會重新寫入到主內存中。
從橫向來看,線程A和線程線程都是基于主內存中的共享變量互相感知到對方的數據操作,并基于共享變量來完成并發實體中的協同工作,整個過程就好像線程A給線程B發送了一個數據變更的“通知”,這種通信機制就是基于共享內存的并發模型結構導致。
通過上面的討論對synchronized應該有一定了解,它最大的特征就是在同一時刻只有一個線程能夠獲得對象monitor,從而確保當前線程能夠執行到相應的同步邏輯,對線程之間而言表現為互斥性(排它性)。自然而然這種同步方式會有效率相對低下的弊端,既然同步流程不能發生改變,那么能不能讓每次獲取鎖的速度更快或者降低阻塞等待的概率呢?也就是通過局部的優化來提升系統整體的并發同步的效率。比如去收銀臺付款的場景,之前的方式是大家都去排隊,然后去紙幣付款收銀員找零。甚至有的時候付款的時候還需要在包里拿出錢包拿出錢,這個過程是比較耗時的。針對付款的流程,就可以通過線上化的手段來進行優化,在現在只需要通過支付寶掃描二維碼就可以完成付款了,也省去了收銀員找零的時間。盡管整個付款場景還是需要排隊,但是因為付款(類似于獲取鎖釋放鎖)這個環節的優化導致耗時大大縮短,對收銀臺(系統整體并發效率)而言操作效率就極大的帶來提升。如此類比,如果能對鎖操作過程進行優化的話,也會對并發效率帶來極大的提升。
那么,針對synchronized的優化是怎樣做的呢?在進一步分析之前,需要先了解這兩個概念:1. CAS操作;2.Java對象頭。
3.1 CAS操作
3.1.1 什么是CAS?
使用鎖時,線程獲取鎖是一種悲觀鎖策略,即假設每一次執行臨界區代碼都會產生沖突,所以當前線程獲取到鎖的時候同時也會阻塞其他線程獲取該鎖。而CAS操作(又稱為無鎖操作)是一種樂觀鎖策略,它假設所有線程訪問共享資源的時候不會出現沖突,既然不會出現沖突自然而然就不會阻塞其他線程的操作。因此,線程就不會出現阻塞停頓的狀態。那么,如果出現沖突了怎么辦?無鎖操作是使用CAS(compare and swap)又叫做比較交換來鑒別線程是否出現沖突,出現沖突就重試當前操作直到沒有沖突為止。
3.1.2 CAS的操作過程
CAS比較交換的過程可以通俗的理解為CAS(V,O,N),包含三個值分別為:V 內存地址存放的實際值;O 預期的值(舊值);N 更新的新值。當V和O相同時,也就是說舊值和內存中實際的值相同表明該值沒有被其他線程更改過,即該舊值O就是目前來說最新的值了,自然而然可以將新值N賦值給V。反之,V和O不相同,表明該值已經被其他線程改過了則該舊值O不是最新版本的值了,所以不能將新值N賦給V,返回V即可。當多個線程使用CAS操作一個變量是,只有一個線程會成功,并成功更新,其余會失敗。失敗的線程會重新嘗試,當然也可以選擇掛起線程
CAS的實現需要硬件指令集的支撐,在JDK1.5后虛擬機才可以使用處理器提供的CMPXCHG指令實現。
Synchronized VS CAS
元老級的Synchronized(未優化前)最主要的問題是:在存在線程競爭的情況下會出現線程阻塞和喚醒鎖帶來的性能問題,因為這是一種互斥同步(阻塞同步)。而CAS并不是武斷的間線程掛起,當CAS操作失敗后會進行一定的嘗試,而非進行耗時的掛起喚醒的操作,因此也叫做非阻塞同步。這是兩者主要的區別。
3.1.3 CAS的應用場景
在J.U.C包中利用CAS實現類有很多,可以說是支撐起整個concurrency包的實現,在Lock實現中會有CAS改變state變量,在atomic包中的實現類也幾乎都是用CAS實現,關于這些具體的實現場景在之后會詳細聊聊,現在有個印象就好了(微笑臉)。
3.1.4 CAS的問題
1. ABA問題
因為CAS會檢查舊值有沒有變化,這里存在這樣一個有意思的問題。比如一個舊值A變為了成B,然后再變成A,剛好在做CAS時檢查發現舊值并沒有變化依然為A,但是實際上的確發生了變化。解決方案可以沿襲數據庫中常用的樂觀鎖方式,添加一個版本號可以解決。原來的變化路徑A->B->A就變成了1A->2B->3C。java這么優秀的語言,當然在java 1.5后的atomic包中提供了AtomicStampedReference來解決ABA問題,解決思路就是這樣的。
2. 自旋時間過長
使用CAS時非阻塞同步,也就是說不會將線程掛起,會自旋(無非就是一個死循環)進行下一次嘗試,如果這里自旋時間過長對性能是很大的消耗。如果JVM能支持處理器提供的pause指令,那么在效率上會有一定的提升。
3. 只能保證一個共享變量的原子操作
當對一個共享變量執行操作時CAS能保證其原子性,如果對多個共享變量進行操作,CAS就不能保證其原子性。有一個解決方案是利用對象整合多個共享變量,即一個類中的成員變量就是這幾個共享變量。然后將這個對象做CAS操作就可以保證其原子性。atomic中提供了AtomicReference來保證引用對象之間的原子性。
3.2 Java對象頭
在同步的時候是獲取對象的monitor,即獲取到對象的鎖。那么對象的鎖怎么理解?無非就是類似對對象的一個標志,那么這個標志就是存放在Java對象的對象頭。Java對象頭里的Mark Word里默認的存放的對象的Hashcode,分代年齡和鎖標記位。32為JVM Mark Word默認存儲結構為(注:java對象頭以及下面的鎖狀態變化摘自《java并發編程的藝術》一書,該書我認為寫的足夠好,就沒在自己組織語言班門弄斧了):
如圖在Mark Word會默認存放hasdcode,年齡值以及鎖標志位等信息。
Java SE 1.6中,鎖一共有4種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態,這幾個狀態會隨著競爭情況逐漸升級。鎖可以升級但不能降級,意味著偏向鎖升級成輕量級鎖后不能降級成偏向鎖。這種鎖升級卻不能降級的策略,目的是為了提高獲得鎖和釋放鎖的效率。對象的MarkWord變化為下圖:
3.2 偏向鎖
HotSpot的作者經過研究發現,大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,為了讓線程獲得鎖的代價更低而引入了偏向鎖。
偏向鎖的獲取
當一個線程訪問同步塊并獲取鎖時,會在對象頭和棧幀中的鎖記錄里存儲鎖偏向的線程ID,以后該線程在進入和退出同步塊時不需要進行CAS操作來加鎖和解鎖,只需簡單地測試一下對象頭的Mark Word里是否存儲著指向當前線程的偏向鎖。如果測試成功,表示線程已經獲得了鎖。如果測試失敗,則需要再測試一下Mark Word中偏向鎖的標識是否設置成1(表示當前是偏向鎖):如果沒有設置,則使用CAS競爭鎖;如果設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前線程
偏向鎖的撤銷
偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程才會釋放鎖。
如圖,偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有正在執行的字節碼)。它會首先暫停擁有偏向鎖的線程,然后檢查持有偏向鎖的線程是否活著,如果線程不處于活動狀態,則將對象頭設置成無鎖狀態;如果線程仍然活著,擁有偏向鎖的棧會被執行,遍歷偏向對象的鎖記錄,棧中的鎖記錄和對象頭的Mark Word要么重新偏向于其他線程,要么恢復到無鎖或者標記對象不適合作為偏向鎖,最后喚醒暫停的線程。
下圖線程1展示了偏向鎖獲取的過程,線程2展示了偏向鎖撤銷的過程。
如何關閉偏向鎖
偏向鎖在Java 6和Java 7里是默認啟用的,但是它在應用程序啟動幾秒鐘之后才激活,如有必要可以使用JVM參數來關閉延遲:-XX:BiasedLockingStartupDelay=0。如果你確定應用程序里所有的鎖通常情況下處于競爭狀態,可以通過JVM參數關閉偏向鎖:-XX:-UseBiasedLocking=false,那么程序默認會進入輕量級鎖狀態
3.3 輕量級鎖
加鎖
線程在執行同步塊之前,JVM會先在當前線程的棧楨中創建用于存儲鎖記錄的空間,并將對象頭中的Mark Word復制到鎖記錄中,官方稱為Displaced Mark Word。然后線程嘗試使用CAS將對象頭中的Mark Word替換為指向鎖記錄的指針。如果成功,當前線程獲得鎖,如果失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖
解鎖
輕量級解鎖時,會使用原子的CAS操作將Displaced Mark Word替換回到對象頭,如果成功,則表示沒有競爭發生。如果失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。下圖是兩個線程同時爭奪鎖,導致鎖膨脹的流程圖。
因為自旋會消耗CPU,為了避免無用的自旋(比如獲得鎖的線程被阻塞住了),一旦鎖升級成重量級鎖,就不會再恢復到輕量級鎖狀態。當鎖處于這個狀態下,其他線程試圖獲取鎖時,都會被阻塞住,當持有鎖的線程釋放鎖之后會喚醒這些線程,被喚醒的線程就會進行新一輪的奪鎖之爭。
3.5 各種鎖的比較
4. 一個例子
經過上面的理解,我們現在應該知道了該怎樣解決了。更正后的代碼為:
public class SynchronizedDemo implements Runnable {
private static int count = 0;
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(new SynchronizedDemo());
thread.start();
}
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("result: " + count);
}
@Override
public void run() {
synchronized (SynchronizedDemo.class) {
for (int i = 0; i < 1000000; i++)
count++;
}
}
}
開啟十個線程,每個線程在原值上累加1000000次,最終正確的結果為10X1000000=10000000,這里能夠計算出正確的結果是因為在做累加操作時使用了同步代碼塊,這樣就能保證每個線程所獲得共享變量的值都是當前最新的值,如果不使用同步的話,就可能會出現A線程累加后,而B線程做累加操作有可能是使用原來的就值,即“臟值”。這樣,就導致最終的計算結果不是正確的。而使用Syncnized就可能保證內存可見性,保證每個線程都是操作的最新值。這里只是一個示例性的demo,聰明的你,還有其他辦法嗎?
參考文獻
《java并發編程的藝術》