天天看點

JAVA鎖優化鎖優化思路虛拟機的鎖優化

鎖優化思路

最好的方式不加鎖,如果必須加鎖,可以從如下幾個方面入手進行鎖優化:

1. 減少鎖持有時間
2. 減小鎖粒度
3. 讀寫鎖替代獨占鎖
4. 鎖分離
5. 鎖粗化           

減少鎖的持有時間

減少鎖的持有時間,即減少鎖内代碼執行時間,可以通過減少鎖内代碼量實作,例如避免給整個方法加鎖、将不需要加鎖的代碼移出去,例如:

public synchronized void doSomething() { 
     System.out.println("before");
     needLockCode(); 
     System.out.println("after");
 }
 
 改為:
 
 public void doSomething() { 
     System.out.println("before");
     synchronized(this){ 
         needLockCode(); 
     } 
     System.out.println("after");
 }           

或:

public void doSomething() { 
     synchronized(this){ 
         System.out.println("before");
         needLockCode(); 
         System.out.println("after");
     } 
 }
 
 改為:
 
  public void doSomething() { 
     System.out.println("before");
     synchronized(this){ 
         needLockCode(); 
     } 
     System.out.println("after");
 }           

減小鎖的粒度

減小鎖的粒度,這個偏向于減小被鎖住代碼涉及的影響範圍的減小,降低鎖競争的幾率,例如jdk5的ConcurrentHashMap,ConcurrentHashMap不會為整個hash表加鎖,而是将Hash表劃分為多個分段,對每個段加鎖,這樣減小了鎖粒度,提升了并發處理效果。

再如假設有對象object,如果加鎖後,不允許對object操作,此時鎖粒度相當于object對象,如果實際上object隻有一個名為

needLock

字段可能會出現并發問題,此時将鎖加在這個字段上即可。

讀寫鎖替代獨占鎖

ReentrantLock和synchronized使用的是獨占鎖,無論是讀或寫都保證同時隻有一個線程執行被鎖代碼。但是單純的讀實際上不會引起并發問題。尤其是對于讀多寫少的場景,可以将讀和寫的鎖分離開來,可以有效提升系統的并發能力。

讀寫鎖在同一時刻可以允許多個線程通路,但是在寫線程通路時,所有的讀線程和其他寫線程都會被阻塞。讀寫鎖維護了一對鎖:讀鎖和寫鎖。一般情況下,讀寫鎖的性能都會比排它鎖好,因為大多數場景讀是多于寫的。

當執行讀操作的時候,需要擷取讀鎖,在并發通路的時候,讀鎖不會被阻塞;在執行寫操作時線程必須要擷取寫鎖,當已經有線程持有寫鎖的情況下,所有的線程都會被阻塞。讀鎖和寫鎖關系:

讀鎖與讀鎖可以共享;
讀鎖與寫鎖互斥;
寫鎖與寫鎖互斥。           

ReentrantReadWriteLock是提供了讀鎖和寫鎖:

public class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable {
    ...
    /** Inner class providing readlock */
    private final ReentrantReadWriteLock.ReadLock readerLock;

    /** Inner class providing writelock */
    private final ReentrantReadWriteLock.WriteLock writerLock;
    ...
}           

鎖分離

在讀寫鎖的思想上做進一步的延伸,如果對兩個上下文互相不依賴、互相不影響的操作使用了同一把鎖,這時候可以把鎖進行拆分,根據不同的功能拆分不同的鎖, 進行有效的鎖分離。

一個典型的示例便是LinkedBlockingQueue,在它内部,take()和put()分别實作了從隊列中取得資料和往隊列中增加資料的功能,雖然兩個方法都對目前隊列進行了修改操作,但由于目前隊列為連結清單實作,兩個操作分别作用于隊列的前端和尾端,從理論上說,兩者并不沖突。

如果使用獨占鎖,那麼同一時間兩個操作不能同時進行,會因為等待鎖資源而阻塞。但是兩個操作實際上是不沖突的,這時候可以使take()和put()各自使用一把鎖,提高并發效率。LinkedBlockingQueue中為兩個操作分别準備了takeLock和putLock:

1     /** Lock held by take, poll, etc */
 2     private final ReentrantLock takeLock = new ReentrantLock();
 3 
 4     /** Wait queue for waiting takes */
 5     private final Condition notEmpty = takeLock.newCondition();
 6 
 7     /** Lock held by put, offer, etc */
 8     private final ReentrantLock putLock = new ReentrantLock();
 9 
10     /** Wait queue for waiting puts */
11     private final Condition notFull = putLock.newCondition();           

鎖粗化

必要的時候,将被鎖住的代碼量變多、鎖持有時間更長也是鎖優化的方式,但優化結果一定要使整體的執行效率變的更好,例如:

for(int i = 0; i < 100; i++) {
     synchronized(lock) {
         needLockCode();             
     }
 }

 改為:

 synchronized(lock) {
     for(int i = 0; i < 100; i++) {
         needLockCode();
     }
 }           

改造後,盡管每個線程每次持有鎖的時間變長了,但減少了每個線程請求和釋放鎖的次數,而請求和釋放鎖也是要消耗資源的。

虛拟機的鎖優化

1、自旋鎖與自适應自旋

由于挂起線程和恢複線程都需要轉入核心态完成,給系統帶來很大壓力,同時,共享資料的鎖定狀态隻會持續很短的一段時間,是以去挂起和恢複線程很不值得。是以,可以使線程執行一個自我循環,因為對于執行時間短的代碼這一會可能就會釋放鎖,而線程就不需要進行一次阻塞與喚醒。

自旋等待不能代替阻塞,自旋本身雖然避免了線程切換的開銷,但是會占用處理器時間,如果鎖被占用時間短,自旋等待效果好;反之,自旋的線程隻會白白浪費處理器資源;是以,要限制自旋等待時間,自旋次數預設值是10次,超過次數仍然沒有成功擷取鎖,就挂起線程,進入同步阻塞狀态。

自适應自旋更智能一些,它根據前一次在同一個鎖上的自旋時間以及鎖的擁有者的狀态來決定自旋次數,如果對于某個鎖的自旋很少有成功獲得過鎖,就不自旋了,避免浪費CPU資源。如果自旋等待剛剛成功獲得過鎖,并且持有鎖的線程在運作,則認為此次自旋很有可能成功,就允許自旋更多的次數。

2. 鎖消除

鎖消除是指虛拟機即時編譯器在運作時,對一些代碼上要求同步,但是被檢測到不可能存在共享資料競争的鎖進行消除。鎖消除的目的主要是判定依據來源于逃逸分析的資料支援,如果判斷在一段代碼中,堆上的所有資料都不會逃逸出去進而被其他線程通路到,那就可以把他們當作棧資料對待,認為它們是線程私有的,同步加鎖自然就無需進行。

有時候鎖是開發者無意中涉及到的,例如對于下面代碼:

public static String getStr(String s1, String s2) {
        return s1 + s2;
    }           

隻進行了字元串的拼接,但其中的

s1 + s2

可能被虛拟機優化為:

StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb.toString();           

而append()涉及了synchronized:

@Override
    public synchronized StringBuffer append(String str) {
        toStringCache = null;
        super.append(str);
        return this;
    }           

append()中的鎖就是sb對象,如果該對象在方法中new的話,sb對象就不會逃逸到方法以外,jvm認為此時不必要加鎖,此處的鎖就被安全的消除了。

3. 鎖粗化

原則上,我們在編寫代碼的時候,總是推薦将同步塊的作用範圍限制得盡量小——隻在共享資料的實際作用域中才進行同步,這樣是為了使需要同步的操作數量盡可能變小,如果存在鎖競争,那等待鎖的線程也能盡快拿到鎖。

但如果一系列操作頻繁對同一個對象加鎖解鎖,或者加鎖操作再循環體内,會耗費性能,這時虛拟機會擴大加鎖範圍來減少擷取鎖、釋放鎖的操作。具體可以看上文示例。

4. 輕量級鎖

輕量級鎖是JDK6之中加入的新型鎖機制,它名字中的“輕量級”是相對于使用作業系統互斥量來實作的傳統鎖而言的,是以傳統的鎖機制就稱為“重量級”鎖。首先需要強調一點的是,輕量級鎖并不是用來代替重量級鎖的,它的本意是在沒有多線程競争的前提下減少傳統的重量級鎖使用作業系統互斥量産生的性能消耗。

在代碼進入同步塊的時候,如果同步對象沒有被鎖定,虛拟機首先将在目前線程的棧幀中建立一個名為鎖記錄( Lock Record)的空間,用于存儲鎖對象目前的Mark Word的拷貝,虛拟機将使用CAS操作嘗試将對象的Mark Word更新為指向Lock Record的指針。如果這個更新動作成功了,那麼這個線程就擁有了該對象的鎖,并且對象 Mark Word的鎖标志位(Mark Word的最後2bit)将轉變為“00”,即表示此又對象處于輕量級鎖定狀态。

如果這個更新操作失敗了,虛拟機首先會檢查對象的Mark Word是否指向目前線程的棧幀,如果是說明目前線程已經擁有了這個對象的鎖,那就可以直接進入同步塊繼續執行,否則說明這個鎖對象已經被其他線程搶占了,如果有兩條以上的線程争用同一個鎖,那輕量級鎖就不再有效,自旋失敗後要膨脹為重量級鎖,Mark Word中存儲的就是指向重量級鎖的指針,後面等待鎖的線程也要進入阻塞狀态。

輕量級鎖能提升程式同步性能的依據是“對于絕大部分的鎖,在整個同步周期内都是不存在競争”,這是一個經驗資料。如果沒有競争,輕量級鎖使用CAS操作避免了使用互斥量的開銷,但如果存在鎖競争,除了互斥量的開銷外,還額外發生了CAS操作,是以在有競争的情況下,輕量級鎖會比傳統的重量級鎖更慢。

5. 偏向鎖

大多數情況下,鎖不僅不存在多線程競争,而且總是由同一線程多次獲得,為了讓線程獲得鎖的代價更低而引入了偏向鎖。如果說輕量級鎖是在無競争的情況下使用CAS操作去消除同步使用的互斥量,那偏向鎖就是在無競争的情況下把整個同步都消除掉,連CAS操作都不做了。

當鎖對象第一次被線程擷取的時候,虛拟機将會把對象頭中的标志位設為“01”,即偏向模式。同時使用CAS操作把擷取到這個鎖的線程的ID記錄在對象的Mark Word之中,如果CAS操作成功,持有偏向鎖的線程以後每次進入這個鎖相關的同步塊時,虛拟機都可以不再進行任何同步操作(例如Locking、Unlocking及對Mark Word的Update等)。當有另外一個線程去嘗試擷取這個鎖時,偏向模式就宣告結束。

也就是說,偏向鎖會偏向第一個獲得它的線程,隻有當其它線程嘗試競争偏向鎖時,偏向模式才會失效。偏向鎖是為了避免某個線程反複執行擷取、釋放同一把鎖時的性能消耗,即如果仍是同個線程去獲得這個鎖,偏向鎖模式會直接進入同步塊,不需要再次獲得鎖。

鎖的作用效果

偏向鎖是為了避免某個線程反複執行擷取、釋放同一把鎖時的性能消耗,而輕量級鎖和自旋鎖是為了避免重量級鎖,因為重量級鎖屬于作業系統層面的互斥操作,挂起和喚醒線程是非常消耗資源的操作。

鎖擷取過程

最終,鎖的擷取過程會是,首先會嘗試輕量級鎖,輕量級鎖會使用CAS操作來獲得鎖,如果輕量級鎖獲得失敗,說明存在多線程對鎖資源的競争。此時會會嘗試自旋鎖,如果自旋失敗,最終隻能膨脹為重量級鎖。

除重量級鎖外,以上鎖均為樂觀鎖。

注:本文内容參考自:《Java高并發程式設計》、《深入了解Java虛拟機》