併發編程系列之重入鎖VS讀寫鎖

前言

上節我們介紹了Java中的鎖基礎篇,也算是對鎖有了個基本的認識,對鎖底層的一些原理有所掌握,那麼今天我們就來看看2個最常見的鎖的實例應用,重入鎖和讀寫鎖,這是今天旅途最美的兩大景點,是不是有點迫不及待了,OK,那就讓我們一起開啓今天的併發之旅吧,祝您今天的旅途愉快。

 

景點一:重入鎖

什麼是重入鎖?

重入鎖ReentrantLock指的是支持重進入的鎖,表示該鎖能夠支持同一個線程對資源的重複加鎖,也就是說當線程對某個資源獲取鎖之後,該線程繼續獲取該資源的鎖時不會被阻塞;

synchronized關鍵字就是一種可重入鎖,不過他是隱式的支持可重入,不需要開發者手動的獲取鎖,解鎖,其實本質上synchronized鎖機制實現同步本身就是隱式的,對開發者完全是透明的,這樣雖然簡化了開發,但是也限制了對鎖的可控性;

ReentrantLock雖然沒有像synchronized一樣支持隱式的重進入,但是在調用了lock方法之後,已經獲得鎖的線程還能繼續調用lock方法獲得鎖,而不會被自己阻塞;

 

鎖獲取公平性問題

什麼是公平的獲取鎖:在絕對時間上,先對鎖進行獲取的請求一定會先獲取到鎖,那麼這個鎖就成爲公平性鎖,也就是說,等待時間越長的線程越優先獲取鎖,獲取鎖的順序是跟請求順序一致的,先到先得的規則,滿足這種規則就是公平性鎖,反正就是非公平性鎖,ReentrantLock通過一個Boolean值來控制是否爲公平性鎖:

// 公平性可重入鎖
ReentrantLock fairReentrantLock = new ReentrantLock(true);
// 非公平性可重入鎖
ReentrantLock  unFairReentrantLock = new ReentrantLock(false);

 

公平性鎖和非公平性鎖比較:公平鎖事實上沒有非公平性鎖效率高,主要是因爲公平性鎖每次都是從同步隊列中的第一個節點獲取鎖(爲了保證FIFO特性)。那麼有人就會疑問,既然效率是非公平性高,那麼我們是不是就應該儘量多的去使用非公平性鎖代替公平性鎖呢?當然答案是否定的,非公平性鎖雖然效率上比較高,但是出現線程飢餓的情況概率比較大,這是因爲剛剛釋放鎖的線程再次獲得鎖的概率比較大,這樣就會導致某些線程會出現一直獲取不到鎖的機會,而一直在同步隊列中等待着,也正是非公平鎖的這種隨機不公平性導致飢餓概率大大提升;

對於我們的ReentrantLock,其實還是更傾向於高效率的非公平性鎖,我們看如下源碼就能很清晰的看到這一點:

ReentrantLock  reentrantLock = new ReentrantLock();
public ReentrantLock() {
       // new 一個非公平性鎖
       sync = new NonfairSync();
   }

公平性鎖和非公平性鎖總結:公平性鎖保證了鎖的獲取按照FIFO規則進行,而代價是進行大量的線程切換,降低了處理效率;非公平性鎖,雖然會造成線程飢餓情況發生,但是極少的線程切換,換來了更大的吞吐量,提高處理效率。

如何實現重進入

重進入是指任意線程在獲取到鎖之後還能再次獲取該鎖,而不會被阻塞,實現重進入主要需要解決下面2個問題:

  • 線程再次獲取鎖:鎖需要去識別獲取鎖的線程是否爲當前已經獲得鎖的線程,如果是,那就再次獲取鎖成功,不是則進入同步隊列等待下次獲取;

  • 鎖的最終釋放:同一個線程重複N次獲取了鎖,那麼就需要在隨後第N次釋放鎖之後,其他線程才能獲取到該鎖。其實現主要依賴於一個計數器,對於某個線程獲取某個資源都有一個計數器,每次該線程再次獲取該鎖時計數器+1,而鎖被釋放則-1,當計數器爲0時,才能代表真正釋放成功,才能被其他線程獲取;

上面我們提到ReentrantLock有2種鎖,那麼我們接下來就分別就這2種分析其獲取和釋放是如何實現的;

 

公平性鎖的獲取

fairReentrantLock.lock();

我們再看看公平性鎖lock方法內部實現:其調用Sync的lock方法

public void lock() {
       sync.lock();
   }

我們在看下如何獲取同步狀態的:

static final class FairSync extends Sync {
       private static final long serialVersionUID = -3000897897090466540L;

       final void lock() {
           acquire(1);
       }

       protected final boolean tryAcquire(int acquires) {
           final Thread current = Thread.currentThread();
           int c = getState();
           // 判斷是否是重進入,如果不是重進入,首次獲取鎖,得先判斷是有有前驅節點
           if (c == 0) {
                       // 判斷當前線程節點在同步隊列中是否有前驅節點,如果有
               // 則表示有線程比當前線程更早的請求獲取鎖,因此需要等待
               // 前驅線程獲取並釋放鎖之後才能繼續獲取鎖,如果沒有前驅
               // 節點,並且CAS操作成功則說明獲取同步狀態成功,即獲取鎖成功,返回
               if (!hasQueuedPredecessors() &&
                   compareAndSetState(0, acquires)) {
                   setExclusiveOwnerThread(current);
                   return true;
               }
           }
           // 如果是重進入就直接獲取鎖成功,計數器+1
           else if (current == getExclusiveOwnerThread()) {
               int nextc = c + acquires;
               if (nextc < 0)
                   throw new Error("Maximum lock count exceeded");
               setState(nextc);
               return true;
           }
           return false;
       }
   }

 

公平性鎖的釋放

同樣的在釋放鎖也需要根據計數器判斷,先調用釋放鎖方法:

try {
           // do some thing
       }finally {
           fairReentrantLock.unlock();
       }

然後我們看下釋放鎖的源碼:假設獲取鎖的次數爲N次,則前N-1次都返回false,第N次才返回true,真正釋放鎖

protected final boolean tryRelease(int releases) {
           // 計數器-1
           int c = getState() - releases;
           if (Thread.currentThread() != getExclusiveOwnerThread())
               throw new IllegalMonitorStateException();
           boolean free = false;
           // 如果計數器=0,說明最終釋放條件滿足,設置當前同步狀態爲0
           // 並且將當前佔有線程設置爲null,並返回true
           if (c == 0) {
               free = true;
               setExclusiveOwnerThread(null);
           }
           setState(c);
           return free;
       }

 

非公平性獲取鎖

ReentrantLock  unFairReentrantLock = new ReentrantLock(false);
unFairReentrantLock.lock();

非公平性獲取鎖和公平性獲取鎖區別不大,主要體現在不需要判斷同步隊列中的當前線程需要有前驅節點,它不需要保證節點的FIFO,我們看源碼分析:

final boolean nonfairTryAcquire(int acquires) {
           final Thread current = Thread.currentThread();
           // 判斷是否是首次獲取鎖
           int c = getState();
           // 如果是首次獲取鎖,直接CAS操作獲取鎖成功,返回true
           if (c == 0) {
               if (compareAndSetState(0, acquires)) {
                   setExclusiveOwnerThread(current);
                   return true;
               }
           }
           // 否則,加鎖計數器+1,獲取鎖成功,返回true
           else if (current == getExclusiveOwnerThread()) {
               int nextc = c + acquires;
               if (nextc < 0) // overflow
                   throw new Error("Maximum lock count exceeded");
               setState(nextc);
               return true;
           }
           return false;
       }

代碼邏輯:判斷當前線程是否爲獲取鎖的線程,如果是獲取鎖的線程再次請求,則將同步狀態計數器+1並返回true,獲取鎖成功,如果不是則表現當前線程是第一次獲取鎖,直接進行CAS修改同步狀態,修改成功返回true,完成非公平性獲取鎖的操作,非公平性釋放鎖流程和源碼跟公平性是一樣的,都是tryRelease方法,就不做重複的講解了。

 

景點二:讀寫鎖

什麼是讀寫鎖?

上面所說的重入鎖是排他鎖,排他鎖在同一時刻只允許一個線程進行訪問,而讀寫鎖在同一時刻可以允許多個讀線程訪問,但是在寫操作時,所有對該資源的讀線程和寫線程都將被阻塞;

讀寫鎖實質上是維護這一對鎖,一個讀鎖和一個寫鎖,通過分離讀鎖和寫鎖,使得併發性有很大的提升;

讀寫鎖實現:在讀操作的時候獲取鎖,寫操作時獲取寫鎖即可,當寫鎖被獲取時,後續的讀寫操作都會阻塞,寫鎖釋放之後,所有操作繼續執行,而讀鎖是可以同時併發訪問的;

 

讀寫鎖的特性

讀寫鎖比其他排他鎖具有更好的併發性和吞吐量,主要有下面三大特性:

  • 公平性選擇:支持公平和非公平的鎖獲取方式,吞吐量考慮非公平鎖優先公平鎖

  • 重進入:讀寫鎖支持重進入

  • 鎖降級:遵循寫鎖——讀鎖——釋放寫鎖的次序,寫鎖也能降級爲讀鎖

  • 支持中斷:讀取鎖和寫入鎖都支持鎖獲取時的中斷

  • Condition:寫入鎖提供了Condition的實現

 

讀寫鎖的接口

ReadWriteLock接口只定義了兩個方法,所以一般情況下,我們都是使用實現好的類ReentrantReadWriteLock,我們先看下ReadWriteLock定義的兩個方法如下:

ReentrantReadWriteLock類提供的方法如下:

讀寫鎖的實現原理

讀寫狀態的設計

讀寫鎖同樣依賴自定義同步器來實現同步功能,讀寫狀態就是其同步器的狀態,讀寫鎖的自定義同步器系統在同步狀態上維護多個讀線程和一個寫線程的狀態;

讀寫鎖採用按位劃分的思想,將一個整型變量切分成兩個部分,高16位表示讀,低16位表示寫,如下圖所示:

寫鎖的獲取和釋放

寫鎖是一個支持重進入的排他鎖,如果當前線程已經獲取寫鎖,則寫狀態+1,如當前線程在獲取寫鎖的時候,讀鎖已經被獲取或者獲取寫鎖的不是當前線程,則當前線程進入等待狀態,我們看源碼如下:

/** 如果讀計數器不爲0,或者寫計數器不爲0,並且當前線程
*  不是已經獲得鎖的線程時,則失敗;
*
*  如果計數器飽和,則失敗;
*
*  否則當前線程就可以獲得鎖,要麼爲可重入的,要麼就是
*  FIFO策略下的,如果滿足,則更新同步狀態,獲取鎖成功
*/
protected final boolean tryAcquire(int acquires) {
           Thread current = Thread.currentThread();
           int c = getState();
           int w = exclusiveCount(c);
           if (c != 0) {
               // 如果存在讀鎖或者當前線程不是已經獲得寫鎖的線程時
               if (w == 0 || current != getExclusiveOwnerThread())
                   return false;
               if (w + exclusiveCount(acquires) > MAX_COUNT)
                   throw new Error("Maximum lock count exceeded");
               // Reentrant acquire
               setState(c + acquires);
               return true;
           }
           // 是否爲讀鎖 或者CAS操作失敗
           if (writerShouldBlock() ||
               !compareAndSetState(c, c + acquires))
               return false;
           setExclusiveOwnerThread(current);
           return true;
       }

我們會發現這裏還增加了一個是否爲讀鎖判斷,如果存在讀鎖則寫鎖就不能被獲取,這是什麼原因呢?是因爲:讀寫鎖要保證寫鎖的操作對讀鎖是可見的,也就是說,每個寫操作的結果一定要對後續所有的讀操作是可見的。所以,寫鎖一定要等待其他讀線程全部都釋放了鎖才能被當前線程獲取,而寫鎖一旦被獲取,則其他後續讀寫線程都必須被阻塞;

寫鎖的釋放:寫鎖的釋放與ReentrantLock的釋放過程基本一樣,每次釋放時都減少寫狀態,當寫狀態計數器爲0時,則最終完全釋放,從而等待的讀寫線程才能夠繼續訪問讀寫鎖,並且當前釋放的寫操作結果對後續操作均可見。

讀鎖的獲取和釋放

讀鎖是一個支持可重入的共享鎖,它能夠被多個線程同時獲取,在沒有線程對資源訪問時,讀鎖就會獲取成功,增加讀狀態,如果在獲取讀鎖的過程中,該資源的寫鎖已經被其他線程獲取,則讀鎖進入等待狀態,等待寫鎖的釋放,再嘗試獲取讀鎖。

獲取讀鎖的邏輯:如果其他線程已經獲取了寫鎖,則當前線程獲取讀鎖失敗,進入等待狀態;如果當前線程獲取寫鎖或者寫鎖沒有被其他線程獲取,則當前線程獲取讀鎖成功,增加讀狀態,也就是說同一個線程可以在獲取寫鎖的前提下再次獲得該讀鎖,有人就會問,不是所有的寫必須對後續讀可見嗎?萬一這裏寫線程執行比讀慢不就不滿足寫結果對讀的可見了嗎,這裏我要做個說明:上面我在講讀寫鎖特性時提到過鎖降級,必須遵循寫鎖——讀鎖——釋放寫鎖的次序,也就是說同一個線程對同一個對象的操作,必須寫優先於讀,所以寫的結果一定在讀之前,不用去擔心臟讀的發生;具體源碼見下:

protected final int tryAcquireShared(int unused) {
           Thread current = Thread.currentThread();
           int c = getState();
           if (exclusiveCount(c) != 0 &&
               getExclusiveOwnerThread() != current)
               return -1;
           int r = sharedCount(c);
           if (!readerShouldBlock() &&
               r < MAX_COUNT &&
               compareAndSetState(c, c + SHARED_UNIT)) {
               if (r == 0) {
                   firstReader = current;
                   firstReaderHoldCount = 1;
               } else if (firstReader == current) {
                   firstReaderHoldCount++;
               } else {
                   HoldCounter rh = cachedHoldCounter;
                   if (rh == null || rh.tid != current.getId())
                       cachedHoldCounter = rh = readHolds.get();
                   else if (rh.count == 0)
                       readHolds.set(rh);
                   rh.count++;
               }
               return 1;
           }
           return fullTryAcquireShared(current);
       }

其中SHARED_UNIT爲:

static final int SHARED_SHIFT   = 16;
static final int SHARED_UNIT    = (1 << SHARED_SHIFT);
static final int MAX_COUNT      = (1 << SHARED_SHIFT) - 1;
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;

 

讀鎖的釋放:跟前面講的都差不多,都是每次釋放減少讀狀態,減少的值是1<<16,我們前面說了高16位爲讀狀態。當減少若干次數高16位等於0時,則代表讀鎖全部釋放完畢;

鎖降級

鎖降級指的是寫鎖降級爲讀鎖,是線程先獲取寫鎖——然後又獲取了讀鎖——隨後釋放寫鎖的過程;如果是獲取寫鎖——釋放寫鎖——再獲取讀鎖,這種分段完成的過程不能稱之爲鎖降級;

有人就會問鎖降級過程中,獲取讀鎖的步驟可不可以省略,這當然是不行的,讀鎖在這裏的目的是爲了保證數據的可見性,假設線程1獲取了寫鎖,然後釋放寫鎖,而沒有獲取讀鎖,如果此時線程2剛好獲取了寫鎖,就會把線程1更新的值直接覆蓋掉,而對於其他線程完全不可見,也就是說線程1的修改完全沒有被其他線程感知到;

讀寫鎖是沒有鎖升級的,主要目的也是爲了保證數據的可見性,如果某個對象讀鎖已經被多個線程獲取,而此時其中一個線程升級爲寫鎖了,那麼該線程對數據的更新,對於剩下的其他獲取到讀鎖的線程就是不可見的。

 

以上就是今天重入鎖和讀寫鎖兩大景點的全部內容,是不是有種意猶未盡的感覺,沒關係,今天的旅途累了,我們休息一下,下節繼續,感謝關注,感謝閱讀!!!

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