Java 重入鎖和讀寫鎖的具體使用

重入鎖

重入鎖 ReentrantLock,顧名思義,就是支持重進入的鎖,它表示該鎖能夠支持一個線程對資源的重復加鎖。除此之外,該鎖還支持獲取鎖時的公平和非公平性選擇

所謂不支持重進入,可以考慮如下場景:當一個線程調用 lock() 方法獲取鎖之後,如果再次調用 lock() 方法,則該線程將會被自己阻塞,原因是在調用 tryAcquire(int acquires) 方法時會返回 false,從而導致線程阻塞

synchronize 關鍵字隱式的支持重進入,比如一個 synchronize 修飾的遞歸方法,在方法執行時,執行線程在獲取鎖之後仍能連續多次地獲得該鎖。ReentrantLock 雖然不能像 synchronize 關鍵字一樣支持隱式的重進入,但在調用 lock() 方法時,已經獲得鎖的線程,能夠再次調用 lock() 方法獲取鎖而不被阻塞

1. 實現重進入

重進入特性的實現需要解決以下兩個問題:

線程再次獲取鎖
鎖需要去識別獲取鎖的線程是否為當前占據鎖的線程,如果是,則再次成功獲取

鎖的最終釋放
線程重復 n 次獲取鎖,隨後在第 n 次釋放該鎖後,其他線程能獲取到鎖。實現此功能,理應考慮使用計數

ReentrantLock 通過組合自定義同步器來實現鎖的獲取與釋放,以非公平鎖實現為例,獲取同步狀態的代碼如下所示,主要是增加瞭再次獲取同步狀態的處理邏輯

final boolean nonfairTryAcquire(int acquires) {
 final Thread current = Thread.currentThread();
 int c = getState();
 if (c == 0) {
  if (compareAndSetState(0, acquires)) {
   setExclusiveOwnerThread(current);
   return true;
  }
 }
 // 判斷當前線程是否為獲取鎖的線程
 else if (current == getExclusiveOwnerThread()) {
  // 將同步值進行增加,並返回 true
  int nextc = c + acquires;
  if (nextc < 0)
   throw new Error("Maximum lock count exceeded");
  setState(nextc);
  return true;
 }
 return false;
}

考慮到成功獲取鎖的線程再次獲取鎖,隻是增加同步狀態值,這也就要求 ReentrantLock 在釋放同步狀態時減少同步狀態值,該方法代碼如下:

protected final boolean tryRelease(int releases) {
 // 減少狀態值
 int c = getState() - releases;
 if (Thread.currentThread() != getExclusiveOwnerThread())
  throw new IllegalMonitorStateException();
 boolean free = false;
 // 當同步狀態為0,將占有線程設為null,並返回true,表示釋放成功
 if (c == 0) {
  free = true;
  setExclusiveOwnerThread(null);
 }
 setState(c);
 return free;
}

2. 公平與非公平獲取鎖的區別

如果一個鎖是公平的,那麼鎖的獲取順序就應該符合請求的絕對時間順序,也即 FIFO。回顧上一節,非公平鎖隻要 CAS 設置同步狀態成功,即表示當前線程獲取瞭鎖,而公平鎖則不同,代碼如下:

protected final boolean tryAcquire(int acquires) {
 final Thread current = Thread.currentThread();
 int c = getState();
 if (c == 0) {
  /* 
   * 唯一不同的就是判斷條件多瞭 hasQueuedPredecessors()
   * 該方法用來判斷當前節點是否有前驅節點
   * 如果該方法返回 true,表示有線程比當前線程更早請求獲取鎖
   * 因此需要等待前驅線程釋放鎖之後才能繼續獲取鎖
   */
  if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
   setExclusiveOwnerThread(current);
   return true;
  }
 }
 else if (current == getExclusiveOwnerThread()) {
  int nextc = c + acquires;
  if (nextc < 0)
   throw new Error("Maximum lock count exceeded");
  setState(nextc);
  return true;
 }
 return false;
}

讀寫鎖

之前提到的鎖基本都是排它鎖,同一時刻隻允許一個線程訪問,而讀寫鎖在同一時刻可以允許多個線程訪問,但在寫線程訪問時,所有的讀線程和其他寫線程均被阻塞。讀寫鎖維護瞭一對鎖,一個讀鎖和一個寫鎖,通過分離讀鎖和寫鎖,使得並發性相比一般的排它鎖有瞭很大提升

1. 接口示例

下面通過緩存示例說明讀寫鎖的使用方式

public class Cache {

 static Map<String, Object> map = new HashMap<>();
 static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
 static Lock r = rwl.readLock();
 static Lock w = rwl.writeLock();

 /**
  * 獲取一個 key 對應的 value
  */
 public static Object get(String key) {
  r.lock();
  try {
   return map.get(key);
  } finally {
   r.unlock();
  }
 }

 /**
  * 設置 key 對應的 value,並返回舊的 value
  */
 public static Object put(String key, Object value) {
  w.lock();
  try {
   return map.put(key, value);
  } finally {
   w.unlock();
  }
 }

 /**
  * 清空所有的內容
  */
 public static void clear() {
  w.lock();
  try {
   map.clear();
  } finally {
   w.unlock();
  }
 }
}

2. 讀寫狀態的設計

讀寫鎖同樣依賴自定義同步器來實現功能,而讀寫狀態就是其同步器狀態。讀寫鎖的自定義同步器需要在同步狀態(一個整型變量)上維護多個讀線程和一個寫線程的狀態,為此需要讀寫鎖將變量切分成兩部分,高 16 位表示讀,低 16 位表示寫

上圖表示一個線程已經獲取瞭寫鎖,且重進入瞭兩次,同時也連續兩次獲取瞭讀鎖。通過位運算可以迅速確定讀和寫各自的狀態,假設當前同步狀態值為 S,則:

  • 寫狀態等於 S & 0x0000FFFF(將高 16 位全部抹去)
  • 讀狀態等於 S >>> 16(無符號右移 16 位)
  • 當寫狀態增加 1 時,等於 S + 1
  • 當讀狀態增加 1 時,等於 S + (1<<6),也就是 S + 0x00010000

根據狀態的劃分能得出一個結論:S 不等於 0 時,當寫狀態(S & 0x0000FFFF)等於 0 時,則讀狀態(S >>> 16)大於 0,即讀鎖已被獲取

3. 寫鎖的獲取與釋放

寫鎖是一個支持重進入的排它鎖。如果當前線程已經獲取瞭寫鎖,則增加寫狀態。如果當前線程在獲取寫鎖時,讀鎖已被獲取,或者該線程不是獲取寫鎖的線程,則當前線程進入等待狀態,獲取寫鎖的代碼如下:

protected final boolean tryAcquire(int acquires) {
 Thread current = Thread.currentThread();
 int c = getState();
 // exclusiveCount 方法會用 c & 0x0000FFFF,即得出寫狀態個數
 int w = exclusiveCount(c);
 if (c != 0) {
  // 根據上面提到的推論,c 不等於 0,而 w 等於 0,證明存在讀鎖
  // 當前線程也不是獲取瞭寫鎖的線程
  if (w == 0 || current != getExclusiveOwnerThread())
   return false;
  if (w + exclusiveCount(acquires) > MAX_COUNT)
   throw new Error("Maximum lock count exceeded");
  setState(c + acquires);
  return true;
 }
 if (writerShouldBlock() || !compareAndSetState(c, c + acquires))
  return false;
 setExclusiveOwnerThread(current);
 return true;
}

寫鎖的每次釋放均會減少寫狀態,當寫狀態為 0 時表示寫鎖已被釋放,從而等待的讀寫線程能夠繼續訪問讀寫鎖,同時前次寫線程的修改對後續讀寫線程可見

4. 讀鎖的獲取與釋放

讀鎖是一個支持重進入的共享鎖,它能被多個線程同時獲取,在沒有其他寫線程訪問時,讀鎖總能被成功獲取,這裡對獲取讀鎖的代碼做瞭簡化:

protected final int tryAcquireShared(int unused) {

 for(;;) {
  int c = getState();
  int nextc = c + (1<<16);
  if(nextc < c) {
   throw new Error("Maximum lock count exceeded");
  }
  // 如果其他線程已經獲取寫鎖,則讀取獲取失敗
  if(exclusiveCount(c) != 0 && owner != Thread.currentThread()) {
   return -1;
  }
  if(compareAndSetState(c, nextc)) {
   return 1;
  }
 }
}

讀鎖的每次釋放均減少讀狀態,減少的值是 1<<16

5. 鎖降級

鎖降級指的是寫鎖降級成為讀鎖。如果當前線程擁有寫鎖,然後將其釋放,最後再獲取讀鎖,這種分段完成的過程不能稱之為鎖降級。鎖降級是指把持住寫鎖,再獲取讀鎖,隨後釋放寫鎖的過程

public void processData() {
 readLock.lock();
 if(!update) {
  // 必須先釋放讀鎖
  readLock.unlock();
  // 鎖降級從寫鎖獲取到開始
  writeLock.lock();
  try {
 if(!update) {
    // 準備數據的流程(略)
    update = true;
   }
   readLock.lock();
  } finally {
   writeLock.unlock();
  }
 }
 try {
  // 使用數據的流程(略)
 } finally {
  readLock.unlock();
 }
}

上例中,當數據發生變更,則 update(使用 volatile 修飾)被設置為 false,此時所有訪問 processData 方法的線程都能感知到變化,但隻有一個線程能獲取到寫鎖,其餘線程會被阻塞在寫鎖的 lock 方法上。當前線程獲取寫鎖完成數據準備之後,再次獲取讀鎖,隨後釋放寫鎖,完成鎖降級

到此這篇關於Java 重入鎖和讀寫鎖的具體使用的文章就介紹到這瞭,更多相關Java 重入鎖和讀寫鎖內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: