1. 程式人生 > 其它 >併發:讀寫鎖ReentrantReadWriteLock和StampedLock

併發:讀寫鎖ReentrantReadWriteLock和StampedLock

技術標籤:併發程式設計資料庫java多執行緒

1. 讀寫鎖

讀寫鎖通常遵守以下三條規則:

  • 多個執行緒可以同時讀共享變數;
  • 只允許一個執行緒寫共享變數;
  • 對共享變數寫的時候,不允許對該變數讀。

簡單來說,讀寫鎖允許同時讀讀和寫互斥寫和寫互斥

因此,在讀多寫少的場景下,讀寫鎖比普通的互斥鎖更具優勢。

2. 讀寫鎖ReentrantReadWriteLock

ReentrantReadWriteLock從字面上來看,可以知道它是一個可重入的讀寫鎖。

在使用容器時,使用ReentrantReadWriteLock,可以實現較好的併發效能,尤其是在讀多寫少的場景中。快取工具類即是一種讀多寫少的場景。

2.1 java官方程式碼示例

class RWDictionary{
  private final Map<String, Data> m = new TreeMap<String, Data>();
  private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
 	private final Lock r = rwl.readLock();
 	private final Lock w = rwl.writeLock();
 
 	public Data get(String key)
{ //獲取讀鎖 r.lock(); try { return m.get(key); } //釋放讀鎖 finally { r.unlock(); } } public String[] allKeys(){ r.lock(); try{ return m.keySet().toArray(); }finally{ r.unlock(); } } public Data put(String key, Data value) { //獲取寫鎖 w.lock(); try
{ return m.put(key, value); } finally { w.unlock(); } } public void clear() { w.lock(); try { m.clear(); } finally { w.unlock(); } } }

2.2 快取的載入策略

①當源資料量不大時,可以採用一次性載入的策略,也就是說一次就把所有的資料放入本類的Map容器中。

②當源資料量比較大時,推薦採用按需載入的策略,也就是說要用哪個資料,先去快取中查詢,如果找到,就直接返回;否則,就將其從資料庫中查詢到,放入快取中。

2.3 快取按需載入策略的實現

class RWDictionary{
  private final Map<String, Data> m = new TreeMap<String, Data>();
  private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
 	private final Lock r = rwl.readLock();
 	private final Lock w = rwl.writeLock();
  
   	public Data get(String key) {
      Data value;
    	//獲取讀鎖
  		r.lock();
      //嘗試從快取中獲取資料
      try{
        value = m.get(key);
      }finally{//釋放讀鎖
        r.unlock();
      }
      //如果快取中有,就直接返回
      if(value != null){
        return value;
      }
      
      //如果快取中沒有,就要從資料庫中查詢,再寫入快取,需要加寫鎖
      w.lock();
      try{
        //在獲取寫鎖前,可能已經有其他執行緒將該資料寫入快取了,所以要再次判斷,如果已經有了,就可以直接返回
        value = m.get(key);
        if(value == null){
          //從資料庫中查詢到後,寫入到value中,此處省略該過程
          map.put(key,value);
        } 
      }finally{
        w.unlock();
      }
      
      return value;

  }
}

2.4 鎖的降級

如果一個執行緒已經獲取了寫鎖,然後又嘗試獲取讀鎖,稱之為鎖的降級

由於當存在寫操作時,沒有其他執行緒正在讀或者寫,因此,當前執行緒可以輕鬆地將自身持有的寫鎖降級為讀鎖。

鎖的降級的官方示例:

class CachedData{
  //快取資料
  Object data;
  //資料是否有效
  volatile boolean cacheValid;
  //讀寫鎖
  final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
  
  void processCachedData() {
    //獲取讀鎖
    rwl.readlock().lock();
    //如果該快取資料無效
    if(!cacheValid){
      //釋放讀鎖
      rwl.readlock().unlock();
      //獲取寫鎖
      rwl.writelock().lock();
      try{
        //再次驗證是否有效
        if(!cacheValid){
          //就將有效的資料寫入data中
          data = ...
          cacheValid = true;
        }
        //釋放寫鎖前,將寫鎖降級為讀鎖,以保證本執行緒在釋放寫鎖後依然可以使用該資料
        rwl.readlock().lock();
      }finally{
        //釋放寫鎖
        rwl.writelock().unlock();
      }
      
      //使用該資料時,此時依然持有讀鎖
      try{
        use(data);
      }finally{
        //釋放讀鎖
        rwl.readlock().unlock();
      }    
    }
  }
}

那麼,反過來對鎖升級是否能成立呢?

答案是不能。因為如果當前執行緒持有了讀鎖,其他執行緒此時可能也在讀,現在當前執行緒又想將自己的讀鎖升級為寫鎖,顯然讀和寫是互斥的,獲取寫鎖可能會讓當前執行緒永久阻塞。

2.5 條件變數

ReentrantReadWriteLock的讀鎖不支援條件變數,試圖獲取將會丟擲異常。寫鎖支援條件變數。

3. 可以樂觀讀的StampedLock

StampedLock與ReentrantReadWriteLock的區別是:

  • StampedLock不是可重入的鎖;
  • StampedLock不僅包含ReentrantReadWriteLock中的讀寫鎖(其中的讀鎖也稱為悲觀讀鎖),還支援樂觀讀。

3.1 悲觀讀和樂觀讀

首先,解釋一下悲觀讀和樂觀讀是什麼意思。

悲觀讀,就是ReentrantReadWriteLock中的讀鎖方式,多個執行緒可以同時讀共享變數,在讀的時候,會悲觀地上一把讀鎖,以保持和寫操作的互斥性。

樂觀讀,實際上是無鎖的。在讀的時候,執行緒會首先樂觀地認為共享變數沒有被修改過。但是樂觀歸樂觀,資料一致性還是要保持的。所以,在使用資料前,要先驗證一下,該資料是否真的沒有被修改過。

下面是一段java官方給出的示例程式碼:

class Point{
  //座標的x和y
  private double x, y;
  //使用final是個好習慣
  private final StampedLock sl = new StampedLock();
  
  //移動座標,使用寫鎖
  void move(double deltaX, double deltaY){
    //獲取寫鎖
    long stamp = sl.writeLock();
    try{
      x += deltaX;
      y += deltaY;
    }finally{
      //要將與鎖匹配的stamp傳入解鎖
      sl.unlockWrite(stamp);
    }  
  }
  
  //計算當前座標距離原點的距離,使用樂觀讀
  double distanceFromOrigin(){
    //嘗試樂觀讀,此時尚未加鎖
    long stamp = sl.tryOptimisticRead();
    //讀取當前x座標和y座標
    double currentX = x, currentY = y;
    
    //如果有其他執行緒修改了座標,則使用悲觀讀鎖,鎖住這段程式碼塊
    if(!sl.validate(stamp)){
      //獲取悲觀讀鎖和相應的stamp
      stamp = sl.readLock();
      try{
        currentX = x;
        currentY = y;
      }finally{
        //釋放悲觀讀鎖
        sl.unlockRead(stamp);
      }
    }
    return Math.sqrt(currentX * currentX + currentY * currentY);
  } 
}

在distanceFromOrigin()方法中,在讀取共享變數到本地變數的過程中,可能會有其他執行緒修改共享變數,因此這裡使用了StampedLock的validate方法進行了驗證(原理後面講),如果沒有被修改,則validate方法返回true;否則,返回false。如果被修改了,就將樂觀讀升級為悲觀讀鎖。

說明

①該方式保證的是x座標和y座標的一致性正確性。什麼是不一致?比如,當執行緒A執行currentX = x,取得x座標,此時切換到執行緒B,執行緒B可能出現重排序,先將y座標做了修改,然後還沒修改x座標的時候,又切換回執行緒A,此時執行緒A再執行currentY = y。現在,執行緒A取得的x座標和y座標不具有一致性,也是不正確的,因為系統中並未設定這樣的x和y的組合。

②該方式不保證資料的實時性,也就是說獲取到x座標和y座標,然後驗證成功後,其他執行緒修改了x座標和y座標,而當前執行緒計算的是之前的x和y。因此,該方式適用於可以容忍資料短暫不一致的場景。

③樂觀讀失敗,升級為悲觀讀鎖的方式,可以避免在一個迴圈中反覆進行樂觀讀,直到樂觀讀期間沒有其他執行緒修改共享變數,這種方式會浪費大量的CPU資源。

3.2 樂觀讀的原理

StampedLock中的樂觀讀與資料庫中的樂觀鎖具有相似的原理。

(1)首先,來看一下資料庫中的樂觀鎖是如何實現的。

樂觀鎖是在資料表中新增一列version欄位。當對某個條目進行更新時,就將version加1。

試想一下,在一個先查詢後更新的場景中。

我們在table表中先對某一行進行了查詢,包括version欄位。

SELECT id,...,version 
FROM table 
WHERE id=1

假設前面查詢到的version為9。

接下來,我們將獲取到的資料進行了一系列操作,現在要將新的資料寫回到資料庫中。

那麼,在對資料庫條目更新的過程中,為了保證資料一致性和正確性,要驗證該條目是否被修改過,如果被修改了,那我們按照舊資料得到的資料,如果寫入資料庫,就會覆蓋其他人作出的更新,是不正確的。

現在,實際上只需要驗證version是否被修改過,如果被修改了,則version欄位一定會增長,此次更新也會失敗。

UPDATE table
SET version=version+1,...
where id=1 and version=9

(2)StampedLock的樂觀讀

tryOptimisticRead方法返回一個跟例項變數state相關的一個標記stamp。state表示的是當前這個StampedLock鎖的狀態。

/** Lock sequence/state */
private transient volatile long state;

public long tryOptimisticRead() {
    long s;
    return (((s = state) & WBIT) == 0L) ? (s & SBITS) : 0L;
}

在樂觀讀的期間,如果存在寫操作,則當前鎖的狀態state就會通過cas方式被修改。

public long writeLock() {
    long s, next;  // bypass acquireWrite in fully unlocked case only
    return ((((s = state) & ABITS) == 0L &&
             U.compareAndSwapLong(this, STATE, s, next = s + WBIT)) ?
            next : acquireWrite(false, 0L));
}

validate方法拿著tryOptimisticRead時取得的鎖狀態,跟當前的鎖狀態進行比較,如果不一致,說明樂觀讀期間存在寫操作。

public boolean validate(long stamp) {
    U.loadFence();
    return (stamp & SBITS) == (state & SBITS);
}

3.3 鎖的升級和降級

StampedLock通過tryConvertToWriteLock方法可以進行鎖的升級,通過tryConvertToReadLock方法可以進行鎖的降級。

//如果在原點就移動座標
void moveIfAtOrigin(double newX, double newY){
  //先獲取悲觀讀鎖
  long stamp = sl.readLock();
  try{
    //如果在原點,此時會嘗試將讀鎖升級為寫鎖
    while (x == 0.0 && y == 0.0){
      //如果當前持有寫鎖,升級為寫鎖也會成功
      long ws = sl.tryConvertToWriteLock(stamp);
      //如果升級成功
      if(ws != 0L){
        //stamp更新為當前鎖的狀態
        stamp = ws;
        x = newX;
        y = newY;
        break;
      }else{//升級失敗,就手動釋放讀鎖,再獲取寫鎖
        sl.unlockRead(stamp);
        sl.writeLock();
        //然後回到迴圈起始處判斷是否還在原點,如果還在,就繼續更新,如果已經被其他執行緒修改了,就退出迴圈
      }
    }
  }finally{
    sl.unlock(stamp);
  }
}

3.4 特殊說明

StampedLock不支援重入,並且悲觀讀鎖和寫鎖都不支援條件變數

如果需要中斷功能,請使用悲觀讀鎖readLockInterruptibly方法和寫鎖writeLockInterruptibly方法。如果在使用readLock時,呼叫執行緒的interrupt方法,會導致CPU飆升到100%。