Java高併發- 鎖的優化及 JVM 對鎖優化所做的努力

在高併發環境下,激烈的鎖競爭會導致程序的性能下降,所以我們有必要討論一下有關 鎖 的性能問題及注意事項。如:避免死鎖,減小鎖粒度,鎖分離等。

一、鎖優化

1.1 減小鎖持有時間
在鎖競爭過程中,單個線程對鎖的持有時間與系統性能有着直接的關係,如果線程持有鎖的時間很長,那麼相對地,鎖的競爭程序也就越激烈。
示例代碼:

public void syncMethod(){
fun1();
mutextMethod();
fun2();
}

在 syncMethod 方法中,假設只有 mutextMethod() 方法需要同步,而 fun1() 和 fun2() 不需要同步。如果 fun1() 和 fun2() 分別是重量級的方法,則會花費較大的 CPU 時間。此時如果在併發量較大,使用這種對整個方法做同步的方案,會導致等待線程大量增加。
一個較爲優化的方案是,只在必要時進行同步,這樣就能明顯減少線程持有鎖的時間,提高系統吞吐量。

public void syncMethod(){
fun1();
synchronized(this){
mutextMethod();
}
fun2();
)

在改進的代碼中,只針對 mutextMethod 方法做同步,鎖佔用時間相對較短,因此能提高並行度。這種技術手段在 JDK 的源碼總也可以嗯容易找到,如處理正則表達式的 Pattern 類:

public Matcher matcher(CharSequence input){
if(!compiled){
synchronized(this){
if(!compiled){
compile();
}
}
}
Matcher m = new Matcher(this,input);
return m;
}

matcher() 方法有添加的進行鎖申請,只有在表達式未編譯時,進行局部加鎖。這種處理大大提高了 matcher() 方法的執行效率和可靠性。
注意:減小鎖的持有時間有助於降低鎖衝突的可能性,進而提升系統的併發能力。
1.2 減小鎖力度
減小鎖力度也是一種削弱多線程鎖競爭的有效手段,這種技術的使用場景就是 ConcurrentHashMap 類的實現。
ConcurrentHashMap 不是直接鎖住整個 HashMap,而是在 ConcurrentHashMap 內部進一步細分了若干個小的 HashMap,稱之爲段(Segment)。在 ConcurrentHashMap 中,無論是讀操作還是寫操作都能保證很高的性能:在進行讀操作時 (幾乎) 不需要加鎖,而在寫操作時通過鎖分段技術只對所操作的段加鎖而不影響客戶端對其它段的訪問。特別地,在理想狀態下,ConcurrentHashMap 可以支持 16 個線程執行併發寫操作(如果併發級別設爲16),及任意數量線程的讀操作。
注意:所謂減小鎖粒度,就是縮小鎖對象的範圍,從而減少鎖衝突的可能性,進而提高系統的併發能力。
1.3 讀寫分離鎖類代替獨佔鎖
讀寫鎖 ReentrantReadWriteLock 可以提高系統性能,使用讀寫分離鎖來替代獨佔鎖是減小鎖粒度的一種特殊情況。讀操作本身不會影響數據的完整性和一致性,因此,大部分情況下,可以允許多線程同時讀,讀寫鎖正是實現了這種功能。
注意:在讀多寫少的場合,使用讀寫鎖可以有效提升系統併發能力。
1.4 鎖分離
將讀寫鎖思想進一步延伸,就是鎖分離。讀寫鎖根據讀寫操作功能上的不同,進行了有效的鎖分離。根據應用程序的功能特點,使用類似的分離思想,也可以對獨佔鎖進行分離。
典型案例就是 LinkedBlockingQueue 的實現,take() 和 put() 分別實現了從隊列中取得數據和往隊列中增加數據的功能。雖兩個數據都對當前隊列進行了修改操作,但由於 LinkedBlockingQueue 是基於鏈表的,因此兩個操作分別作用於隊列的前端和尾端。
如果使用獨佔鎖,則要求在兩個操作進行時獲取當前隊列的獨佔鎖,那麼 take() 和 put() 操作不可能真正的併發,在運行時,它們會彼此等待對方釋放資源。這種情況下,鎖競爭會相對比較激烈,從而影響呈現的高併發時的性能。
在 JDK 實現中,採用的是分離鎖思想,使用了兩把不同的鎖,分離 take() 和 put() 操作。

/** Lock held by take, poll, etc */
    private final ReentrantLock takeLock = new ReentrantLock();
 /** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition();
     /** Lock held by put, offer, etc */
    private final ReentrantLock putLock = new ReentrantLock(); 
    /** Wait queue for waiting puts */
    private final Condition notFull = putLock.newCondition();

上面的代碼定義了 takeLock 和 putLock 分別在 take() 和 put() 操作中使用,因此 take() 和 put() 相對獨立,它們之間不存在鎖競爭關係,只需要在 take() 和 take() 之間,put() 和 put() 之間分別對 takeLock 和 putLock 進行競爭。

public E take()throws InterruptedException{
E x;
int c = -1;
final ReetrantLock takeLock =this.takeLock;
   takeLock.lockInterruptibly(); //不能有兩個線程同時去數據
   try {
            while (count.get() == 0) { //如果當前沒有可用數據,則一直等到
                notEmpty.await();       //等待,put() 操作的通知
            }
            x = dequeue();              //取得第一個數據
            c = count.getAndDecrement();    //數量減 1,原子操作,因爲會和 put() 函數同時訪問 count
            if (c > 1)
                notEmpty.signal();          //通知其他 take() 操作
        } finally {
              takeLock.unlock();              //釋放鎖
        }
        if (c == capacity)
            signalNotFull();                //通知put()操作,已有空餘空間
        return x;}
put()
public void put(E e) throws InterruptedException {
    if (e == null) throw new NullPointerException();
        int c = -1;
        Node node = new Node(e);
        final ReentrantLock putLock = this.putLock;
        final AtomicInteger count = this.count;
        putLock.lockInterruptibly();    //不能有兩個線程同時去數據
        try {
            while (count.get() == capacity) {   //如果隊列已滿,等待
                notFull.await();
            }
            enqueue(node);                  //插入數據
            c = count.getAndIncrement();     //更新總數,count 加 1
            if (c + 1 < capacity)
                notFull.signal();           //有足夠的空間,通知其他線程
        } finally {
            putLock.unlock();               //釋放鎖
        }
        if (c == 0)
            signalNotEmpty();       //插入成功後,通知 take()操作取數據
    }

1.5 鎖粗化
爲保證多線程間有效併發,會要求每個線程持有鎖的時間儘量短,即在使用完公共資源後,應立即釋放資源。但是,如果對同一個鎖不停地請求,同步和釋放,其本身也會消耗系統寶貴的資源,反而不利於性能優化。
爲此,JVM 在遇到一連串連續地對同一鎖不斷請求和釋放的操作時,便會把所有的鎖操作整合成對鎖的一次性請求,從而減少對鎖的請求同步次數,這個操作叫做鎖的粗化。
尤其在循環內請求鎖的例子,這種情況下,意味着每次循環都有申請鎖和釋放鎖的操作。

for(int i=0; i<CIRCLE; i++){
    synchronized(lock){
        //do something
}
}

改進,在循環外層只請求一次鎖

synchronized(lock){
    for(int i=0; i<CIRCLE; i++){
        //do something
}
}

注意:性能優化就是根據運行時的真實情況對各個資源進行權衡折中的過程。鎖粗化的思想和減少鎖持有時間是相反的,但在不同場合,他們效果並不相同,所以大家需要根據實際情況進行權衡。

二、JVM 對鎖優化鎖做的努力

在 JDK1.6 之後,出現了各種鎖優化技術,如輕量級鎖、偏向鎖、適應性自旋、鎖粗化、鎖消除等,這些技術都是爲了在線程間更高效的解決競爭問題,從而提升程序的執行效率。
通過引入輕量級鎖和偏向鎖來減少重量級鎖的使用。鎖的狀態總共分四種:無鎖狀態、偏向鎖、輕量級鎖和重量級鎖。鎖隨着競爭情況可以升級,但鎖升級後不能降級,意味着不能從輕量級鎖狀態降級爲偏向鎖狀態,也不能從重量級鎖狀態降級爲輕量級鎖狀態。
無鎖狀態 → 偏向鎖狀態 → 輕量級鎖 → 重量級鎖
對象頭
要理解輕量級鎖和偏向鎖的運行機制,還要從瞭解對象頭(Object Header)開始。對象頭分爲兩部分:
1、Mark Word:存儲對象自身的運行時數據,如:Hash Code,GC 分代年齡、鎖信息。這部分數據在32位和64位的 JVM 中分別爲 32bit 和 64bit。考慮空間效率,Mark Word 被設計爲非固定的數據結構,以便在極小的空間內存儲儘量多的信息,32bit的 Mark Word 如下圖所示:
對象頭
2、存儲指向方法區對象類型數據的指針,如果是數組對象的話,額外會存儲數組的長度
2.1 偏向鎖
核心思想:當線程請求到鎖對象後,將鎖對象的狀態標誌位改爲 01,即偏向模式。然後使用 CAS 操作將線程的 ID 記錄在鎖對象的 Mark Word 中。以後該線程可以直接進入同步塊,連CAS操作都不需要。但是,一旦有第二條線程需要競爭鎖,那麼偏向模式立即結束,進入輕量級鎖的狀態。
與輕量級鎖的區別:輕量級鎖是在無競爭的情況下使用CAS操作來代替互斥量的使用,從而實現同步;而偏向鎖是在無競爭的情況下完全取消同步。
作用:偏向鎖是爲了消除無競爭情況下的同步原語,進一步提升程序性能。
優點:偏向鎖可以提高有同步但沒有競爭的程序性能。但是如果鎖對象時常被多條線程競爭,那偏向鎖就是多餘的。
2.2 輕量級鎖
如果偏向鎖失敗,JVM 並不會立即掛起線程。他還會使用一種稱爲輕量級鎖的優化手段。
核心思想:輕量級鎖將對象頭部作爲指針,指向持有鎖的線程堆棧的內部,來判斷一個線程是否持有對象鎖。如果線程獲得輕量級鎖成功,則可以順利進入臨界區,如果獲取輕量級鎖失敗,則表示其它線程先搶到了鎖,那麼線程的鎖請求就會膨脹爲重量級鎖。
前提:輕量級鎖比重量級鎖性能更高的前提是,在輕量級鎖被佔用的整個同步週期內,不存在其他線程的競爭。若在該過程中一旦有其他線程競爭,那麼就會膨脹成重量級鎖,從而除了使用互斥量以外,還額外發生了CAS操作,因此更慢!
輕量級鎖與重量級鎖的比較:
重量級鎖是一種悲觀鎖,它認爲總是有多條線程要競爭鎖,所以它每次處理共享數據時,不管當前系統中是否真的有線程在競爭鎖,它都會使用互斥同步來保證線程的安全;
而輕量級鎖是一種樂觀鎖,它認爲鎖存在競爭的概率比較小,所以它不使用互斥同步,而是使用CAS操作來獲得鎖,這樣能減少互斥同步所使用的『互斥量』帶來的性能開銷。
2.3 自旋鎖
鎖膨脹後,JVM 爲避免線程真正在操作系統層面掛起,JVM 做了最後的努力 — 自旋鎖。由於當前線程無法獲取鎖,但什麼時候獲取鎖是一個未知數。
因此,系統會進行一次賭注:它會假設在不久的將來,線程可以得到這把鎖,JVM 讓當前線程做幾個空循環(這是自旋的含義)。在經過若干次循環後,如果可以得到鎖,那麼久順利進入臨界區。如果還不能獲得鎖,纔會真正將線程在系統層面掛起。
優點:由於自旋等待鎖的過程線程並不會引起上下文切換,因此比較高效;
缺點:自旋等待過程線程一直佔用 CPU 執行權但不處理任何任務,因此若該過程過長,那就會造成 CPU 資源的浪費。
自適應自旋:自適應自旋可以根據以往自旋等待時間的經驗,計算出一個較爲合理的本次自旋等待時間。
2.4 鎖消除
鎖消除是一種更徹底的鎖優化,JVM 在 JIT 編譯時,通過對運行上下文的掃描,去除不可能存在的共享資源競爭的鎖,通過鎖消除,可以節省毫無意義的請求鎖的時間。
讀者可能產生疑問,如果不可能產生競爭,爲什麼還要加鎖?
在 Java 開發中,我們必然會使用 JDK 內置的 API,如 StringBuffer、Vector 等。你在使用這些類時,也許根本不會考慮這些對象到底內部是如何實現的。但是 Vector 內部使用了 synchronized 請求鎖。
文章分享結束,歡迎加入我們的討論組,一起溝通交流
個人整理了一些架構進階資料,需要的朋友可以自行領取。

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