深入併發鎖,解析Synchronized鎖升級

這篇文章分爲六個部分,不同特性的鎖分類,併發鎖的不同設計,Synchronized中的鎖升級,ReentrantLock和ReadWriteLock的應用,幫助你梳理 Java 併發鎖及相關的操作。

一、鎖有哪些分類

一般我們提到的鎖有以下這些:

  • 樂觀鎖/悲觀鎖
  • 公平鎖/非公平鎖
  • 可重入鎖
  • 獨享鎖/共享鎖
  • 互斥鎖/讀寫鎖
  • 分段鎖
  • 偏向鎖/輕量級鎖/重量級鎖
  • 自旋鎖

上面是很多鎖的名詞,這些分類並不是全是指鎖的狀態,有的指鎖的特性,有的指鎖的設計,下面分別說明。

1、樂觀鎖 VS 悲觀鎖

樂觀鎖與悲觀鎖是一種廣義上的概念,體現了看待線程同步的不同角度,在Java和數據庫中都有此概念對應的實際應用。

(1)樂觀鎖

顧名思義,就是很樂觀,每次去拿數據的時候都認爲別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個數據,可以使用版本號等機制。

樂觀鎖適用於多讀的應用類型,樂觀鎖在Java中是通過使用無鎖編程來實現,最常採用的是CAS算法,Java原子類中的遞增操作就通過CAS自旋實現的。

CAS全稱 Compare And Swap(比較與交換),是一種無鎖算法。在不使用鎖(沒有線程被阻塞)的情況下實現多線程之間的變量同步。java.util.concurrent包中的原子類就是通過CAS來實現了樂觀鎖。

簡單來說,CAS算法有3個三個操作數:

  • 需要讀寫的內存值 V。
  • 進行比較的值 A。
  • 要寫入的新值 B。

當且僅當預期值A和內存值V相同時,將內存值V修改爲B,否則返回V。這是一種樂觀鎖的思路,它相信在它修改之前,沒有其它線程去修改它;而Synchronized是一種悲觀鎖,它認爲在它修改之前,一定會有其它線程去修改它,悲觀鎖效率很低。

(2)悲觀鎖

總是假設最壞的情況,每次去拿數據的時候都認爲別人會修改,所以每次在拿數據的時候都會上鎖,這樣別人想拿這個數據就會阻塞直到它拿到鎖。

傳統的MySQL關係型數據庫裏邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。

  • 悲觀鎖適合寫操作多的場景,先加鎖可以保證寫操作時數據正確。
  • 樂觀鎖適合讀操作多的場景,不加鎖的特點能夠使其讀操作的性能大幅提升。

2、公平鎖 VS 非公平鎖

(1)公平鎖

就是很公平,在併發環境中,每個線程在獲取鎖時會先查看此鎖維護的等待隊列,如果爲空,或者當前線程是等待隊列的第一個,就佔有鎖,否則就會加入到等待隊列中,以後會按照FIFO的規則從隊列中取到自己。

公平鎖的優點是等待鎖的線程不會餓死。缺點是整體吞吐效率相對非公平鎖要低,等待隊列中除第一個線程以外的所有線程都會阻塞,CPU喚醒阻塞線程的開銷比非公平鎖大。

(2)非公平鎖

上來就直接嘗試佔有鎖,如果嘗試失敗,就再採用類似公平鎖那種方式。

非公平鎖的優點是可以減少喚起線程的開銷,整體的吞吐效率高,因爲線程有機率不阻塞直接獲得鎖,CPU不必喚醒所有線程。缺點是處於等待隊列中的線程可能會餓死,或者等很久纔會獲得鎖。

(3)典型應用

java jdk併發包中的ReentrantLock可以指定構造函數的boolean類型來創建公平鎖和非公平鎖(默認),比如:公平鎖可以使用new ReentrantLock(true)實現。

3、獨享鎖 VS 共享鎖

(1)獨享鎖

是指該鎖一次只能被一個線程所持有。

(2)共享鎖

是指該鎖可被多個線程所持有。

對於Java ReentrantLock而言,其是獨享鎖。但是對於Lock的另一個實現類ReadWriteLock,其讀鎖是共享鎖,其寫鎖是獨享鎖。

  • 讀鎖的共享鎖可保證併發讀是非常高效的,讀寫,寫讀 ,寫寫的過程是互斥的。
  • 獨享鎖與共享鎖也是通過AQS來實現的,通過實現不同的方法,來實現獨享或者共享。

(3)AQS

抽象隊列同步器(AbstractQueuedSynchronizer,簡稱AQS)是用來構建鎖或者其他同步組件的基礎框架,它使用一個整型的volatile變量(命名爲state)來維護同步狀態,通過內置的FIFO隊列來完成資源獲取線程的排隊工作。

concurrent包的實現結構如上圖所示,AQS、非阻塞數據結構和原子變量類等基礎類都是基於volatile變量的讀/寫和CAS實現,而像Lock、同步器、阻塞隊列、Executor和併發容器等高層類又是基於基礎類實現。

4、互斥鎖 VS 讀寫鎖

相交進程之間的關係主要有兩種,同步與互斥。所謂互斥,是指散佈在不同進程之間的若干程序片斷,當某個進程運行其中一個程序片段時,其它進程就不能運行它們之中的任一程序片段,只能等到該進程運行完這個程序片段後纔可以運行。所謂同步,是指散佈在不同進程之間的若干程序片斷,它們的運行必須嚴格按照規定的某種先後次序來運行,這種先後次序依賴於要完成的特定的任務。

顯然,同步是一種更爲複雜的互斥,而互斥是一種特殊的同步。
也就是說互斥是兩個線程之間不可以同時運行,他們會相互排斥,必須等待一個線程運行完畢,另一個才能運行,而同步也是不能同時運行,但他是必須要安照某種次序來運行相應的線程(也是一種互斥)!

總結:互斥:是指某一資源同時只允許一個訪問者對其進行訪問,具有唯一性和排它性。但互斥無法限制訪問者對資源的訪問順序,即訪問是無序的。

同步:是指在互斥的基礎上(大多數情況),通過其它機制實現訪問者對資源的有序訪問。在大多數情況下,同步已經實現了互斥,特別是所有寫入資源的情況必定是互斥的。少數情況是指可以允許多個訪問者同時訪問資源。

(1)互斥鎖

在訪問共享資源之前對進行加鎖操作,在訪問完成之後進行解鎖操作。 加鎖後,任何其他試圖再次加鎖的線程會被阻塞,直到當前進程解鎖。

如果解鎖時有一個以上的線程阻塞,那麼所有該鎖上的線程都被編程就緒狀態, 第一個變爲就緒狀態的線程又執行加鎖操作,那麼其他的線程又會進入等待。 在這種方式下,只有一個線程能夠訪問被互斥鎖保護的資源

(2)讀寫鎖

這個時候讀寫鎖就應運而生了,讀寫鎖是一種通用技術,並不是Java特有的。

讀寫鎖特點:

  • 多個讀者可以同時進行讀
  • 寫者必須互斥(只允許一個寫者寫,也不能讀者寫者同時進行)
  • 寫者優先於讀者(一旦有寫者,則後續讀者必須等待,喚醒時優先考慮寫者)

互斥鎖特點:

  • 一次只能一個線程擁有互斥鎖,其他線程只有等待

(3)Linux的讀寫鎖

Linux內核也支持讀寫鎖。

互斥鎖
pthread_mutex_init()
pthread_mutex_lock()
pthread_mutex_unlock()
讀寫鎖
 
pthread_rwlock_init()
pthread_rwlock_rdlock()
pthread_rwlock_wrlock()
pthread_rwlock_unlock()
條件變量
 
pthread_cond_init()
pthread_cond_wait()
pthread_cond_signal()

5、自旋鎖

自旋鎖(spinlock):是指當一個線程在獲取鎖的時候,如果鎖已經被其它線程獲取,那麼該線程將循環等待,然後不斷的判斷鎖是否能夠被成功獲取,直到獲取到鎖纔會退出循環。

在Java中,自旋鎖是指嘗試獲取鎖的線程不會立即阻塞,而是採用循環的方式去嘗試獲取鎖,這樣的好處是減少線程上下文切換的消耗,缺點是循環會消耗CPU。
典型的自旋鎖實現的例子,可以參考自旋鎖的實現

它是爲實現保護共享資源而提出一種鎖機制。其實,自旋鎖與互斥鎖比較類似,它們都是爲了解決對某項資源的互斥使用。無論是互斥鎖,還是自旋鎖,在任何時刻,最多只能有一個保持者,也就說,在任何時刻最多只能有一個執行單元獲得鎖。但是兩者在調度機制上略有不同。對於互斥鎖,如果資源已經被佔用,資源申請者只能進入睡眠狀態。

但是自旋鎖不會引起調用者睡眠,如果自旋鎖已經被別的執行單元保持,調用者就一直循環在那裏看是否該自旋鎖的保持者已經釋放了鎖,”自旋”一詞就是因此而得名。

(1)Java如何實現自旋鎖?

下面是個簡單的例子:

public class SpinLock {
    private AtomicReference<Thread> cas = new AtomicReference<Thread>();
    public void lock() {
        Thread current = Thread.currentThread();
        // 利用CAS
        while (!cas.compareAndSet(null, current)) {
            // DO nothing
        }
    }
    public void unlock() {
        Thread current = Thread.currentThread();
        cas.compareAndSet(current, null);
    }
}

lock()方法利用的CAS,當第一個線程A獲取鎖的時候,能夠成功獲取到,不會進入while循環,如果此時線程A沒有釋放鎖,另一個線程B又來獲取鎖,此時由於不滿足CAS,所以就會進入while循環,不斷判斷是否滿足CAS,直到A線程調用unlock方法釋放了該鎖。

(2)自旋鎖存在的問題

  1. 如果某個線程持有鎖的時間過長,就會導致其它等待獲取鎖的線程進入循環等待,消耗CPU。使用不當會造成CPU使用率極高。
  2. 上面Java實現的自旋鎖不是公平的,即無法滿足等待時間最長的線程優先獲取鎖。不公平的鎖就會存在“線程飢餓”問題。

(3)自旋鎖的優點

  1. 自旋鎖不會使線程狀態發生切換,一直處於用戶態,即線程一直都是active的;不會使線程進入阻塞狀態,減少了不必要的上下文切換,執行速度快
  2. 非自旋鎖在獲取不到鎖的時候會進入阻塞狀態,從而進入內核態,當獲取到鎖的時候需要從內核態恢復,需要線程上下文切換。 (線程被阻塞後便進入內核(Linux)調度狀態,這個會導致系統在用戶態與內核態之間來回切換,嚴重影響鎖的性能)

二、併發鎖的不同設計方式

根據所鎖的設計方式和應用,有分段鎖,讀寫鎖等。

1、分段鎖技術,併發鎖的一種設計方案

分段鎖其實是一種鎖的設計,並不是具體的一種鎖,對於ConcurrentHashMap而言,其併發的實現就是通過分段鎖的形式來實現高效的併發操作。

以ConcurrentHashMap來說一下分段鎖的含義以及設計思想,ConcurrentHashMap中的分段鎖稱爲Segment,它即類似於HashMap(JDK7與JDK8中HashMap的實現)的結構,即內部擁有一個Entry數組,數組中的每個元素又是一個鏈表;同時又是一個ReentrantLock(Segment繼承了ReentrantLock)。

當需要put元素的時候,並不是對整個hashmap進行加鎖,而是先通過hashcode來知道他要放在那一個分段中,然後對這個分段進行加鎖,所以當多線程put的時候,只要不是放在一個分段中,就實現了真正的並行的插入。

但是,在統計size的時候,可就是獲取hashmap全局信息的時候,就需要獲取所有的分段鎖才能統計。

分段鎖的設計目的是細化鎖的粒度,當操作不需要更新整個數組的時候,就僅僅針對數組中的一項進行加鎖操作。

2、鎖消除和鎖膨脹(粗化)

鎖消除,如無必要,不要使用鎖。Java 虛擬機也可以根據逃逸分析判斷出加鎖的代碼是否線程安全,如果確認線程安全虛擬機會進行鎖消除提高效率。

鎖粗化。如果一段代碼需要使用多個鎖,建議使用一把範圍更大的鎖來提高執行效率。Java 虛擬機也會進行優化,如果發現同一個對象鎖有一系列的加鎖解鎖操作,虛擬機會進行鎖粗化來降低鎖的耗時。

3、輪詢鎖與定時鎖

輪詢鎖是通過線程不斷嘗試獲取鎖來實現的,可以避免發生死鎖,可以更好地處理錯誤場景。Java 中可以通過調用鎖的 tryLock 方法來進行輪詢。tryLock 方法還提供了一種支持定時的實現,可以通過參數指定獲取鎖的等待時間。如果可以立即獲取鎖那就立即返回,否則等待一段時間後返回。

4、讀寫鎖

讀寫鎖 ReadWriteLock 可以優雅地實現對資源的訪問控制,具體實現爲 ReentrantReadWriteLock。讀寫鎖提供了讀鎖和寫鎖兩把鎖,在讀數據時使用讀鎖,在寫數據時使用寫鎖。

讀寫鎖允許有多個讀操作同時進行,但只允許有一個寫操作執行。如果寫鎖沒有加鎖,則讀鎖不會阻塞,否則需要等待寫入完成。

ReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();

三、synchronized中的鎖

synchronized 代碼塊是由一對兒 monitorenter/monitorexit 指令實現的,Monitor 對象是同步的基本實現單元。

在 Java 6 之前,Monitor 的實現完全是依靠操作系統內部的互斥鎖,因爲需要進行用戶態到內核態的切換,所以同步操作是一個無差別的重量級操作。
現代的(Oracle)JDK 中,JVM 對此進行了大刀闊斧地改進,提供了三種不同的 Monitor 實現,也就是常說的三種不同的鎖:偏斜鎖(Biased Locking)、輕量級鎖和重量級鎖,大大改進了其性能。

1、synchronized中鎖的狀態

鎖的狀態是通過對象監視器在對象頭中的字段來表明的。
四種狀態會隨着競爭的情況逐漸升級,而且是不可逆的過程,即不可降級。
這四種狀態都不是Java語言中的鎖,而是Jvm爲了提高鎖的獲取與釋放效率而做的優化(使用synchronized時)。

  • 無鎖狀態
  • 偏向鎖狀態
  • 輕量級鎖狀態
  • 重量級鎖狀態

2、偏向鎖、輕量級鎖、重量級鎖

這三種鎖是指鎖的狀態,並且是針對Synchronized。在Java 5通過引入鎖升級的機制來實現高效Synchronized。這三種鎖的狀態是通過對象監視器在對象頭中的字段來表明的。

偏向鎖是指一段同步代碼一直被一個線程所訪問,那麼該線程會自動獲取鎖。降低獲取鎖的代價。
輕量級鎖是指當鎖是偏向鎖的時候,被另一個線程所訪問,偏向鎖就會升級爲輕量級鎖,其他線程會通過自旋的形式嘗試獲取鎖,不會阻塞,提高性能。

重量級鎖是指當鎖爲輕量級鎖的時候,另一個線程雖然是自旋,但自旋不會一直持續下去,當自旋一定次數的時候,還沒有獲取到鎖,就會進入阻塞,該鎖膨脹爲重量級鎖。重量級鎖會讓其他申請的線程進入阻塞,性能降低。

3、synchronized的鎖升級

所謂鎖的升級、降級,就是 JVM 優化 synchronized 運行的機制,當 JVM 檢測到不同的競爭狀況時,會自動切換到適合的鎖實現,這種切換就是鎖的升級、降級。

當沒有競爭出現時,默認會使用偏斜鎖。JVM 會利用 CAS 操作(compare and swap),在對象頭上的 Mark Word 部分設置線程 ID,以表示這個對象偏向於當前線程,所以並不涉及真正的互斥鎖。這樣做的假設是基於在很多應用場景中,大部分對象生命週期中最多會被一個線程鎖定,使用偏斜鎖可以降低無競爭開銷。

如果有另外的線程試圖鎖定某個已經被偏斜過的對象,JVM 就需要撤銷(revoke)偏斜鎖,並切換到輕量級鎖實現。輕量級鎖依賴 CAS 操作 Mark Word 來試圖獲取鎖,如果重試成功,就使用普通的輕量級鎖;否則,進一步升級爲重量級鎖。

四、看下ReentrantLock

ReentrantLock,一個可重入的互斥鎖,它具有與使用synchronized方法和語句所訪問的隱式監視器鎖相同的一些基本行爲和語義,但功能更強大。

1、基本用法

public class LockTest {
 
    private Lock lock = new ReentrantLock();
    public void testMethod() {
        lock.lock();
        for (int i = 0; i < 5; i++) {
            System.out.println("ThreadName=" + Thread.currentThread().getName()
                    + (" " + (i + 1)));
        }
        lock.unlock();
    }
 
}

2、Condition應用

synchronized與wait()和nitofy()/notifyAll()方法相結合可以實現等待/通知模型,ReentrantLock同樣可以,但是需要藉助Condition,且Condition有更好的靈活性,具體體現在:

  • 一個Lock裏面可以創建多個Condition實例,實現多路通知
  • notify()方法進行通知時,被通知的線程時Java虛擬機隨機選擇的,但是ReentrantLock結合Condition可以實現有選擇性地通知,這是非常重要的

3、Condition類和Object類

  • Condition類的awiat方法和Object類的wait方法等效
  • Condition類的signal方法和Object類的notify方法等效
  • Condition類的signalAll方法和Object類的notifyAll方法等效

五、再看下ReadWriteLock

在併發場景中用於解決線程安全的問題,我們幾乎會高頻率的使用到獨佔式鎖,通常使用java提供的關鍵字synchronized(關於synchronized可以看這篇文章)或者concurrents包中實現了Lock接口的ReentrantLock。

它們都是獨佔式獲取鎖,也就是在同一時刻只有一個線程能夠獲取鎖。而在一些業務場景中,大部分只是讀數據,寫數據很少,如果僅僅是讀數據的話並不會影響數據正確性(出現髒讀),而如果在這種業務場景下,依然使用獨佔鎖的話,很顯然這將是出現性能瓶頸的地方。

針對這種讀多寫少的情況,java還提供了另外一個實現Lock接口的ReentrantReadWriteLock(讀寫鎖)。讀寫所允許同一時刻被多個讀線程訪問,但是在寫線程訪問時,所有的讀線程和其他的寫線程都會被阻塞。

1、ReadWriteLock接口

ReadWriteLock,顧明思義,讀寫鎖在讀的時候,上讀鎖,在寫的時候,上寫鎖,這樣就很巧妙的解決synchronized的一個性能問題:讀與讀之間互斥。

ReadWriteLock也是一個接口,原型如下:

public interface ReadWriteLock {
    Lock readLock();
    Lock writeLock();
}

該接口只有兩個方法,讀鎖和寫鎖。

也就是說,我們在寫文件的時候,可以將讀和寫分開,分成2個鎖來分配給線程,從而可以做到讀和讀互不影響,讀和寫互斥,寫和寫互斥,提高讀寫文件的效率。

2、ReentrantReadWriteLock應用

下面的實例參考《Java併發編程的藝術》,使用讀寫鎖實現一個緩存。

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class Cache {
    static Map<String,Object> map = new HashMap<String, Object>();
    static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    static Lock readLock = readWriteLock.readLock();
    static Lock writeLock = readWriteLock.writeLock();
    
    public static final Object getByKey(String key){
        readLock.lock();
        try{
            return map.get(key);
        }finally{
            readLock.unlock();
        }
    }
    
    public static final Object getMap(){
        readLock.lock();
        try{
            return map;
        }finally{
            readLock.unlock();
        }
    }
    
    public static final Object put(String key,Object value){
        writeLock.lock();
        try{
            return map.put(key, value);
        }finally{
            writeLock.unlock();
        }
    }
    
    public static final Object remove(String key){
        writeLock.lock();
        try{
            return map.remove(key);
        }finally{
            writeLock.unlock();
        }
    }
    
    public static final void clear(){
        writeLock.lock();
        try{
            map.clear();
        }finally{
            writeLock.unlock();
        }
    }
    
    public static void main(String[] args) {
        List<Thread> threadList = new ArrayList<Thread>();
        for(int i =0;i<6;i++){
            Thread thread = new PutThread();
            threadList.add(thread);
        }
        for(Thread thread : threadList){
            thread.start();
        }
        put("ji","ji");
        System.out.println(getMap());
    }
    
    private static class PutThread extends Thread{
        public void run(){
            put(Thread.currentThread().getName(),Thread.currentThread().getName());
        }
    }
}

3、讀寫鎖的鎖降級

讀寫鎖支持鎖降級,遵循按照獲取寫鎖,獲取讀鎖再釋放寫鎖的次序,寫鎖能夠降級成爲讀鎖,不支持鎖升級,關於鎖降級下面的示例代碼摘自ReentrantWriteReadLock源碼中:

void processCachedData() {
        rwl.readLock().lock();
        if (!cacheValid) {
            // Must release read lock before acquiring write lock
            rwl.readLock().unlock();
            rwl.writeLock().lock();
            try {
                // Recheck state because another thread might have
                // acquired write lock and changed state before we did.
                if (!cacheValid) {
                    data = ...
            cacheValid = true;
          }
          // Downgrade by acquiring read lock before releasing write lock
          rwl.readLock().lock();
        } finally {
          rwl.writeLock().unlock(); // Unlock write, still hold read
        }
      }
 
      try {
        use(data);
      } finally {
        rwl.readLock().unlock();
      }
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章