Java編程拾遺『synchronized原理』

上篇文章簡單介紹了一下synchronized的使用,以及對象鎖和類鎖的概念,本篇文章就來介紹一下synchronized的底層原理。

上篇文章也提到,我們對synchronized的印象是重量級鎖,使用效率低下,導致我們一般在使用synchronized有很多顧慮。但是隨着Java6對synchronized進行的各種優化後,synchronized並不會顯得那麼重了。本篇文章就synchronized的實現機制以及Java6的各種優化,來對synchronized進行一個全面的認識。

1. 自己實現鎖

在講synchronized鎖之前,我們先考慮一下,如何自己實現一個操作系統的鎖?

1.1 自旋

volatile int status=0;

void lock(){
	while(!compareAndSet(0,1)){
	}
	//獲取鎖成功
}

void unlock(){
	status=0;
}

boolean compareAndSet(int except,int newValue){
	//cas操作,修改status成功則返回true
}

上面的僞代碼通過自旋和cas來實現了一個最簡單的鎖。但有個致命的缺點:浪費cpu資源。沒有競爭到鎖的線程會一直佔用cpu資源進行cas操作,假如系統中一共又兩個線程在運行,一個線程獲得鎖後要花費10s處理業務邏輯,那另外一個線程就會白白的花費10s的cpu資源。

1.2 yield + 自旋

要解決自旋鎖的性能問題必須讓競爭鎖失敗的線程不忙等,而是在獲取不到鎖的時候能把cpu資源給讓出來,之前講線程的使用的使用講到yield()方法可以讓正在執行的線程暫停並讓出cpu資源,如下:

volatile int status=0;

void lock(){
	while(!compareAndSet(0,1)){
		//獲取不到鎖,讓出cpu資源
		yield();
	}
	//獲取鎖成功
}

當線程競爭鎖失敗時,會調用yield方法讓出cpu。但該方法只是當前讓出cpu,有可能操作系統下次還是選擇運行該線程。

自旋+yield的方式並沒有完全解決問題,當系統只有兩個線程競爭鎖時,yield是有效的。但是如果有100個線程競爭鎖,當線程1獲得鎖後,還有99個線程在反覆的自旋 + yield,線程2調用yield後,操作系統下次運行的可能是線程3。而線程3CAS失敗後調用yield後,操作系統下次運行的可能是線程4。

假如運行在單核cpu下,在競爭鎖時最差只有1%的cpu利用率,導致獲得鎖的線程1一直被中斷,執行實際業務代碼時間變得更長,從而導致鎖釋放的時間變的更長。

1.3 sleep + 自旋

除了上述的yield()方法,sleep方法也可以讓出cpu,當cas失敗時,可以通過sleep將cpu資源釋放出來,如下:

volatile int status=0;

void lock(){
	while(!compareAndSet(0,1)){
		//競爭鎖失敗,使用sleep釋放cpu資源
		sleep(10);
	}
	//成功獲取鎖
}

上述方式我們可能見的比較多,通常用於實現上層鎖。該方式不適合用於操作系統級別的鎖,因爲作爲一個底層鎖,sleep時間很難設置,sleep的時間取決於同步代碼塊的執行時間,sleep時間如果太短了,會導致線程切換頻繁(極端情況和yield方式一樣)。sleep時間如果設置的過長,會導致線程不能及時獲得鎖。因此沒法設置一個通用的sleep值。就算sleep的值由調用者指定也不能完全解決問題:有的時候調用鎖的人也不知道同步塊代碼會執行多久。

1.4 park + 自旋

上述yield + 自旋、sleep + 自旋都存在一個問題,競爭鎖失敗釋放cpu的線程重新去競爭鎖的時機不可控制。那能不能在獲取不到鎖的時候讓線程釋放cpu資源進行等待,當持有鎖的線程釋放鎖的時候將等待的線程喚起呢?

volatile int status=0;

Queue parkQueue;

void lock(){
	
	while(!compareAndSet(0,1)){
		//競爭鎖失敗,線程釋放cpu並放入等待隊列
		lock_wait();
	}
	//獲取鎖成功

}

void synchronized  unlock(){
	status = 0;
	//解鎖時,從等鎖隊列去除一個線程並喚醒
	lock_notify();
}

void lock_wait(){
	//將當期線程加入到等待隊列
	parkQueue.add(nowThread);
	//將當期線程釋放cpu
	releaseCpu();
}

void lock_notify(){
	//得到要喚醒的線程
	Thread t=parkList.poll();
	//喚醒等待線程
	wakeAThread(t);
}

上面是僞代碼,描述這種設計思想。這種方案相比於yield和sleep而言,只有在鎖被釋放的時候,競爭鎖的線程纔會被喚醒,不會存在過早或過完喚醒的問題。

對於鎖衝突不嚴重的情況,用自旋鎖會更適合,試想每個線程獲得鎖後很短的一段時間內就釋放鎖,競爭鎖的線程只要經歷幾次自旋運算後就能獲得鎖,那就沒必要等待該線程了,因爲等待線程意味着需要進入到內核態進行上下文切換,而上下文切換是有成本的並且還不低,如果鎖很快就釋放了,那上下文切換的開銷將超過自旋。

目前操作系統中,一般是用自旋+等待結合的形式實現鎖:在進入鎖時先自旋一定次數,如果還沒獲得鎖再進行等待。

2. 操作系統實現鎖

linux底層用futex實現鎖,futex由一個內核層的隊列和一個用戶空間層的atomic integer構成。當獲得鎖時,嘗試cas更改integer,如果integer原始值是0,則修改成功,該線程獲得鎖,否則就將當期線程放入到wait queue中。看着好想跟上面講的park + 自旋差不多,其實操作系統實現的鎖就是對上述park + 自旋的優化版本。下面講一下在futex出現之前及futex是怎麼實現鎖的。

2.1 futex之前

在futex之前,linux下的同步機制可以歸爲兩類:用戶態的同步機制和內核同步機制。用戶態的同步機制基本上就是利用原子指令實現的自旋鎖。關於自旋鎖其缺點也說過了,不適用於大的臨界區(即鎖佔用時間比較長的情況)。

內核提供的同步機制,如semaphore等,使用的是上文說的park + 自旋的形式。 它對於大小臨界區和都適用。但是因爲它是內核層的(釋放cpu資源和喚醒線程是內核級調用),所以每次lock與unlock都是一次系統調用,即使沒有鎖衝突,也必須要通過系統調用進入內核之後才能識別。

2.2 futex

在futex之前,內核同步機制存在問題,即使沒有鎖衝突,也必須要通過系統調用進入內核之後才能識別。而理想的同步機制應該是沒有鎖衝突時在用戶態利用原子指令就解決問題,而需要掛起等待時再使用內核提供的系統調用進行睡眠與喚醒。換句話說就是,在用戶態的自旋失敗時,能不能讓進程掛起,由持有鎖的線程釋放鎖時將其喚醒。

我們想象一下,futex是不是可以通過如下僞代碼實現:

void lock(int lockval) {
	//trylock是用戶級的自旋鎖
	while(!trylock(lockval)) {
		//用戶自旋失敗,釋放cpu,並將當期線程加入等待隊列,是系統調用
		wait();
	}
}

boolean trylock(int lockval){
	int i=0; 
	//localval=1代表上鎖成功
	while(!compareAndSet(lockval,0,1)){
		//自旋10次,如果還沒有獲取鎖,則自旋失敗
		if(++i>10){
			return false;
		}
	}
	return true;
}

void unlock(int lockval) {
	//cas釋放鎖
	compareAndSet(lockval,1,0);
	//喚醒一個等待的線程
	notify();
}

上述代碼的問題是trylock和wait兩個調用之間存在一個窗口,假如有兩個線程競爭鎖,一個線程trylock失敗,在調用wait時持有鎖的線程釋放了鎖,當前線程還是會調用wait進行等待,但之後就沒有人再將該線程喚醒了。

下面來看一下futex的方法定義:

//uaddr指向一個地址,val代表這個地址期待的值,當*uaddr==val時,纔會進行wait
int futex_wait(int *uaddr, int val);

//喚醒n個在uaddr指向的鎖變量上掛起等待的進程
int futex_wake(int *uaddr, int n);

futex_wait真正將進程掛起之前會檢查addr指向的地址的值是否等於val,如果不相等則會立即返回,由用戶態繼續trylock。否則將當期線程插入到一個隊列中去,並掛起。

futex內部維護了一個隊列,在線程掛起前會線程插入到其中,同時對於隊列中的每個節點都有一個標識,代表該線程關聯鎖的uaddr。這樣,當用戶態調用futex_wake時,只需要遍歷這個等待隊列,把帶有相同uaddr的節點所對應的進程喚醒就行了。

作爲優化,futex維護的其實是個類似java 中的concurrent hashmap的結構。其持有一個總鏈表,總鏈表中每個元素都是一個帶有自旋鎖的子鏈表。調用futex_wait掛起的進程,通過其uaddr hash到某一個具體的子鏈表上去。這樣一方面能分散對等待隊列的競爭、另一方面減小單個隊列的長度,便於futex_wake時的查找。每個鏈表各自持有一把spinlock,將”*uaddr和val的比較操作”與”把進程加入隊列的操作”保護在一個臨界區中。

3. synchronized

上面介紹了我們我們自己實現鎖及操作系統實現鎖的簡單思路,相信我們都大致能瞭解一個基本鎖的實現方式,接下來介紹一下java內置鎖synchronized的實現。之前介紹synchronized的使用時,我們知道synchronized有三種用法,分別是synchronized代碼塊、synchronized實例方法和synchronized靜態方法。他們之間的區別也就是對象鎖和類鎖的區別,上篇文章Java編程拾遺『synchronized使用』介紹的很清楚了,這裏對用法不多介紹。這裏重點介紹一下synchronized鎖的底層實現,以及java6中對synchronized的優化技術的細節。

3.1 sychronized簡介

這裏用synchronized代碼塊和synchronized實例方法做示例(synchronized靜態方法跟synchronized實例方法類似)。

public class SynchronizedTest {

    public void synchronizedBlock(){
        synchronized (this){
            System.out.println("hello block");
        }
    }

    public synchronized void synchronizedMethod(){
        System.out.println("hello method");
    }
}

把SynchronizedTest.java編譯成class文件,用javap -verbose 命令查看class文件對應的JVM字節碼信息,部分信息如下:

{
  public void synchronizedBlock();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #3                  // String hello block
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: aload_1
        13: monitorexit
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit
        20: aload_2
        21: athrow
        22: return
      Exception table:
         from    to  target type
             4    14    17   any
            17    20    17   any

  public synchronized void synchronizedMethod();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #5                  // String hello method
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 17: 0
        line 18: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  this   Lcom/zhuoli/service/thinking/java/thread0/SynchronizedTest;
}

對於synchronized代碼塊而言,javac在編譯時,會生成對應的monitorenter和monitorexit指令,分別對應synchronized同步塊的進入和退出,有兩個monitorexit指令的原因是:爲了保證拋異常的情況下也能釋放鎖,所以javac爲同步代碼塊添加了一個隱式的try-finally,在finally中會調用monitorexit命令釋放鎖。而對於synchronized方法而言,javac爲其生成了一個ACC_SYNCHRONIZED關鍵字,在JVM進行方法調用時,發現調用的方法被ACC_SYNCHRONIZED修飾,則會先嚐試獲得鎖。在JVM底層,對於這兩種synchronized語義的實現大致相同。

當JVM執行字節碼,遇到monitorenter指令時,便認爲是線程在申請synchronized鎖,隨後JVM解析monitorenter指令,執行一些列操作來實現鎖的語義(保證同一時刻同一個鎖保護的代碼只能被一個線程執行)。Java版本不同,JVM解析monitorenter指令的結果也不一樣。Java6之前,鎖只有一種重量級鎖形式。但是Java6引入了synchronized的一些列優化,鎖的形式也豐富了很多,出現了偏向鎖、輕量級鎖、重量級鎖。(這裏可以這樣理解,Java6之前,JVM解析monitorenter指令只有一種結果,那就是重量級鎖。但是Java6及之後的Java版本,JVM解析monitorenter指令時,會先嚐試使用偏向鎖,輕量級鎖,這兩種方式滿足不了,纔會升級爲重量級鎖。但無論使用那種鎖,都可以保證synchronized的語義。)

重量級鎖依賴於系統的同步函數,在linux上使用mutex互斥鎖,最底層實現依賴於futex,這些同步函數都涉及到用戶態和內核態的切換、進程的上下文切換,成本較高。對於加了synchronized關鍵字但運行時並沒有多線程競爭,或兩個線程接近於交替執行的情況,使用傳統鎖機制無疑效率是會比較低的

Java6之前,synchronized只有傳統的鎖機制,因此給開發者留下了synchronized關鍵字相比於其他同步機制性能不好的印象。Java6引入了兩種新型鎖機制:偏向鎖和輕量級鎖,它們的引入是爲了解決在沒有多線程競爭基本沒有競爭的場景下因使用傳統鎖機制帶來的性能開銷問題。

3.2 對象頭

在Java中任意對象都可以用作鎖,因此必定要有一個映射關係,存儲該對象以及其對應的鎖信息(比如當前哪個線程持有鎖,哪些線程在等待)。一種很直觀的方法是,用一個全局map,來存儲這個映射關係,但這樣會有一些問題:需要對map做線程安全保障,不同的synchronized之間會相互影響,性能差。另外當同步對象較多時,該map可能會佔用比較多的內存。

所以最好的辦法是將這個映射關係存儲在對象頭中,因爲對象頭本身也有一些hashcode、GC相關的數據,所以如果能將鎖信息與這些信息共存在對象頭中就好了。

在JVM中,對象在內存中除了本身的數據外還會有個對象頭,對於普通對象而言,其對象頭中有兩類信息:mark word和類型指針。另外對於數組而言還會有一份記錄數組長度的數據。

類型指針是指向該對象所屬類對象的指針,mark word用於存儲對象的HashCode、GC分代年齡、鎖狀態等信息。在32位系統上mark word長度爲32bit,64位系統上長度爲64bit。爲了能在有限的空間裏存儲下更多的數據,其存儲格式是不固定的,在32位系統上各狀態的格式如下:

可以看到鎖信息也是存在於對象的mark word中的。當對象狀態爲偏向鎖(biasable)時,mark word存儲的是偏向的線程ID。當狀態爲輕量級鎖(lightweight locked)時,mark word存儲的是指向線程棧中Lock Record的指針。當狀態爲重量級鎖(inflated)時,爲指向堆中的monitor對象的指針。

3.3 重量級鎖

重量級鎖是我們常說的傳統意義上的鎖,其底層利用操作系統底層的同步機制去實現Java中的線程同步。重量級鎖的狀態下,對象的mark word爲指向一個堆中monitor對象的指針。一個monitor對象包括這麼幾個關鍵字段:ContentionList,EntryList,OnDeck,Owner,WaitSet。其中ContentionList ,EntryList ,WaitSet都是由ObjectWaiter的鏈表結構,Owner指向持有鎖的線程。

具體流程如下:

  1. 如果線程A執行Obj對象的同步方法,通過對象頭查找到Monitor的位置,然後線程A會進入WaitQueue區域,該區域主要是用於存儲所有競爭鎖資源的線程,多個線程同時競爭鎖資源,只會有一個線程競爭成功,其它線程就會存儲到該區域中,該區域主要維護兩個隊列:
    • ContentionList:當一個線程嘗試獲得鎖時,如果該鎖已經被佔用,請求鎖的線程會封裝一個ObjectWaiter對象放置到該競爭隊列ContentionList的尾部,然後暫停當前線程
    • EntryList:持有鎖的線程(Owner)釋放鎖時會把ContentionList中的ObjectWaiter移到EntryList,這個設計一方面也是從性能方面考慮:ContentionList在高併發場景下不斷的有新線程加入該隊列,並且存在多個線程同時操作ContentionList,所以要進行同步控制,如果持有鎖的線程(Owner)釋放鎖時直接從ContentionList獲取要競爭鎖的ObjectWaiter顯然存在併發訪問問題。所以,持有鎖的線程(Owner)釋放鎖時首先會從ContentionList中的元素移到到EntryList中,然後從EntryList中獲取要競爭鎖的ObjectWaiter,一般都是將EntryList的head賦值給OnDheck,EntryList不會存在併發訪問問題,因爲只有Owner線程纔會從EntryList中提取數據,且也只有Owner才能從ContentionList遷移線程到EntryList中,所以不用進行併發控制,性能更好
  2. ReadyThread區域主要是存儲下一個可以參與競爭鎖資源的線程,等持有鎖的線程釋放鎖時,讓OnDeck指向的線程參與鎖競爭,OnDeck中的元素就是在持有鎖的線程釋放鎖前,從EntryList的head取出的元素。注意:Waiting Queue中只會有一個線程參與競爭,一般是FIFO方式參與競爭,避免所有等待線程一起競爭鎖資源造成性能問題。
    • OnDeck要競爭鎖資源,而不是將Owner的鎖資源直接傳遞給OnDeck線程,OnDeck只代表有資格競爭鎖資源的線程,競爭鎖資源就意味着可能會失敗,失敗就意味着這是一種非公平鎖的實現機制。到底哪些線程會和OnDeck線程競爭鎖資源呢?就是當前新加入申請鎖資源的線程們,因爲我們知道,只有申請鎖失敗的線程纔會放入到ContentionList,現在假如新加入的線程還在剛申請,走了狗屎運這時剛好Owner線程釋放了鎖資源,這就導致了這些新加入線程會和OnDeck一起競爭鎖資源,這些新加入的線程可能優先競爭到鎖資源,這就是非公平性的體現。這麼做主要是從性能方面考慮,畢竟新線程如果競爭失敗要做一大堆初始化工作然後放入到等待隊列ContentionList中,而OnDeck線程競爭失敗只需要重新阻塞即可,顯然工作量要小很多。但是,進入Waiting Queue中的線程基本上是按照先進先出FIFO策略獲取到鎖資源的,因此,這種機制只會犧牲一定的公平性。另外,至少OnDeck線程還可以參與競爭,而不是從性能考慮直接讓新線程獲取到鎖,避免等待隊列中線程餓死現象
  3. RunningThread區域主要是存儲當前獲取到鎖後正在運行的線程,使用Owner指向當前運行線程
  4. BlockingQueue區域主要是存儲那些獲取到鎖資源但是調用wait等方法被阻塞的線程,由於wait操作會釋放當前鎖,即Owner會被重置爲null,當前線程進入WaitSet中,同時OnDeck線程參與鎖競爭獲取鎖資源,等被阻塞的線程被喚醒後會被移入EntryList重新等待獲取鎖資源

現在我們回過頭想一個問題,爲什麼wait()和notify()/notifyAll()方法必須在同步代碼塊或同步方法中?

調用wait()時會釋放鎖,進入BlockingQueue,並喚醒EntryList首部的元素進入OnDeck,去競爭鎖資源。當調用notify()/notifyAll()方法時,會將BlockingQueue中的元素移到EntryList,讓這部分線程有重新競爭鎖的機會。也就是講無論時wait(),還是notify()/notifyAll()都涉及Monitor中BlockingQueue和EntryList隊列結構的改變,而Monitor是線程私有的,所有者就是當前持有鎖的線程,沒有持有鎖的線程是無權改變Monitor中BlockingQueue和EntryList隊列結構的

使用重量級鎖時,多個線程競爭鎖資源要藉助底層系統的Mutex Lock互斥鎖實現,需要由用戶態切換到內核態,由內核協調哪個線程獲取到鎖,哪些線程無法獲取到鎖,獲取鎖失敗的線程會被內核進行阻塞,線程阻塞才能釋放CPU資源。系統執行完後,會由內核態重新切換到用戶態,將CPU的控制權交給獲取鎖的線程進行執行

3.2 輕量級鎖

JVM的開發者發現在很多情況下,在Java程序運行時,同步塊中的代碼都是不存在競爭的,而是不同的線程交替的執行同步塊中的代碼。這種情況下,用重量級鎖的代價是很高的,因爲每次申請鎖釋放鎖都要依賴於操作系統的同步函數,涉及用戶態和內核態的轉化。因此JVM引入了輕量級鎖的概念,以適應這種多個線程交替執行同步代碼塊中的代碼或輕微鎖競爭的情況

線程在執行同步塊之前,JVM會先在當前的線程的棧幀中創建一個Lock Record,其包括一個用於存儲對象頭中的 mark word(官方稱之爲Displaced Mark Word)以及一個指向對象的指針。下圖右邊的部分就是一個Lock Record。

3.1.1 加鎖過程

  1. 在線程棧中創建一個Lock Record,將其obj(即上圖的Object reference)字段指向鎖對象
  2. 直接通過CAS指令將Lock Record的地址存儲在對象頭的mark word中,如果對象處於無鎖狀態則修改成功,代表該線程獲得了輕量級鎖。如果失敗,進入到步驟3
  3. 如果是當前線程已經持有該鎖了,代表這是一次鎖重入。設置Lock Record第一部分(Displaced Mark Word)爲null,起到了一個重入計數器的作用。然後結束
  4. 走到這一步說明發生了競爭,則會自旋獲取鎖,如果自旋失敗膨脹爲重量級鎖

3.1.2 解鎖過程

  1. 遍歷線程棧,找到所有obj字段等於當前鎖對象的Lock Record
  2. 如果Lock Record的Displaced Mark Word爲null,代表這是一次重入,將obj設置爲null後continue
  3. 如果Lock Record的Displaced Mark Word不爲null,則利用CAS指令將對象頭的mark word恢復成爲Displaced Mark Word。如果成功,則continue,否則膨脹爲重量級鎖

 

這裏我們看一個問題,在輕量級鎖存在競爭時,並不是立即升級爲重量級鎖,這是爲什麼?

膨脹爲重量級鎖會涉及到有用戶態切換到內核態進行線程的休眠和喚醒操作,然後再切換到用戶態,這些操作給系統的併發性能帶來了很大的壓力,共享數據的鎖定狀態可能只會持續很短的一段時間,爲了這段時間去掛起和恢復線程並不值得,可以讓後面請求鎖的那個線程”稍等一下”,但不放棄處理器的執行時間,看看持有鎖的線程是否很快就會釋放鎖。爲了讓線程等待,只需要讓線程執行一個忙循環(自旋),所以自旋會對CPU造成資源浪費,特別是長時間無法獲取鎖的情況下,所以自旋次數一定要設置成一個合理的值,而不能無限自旋下去。Java6默認是開啓了自旋鎖功能,而且對自旋次數也不再是固定值,而是通過一套優化機制進行自適應,簡化了對自旋鎖的使用。但同時需要注意的是,自旋在多處理器上纔有意義,這理解也很簡單:自旋是不會釋放CPU資源的,在單處理器上如果某個線程處於自旋狀態,也就意味着沒有其它線程處於同時處於運行狀態,也就在自旋期間不可能存在線程釋放鎖資源。所以,單處理上自旋是沒有意義的

總結一下輕量級鎖:“輕量級”是相對於使用操作系統互斥量來實現的傳統鎖而言的。首先需要強調一點的是,輕量級鎖並不是用來代替重量級鎖的,它的本意是在沒有多線程競爭的前提下,減少傳統的重量級鎖使用產生的性能消耗。輕量級鎖在申請鎖資源時通過一個CAS操作即可獲取,釋放鎖資源時也是通過一個CAS操作即可完成,CAS是一種樂觀鎖的實現機制,其開銷顯然要比互斥開銷小很多,這就是輕量級鎖提升性能的核心所在。但是,輕量級鎖只是對無鎖競爭併發場景下的一個優化,如果鎖競爭激烈,輕量級鎖不但有互斥開銷,還要多一次CAS開銷,這時輕量級鎖比重量級鎖性能更差

3.2 偏向鎖

Java是支持多線程的語言,爲了保證線程安全,都會加入如synchronized這樣的同步語義。但是在應用在實際運行時,很可能只有一個線程會調用相關同步方法。比如下面這個demo:

public class SyncDemo1 {

    public static void main(String[] args) {
        SyncDemo1 syncDemo1 = new SyncDemo1();
        for (int i = 0; i < 100; i++) {
            syncDemo1.addString("test:" + i);
        }
    }

    private List<String> list = new ArrayList<>();

    public synchronized void addString(String s) {
        list.add(s);
    }

}

在這個demo中爲了保證對list操縱時線程安全,對addString方法加了synchronized的修飾,但實際使用時卻只有一個線程調用到該方法,對於輕量級鎖而言,每次調用addString時,加鎖解鎖都有一個CAS操作。

JVM工程師們經過研究發現:在大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,所以Java6中爲了提高一個對象在一段很長的時間內都只被一個線程持有場景下的性能,引入了偏向鎖,在第一次獲得鎖時,會有一個CAS操作,之後該線程再獲取鎖,只會執行幾個簡單的命令,而不是開銷相對較大的CAS命令。

當JVM啓用了偏向鎖模式(Java6以上默認開啓),當新創建一個對象的時候,如果該對象所屬的class沒有關閉偏向鎖模式,那新創建對象的mark word將是可偏向狀態,此時mark word中的thread id(參見上面mark word格式)爲0,表示未偏向任何線程,也叫做匿名偏向(anonymously biased)。

3.2.1 加鎖過程

  1. 當該對象第一次被線程獲得鎖的時候,發現是匿名偏向狀態,則會用CAS指令,將mark word中的thread id由0改成當前線程Id。如果成功,則代表獲得了偏向鎖,繼續執行同步塊中的代碼。否則,將偏向鎖撤銷,升級爲輕量級鎖。
  2. 當被偏向的線程再次進入同步塊時,發現鎖對象偏向的就是當前線程,會往當前線程的棧中添加一條Displaced Mark Word爲空的Lock Record中,然後繼續執行同步塊的代碼,因爲操縱的是線程私有的棧,因此不需要用到CAS指令;由此可見偏向鎖模式下,當被偏向的線程再次嘗試獲得鎖時,不需要像上面輕量級鎖加鎖步驟2那樣進行一次cas操作在這種情況下,synchronized關鍵字帶來的性能開銷基本可以忽略
  3. 當其他線程進入同步塊時,發現已經有偏向的線程了,則會進入到撤銷偏向鎖的邏輯裏,一般來說,會在safepoint中去查看偏向的線程是否還存活,如果存活且還在同步塊中則將鎖升級爲輕量級鎖,原偏向的線程繼續擁有鎖,當前線程則走入到鎖升級的邏輯裏;如果偏向的線程已經不存活或者不在同步塊中,則將對象頭的mark word改爲無鎖狀態(unlocked),之後再升級爲輕量級鎖

由此可見,偏向鎖升級的時機爲:當鎖已經發生偏向後,只要有另一個線程嘗試獲得偏向鎖,則該偏向鎖就會升級成輕量級鎖。當然這個說法不絕對,因爲還有批量重偏向和批量撤銷偏向機制

3.2.2 解鎖過程

當有其他線程嘗試獲得鎖時,是根據遍歷偏向線程的lock record來確定該線程是否還在執行同步塊中的代碼。因此偏向鎖的解鎖很簡單,僅僅將棧中的最近一條lock record的obj字段設置爲null。需要注意的是,偏向鎖的解鎖步驟中並不會修改對象頭中的thread id,這樣同一個線程下次進入同步代碼塊時,就不需要在通過CAS修改mark word的thread id了。

3.2.3 偏向鎖優化

從上文偏向鎖的加鎖解鎖過程中可以看出,當只有一個線程反覆進入同步塊時,偏向鎖帶來的性能開銷基本可以忽略,但是當有其他線程嘗試獲得鎖時,就需要等到safe point時將偏向鎖撤銷爲無鎖狀態或升級爲輕量級/重量級鎖。偏向鎖的撤銷是有一定成本的,如果說運行時的場景本身存在多線程競爭的,那偏向鎖的存在不僅不能提高性能,而且會導致性能下降。因此,JVM中增加了一種批量重偏向/撤銷的機制。下面來看一下如下這兩種場景:

  • 場景1

一個線程創建了大量對象並執行了初始的同步操作,之後在另一個線程中將這些對象作爲鎖進行之後的操作。

public class TestSynchronized {
    public static void main(String[] args) {
        List<MyLock> myLockList = Stream.generate(MyLock::new).limit(60).collect(Collectors.toList());
        System.out.println(myLockList);

        for (MyLock lock : myLockList) {
            new Thread(new ThreadA(lock)).start();
        }
    }


    private static class ThreadA implements Runnable{
        private MyLock lock;

        public ThreadA(MyLock lock) {
            this.lock = lock;
        }

        @Override
        public void run() {
            synchronized (lock) {
                new Thread(new ThreadB(lock)).start();
            }
        }
    }

    private static class ThreadB implements Runnable {
        private MyLock lock;

        public ThreadB(MyLock lock) {
            this.lock = lock;
        }

        @Override
        public void run() {
            synchronized (lock) {
                System.out.println("ThreadB run");
            }
        }
    }
}

這個例子中,ThreadA獲取鎖之後就做了一件事情,將鎖對象傳遞給ThreadB並啓動ThreadB,但是MyLock的偏向鎖是偏向ThreadA的。ThreadB申請鎖時,會觸發偏向鎖撤銷的邏輯。這種情況下,一旦ThreadB啓動,其實MyLock就不用偏向ThreadA了,如果能讓MyLock偏向ThreadB,而不需要進行偏向鎖撤銷,肯定更合適。

  • 場景2

存在明顯多線程競爭的場景,比如生產者/消費者模式。

批量重偏向機制是爲了解決第一種場景,批量撤銷機制則是爲了解決第二種場景。

3.2.3.1 批量重偏向

上面我們講到,每個對象都有一個mark word,用於記錄一些元數據。其實Class對象也有元數據,Class對象元數據裏有一個epoch,爲了區分對象mark word中的epoch,我們把Class對象的epoch記爲Ec,還有個偏向計數器記爲Bc,這個Bc 是類概念上的。每進行一次撤銷偏向,Bc+1。每個對象頭mark word也有一個epoch,我們記爲E。

當 Bc=20時,系統認爲該類的對象可能有不合適偏向鎖的使用情況,這時候,會執行批量重偏向。具體操作是,將Class元數據上的Ec+1,並遍歷所有線程的線程棧。因爲當前是在全局安全點,所以該操作是安全的。找到所有線程棧中該Class的實例對象,如果還在鎖着,就將該對象上的epoch設爲Ec。結果就是所有還被佔用着的偏向鎖對象的E=Ec,此爲E有效,而已經退出同步塊的E!=Ec,此爲E失效。對象mark word鎖標誌位爲01 && 是否爲偏向鎖位爲1(是) && E失效的條件下,當有線程申請鎖時,即使該鎖偏向其它線程,也可以直接通過CAS將mark word的Thread Id修改爲當前申請鎖的線程,而不用走線程撤銷邏輯。

3.2.3.2 批量撤銷

批量重偏向後,如果繼續遇到撤銷偏向鎖操作,Bc繼續+1,如果在一定時間間隔(25秒)內,並沒有達到閾值,這表明批量重偏向起到了很好的效果,Bc將被清零重新開始。但如果達到了新的閾值Bc=40,表明該Class類型對象使用上有問題,不適合使用偏向鎖模式,將執行批量撤銷偏向。首先將Class元數據置爲無鎖(偏向鎖不可偏向)模式,然後遍歷所有線程的線程棧,將所有該Class的實例對象撤銷偏向,使偏向鎖不可偏向。

以上就是Java6對synchronized的優化,這裏借鑑一下《Java併發編程的藝術》書中的總結,如下:

優點 缺點 適用場景
偏向鎖 加鎖和解鎖不需要額外的消耗,和執行非同步方法比僅存在納秒級的差距 如果線程間存在鎖競爭,會帶來額外的鎖撤銷的消耗 適用於只有一個線程訪問同步塊場景(一個線程多次獲取鎖)
輕量級鎖 競爭的線程不會阻塞,提高了程序的響應速度 如果始終得不到鎖競爭的線程使用自旋會消耗CPU 追求響應時間,鎖佔用時間很短(多個線程交替獲取鎖,或接近交替)
重量級鎖 線程競爭不使用自旋,不會消耗CPU 線程阻塞,響應時間緩慢 追求吞吐量,鎖佔用時間較長(鎖競爭激烈)

這裏我覺得書中給出的輕量級鎖的缺點和重量級鎖的有點有些問題,這裏給一下我自己的理解。我覺得輕量級鎖最大的問題不是使用自旋,而是如果存在激烈的鎖競爭,最終還是會升級爲重量級鎖,除了自旋的消耗外,還有鎖升級的消耗。而重量級鎖,底層依賴於操作系統的同步函數,在Linux中使用的是futex,而futex是有自旋機制的,所以我覺得書中講的重量級鎖不適用自旋有可能是不合適的。當然上面只是我自己的理解,如果有大佬比較清楚的話,希望可以解答一下。最後附上一張synchronized鎖狀態的流轉圖:

參考鏈接:

1. 死磕Synchronized底層實現

2. 《Java併發編程的藝術》

3. Synchronized底層優化

4. 併發編程鎖之synchronized總結

5. Java synchronized實現原理與優化

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章