天天看點

深入并發鎖,解析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();
      }
    }
}