Java 並發包中的讀寫鎖及其實現分析

來源:互聯網
上載者:User

Java 並發包中的讀寫鎖及其實現分析

1. 前言

在Java並發包中常用的鎖如:ReentrantLock),基本上都是獨佔鎖定,這些鎖在同一時刻只允許一個線程進行訪問,而讀寫鎖在同一時 刻可以允許多個讀線程訪問,但是在寫線程訪問時,所有的讀線程和其他寫線程均被阻塞。讀寫鎖維護了一對鎖,一個讀鎖和一個寫鎖,通過分離讀鎖和寫鎖,使得 並發性相比一般的獨佔鎖定有了很大提升。

除了保證寫操作對讀操作的可見度以及並發性的提升之外,讀寫鎖能夠簡化讀寫互動情境的編程方式。假設在程式中定義一個共用的資料結構用作緩衝,它大部分時間提供讀服務例如:查詢和搜尋),而寫操作佔有的時間很少,但是寫操作完成之後的更新需要對後續的讀服務可見。

在沒有讀寫鎖支援的Java 5 之前)時候,如果需要完成上述工作就要使用Java的等待通知機制,就是當寫操作開始時,所有晚於寫操作的讀操作均會進入等待狀態,只有寫操作完成並進行 通知之後,所有等待的讀操作才能繼續執行寫操作之間依靠synchronized關鍵字進行同步),這樣做的目的是使讀操作都能讀取到正確的資料,而不 會出現髒讀。改用讀寫鎖實現上述功能,只需要在讀操作時擷取讀鎖,而寫操作時擷取寫鎖即可,當寫鎖被擷取到時,後續非當前寫操作線程)的讀寫操作都會被 阻塞,寫鎖釋放之後,所有操作繼續執行,編程方式相對於使用等待通知機制的實現方式而言,變得簡單明了。

一般情況下,讀寫鎖的效能都會比排它鎖要好,因為大多數情境讀是多於寫的。在讀多於寫的情況下,讀寫鎖能夠提供比排它鎖更好的並發性和輸送量。Java並發包提供讀寫鎖的實現是ReentrantReadWriteLock,它提供的特性如表1所示。

表1. ReentrantReadWriteLock的特性

特性

說明

公平性選擇

支援非公平(預設)和公平的鎖擷取方式,輸送量還是非公平優於公平

重進入

該鎖支援重進入,以讀寫線程為例:讀線程在擷取了讀鎖之後,能夠再次擷取讀鎖。而寫線程在擷取了寫鎖之後能夠再次擷取寫鎖,同時也可以擷取讀鎖

鎖降級

遵循擷取寫鎖、擷取讀鎖再釋放寫鎖的次序,寫鎖能夠降級成為讀鎖

2. 讀寫鎖的介面與樣本

ReadWriteLock僅定義了擷取讀鎖和寫鎖的兩個方法,即readLock()和writeLock()方法,而其實現— ReentrantReadWriteLock,除了介面方法之外,還提供了一些便於外界監控其內部工作狀態的方法,這些方法以及描述如表2所示。

表2. ReentrantReadWriteLock展示內部工作狀態的方法

方法名稱

描述

int getReadLockCount()

返回當前讀鎖被擷取的次數。該次數不等於擷取讀鎖的線程數,比如:僅一個線程,它連續擷取重進入)了n次讀鎖,那麼佔據讀鎖的線程數是1,但該方法返回n

int getReadHoldCount()

返回當前線程擷取讀鎖的次數。該方法在Java 6 中加入到ReentrantReadWriteLock中,使用ThreadLocal儲存當前線程擷取的次數,這也使得Java 6 的實現變得更加複雜

boolean isWriteLocked()

判斷寫鎖是否被擷取

int getWriteHoldCount()

返回當前寫鎖被擷取的次數

接下來通過一個緩衝樣本說明讀寫鎖的使用方式,範例程式碼如代碼清單1所示。

代碼清單1. Cache.java

 
  1. public class Cache { 
  2.   static Map<String, Object> map = new HashMap<String, Object>(); 
  3.   static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock(); 
  4.   static Lock r = rwl.readLock(); 
  5.   static Lock w = rwl.writeLock(); 
  6.   // 擷取一個key對應的value 
  7.   public static final Object get(String key) { 
  8.     r.lock(); 
  9.     try { 
  10.       return map.get(key); 
  11.     } finally { 
  12.       r.unlock(); 
  13.     } 
  14.   } 
  15.   // 設定key對應的value,並返回舊有的value 
  16.   public static final Object put(String key, Object value) { 
  17.     w.lock(); 
  18.     try { 
  19.       return map.put(key, value); 
  20.     } finally { 
  21.       w.unlock(); 
  22.     } 
  23.   } 
  24.   // 清空所有的內容 
  25.   public static final void clear() { 
  26.     w.lock(); 
  27.     try { 
  28.       map.clear(); 
  29.     } finally { 
  30.       w.unlock(); 
  31.     } 
  32.   } 

上述樣本中,Cache組合了一個非安全執行緒的HashMap作為緩衝的實現,同時使用讀寫鎖的讀鎖和寫鎖來保證Cache是安全執行緒的。在讀操作 get(String key)方法中,需要擷取讀鎖,這使得並發訪問該方法時不會被阻塞。寫操作put(String key, Object value)和clear()方法,在更新HashMap時必須提前擷取寫鎖,當寫鎖被擷取後,其他線程對於讀鎖和寫鎖的擷取均被阻塞,而只有寫鎖被釋放 之後,其他讀寫操作才能繼續。Cache使用讀寫鎖提升讀操作並發性,也保證每次寫操作對所有的讀寫操作的可見度,同時簡化了編程方式。

3. 讀寫鎖的實現分析

接下來將分析ReentrantReadWriteLock的實現,主要包括:讀寫狀態的設計、寫鎖的擷取與釋放、讀鎖的擷取與釋放以及鎖降級以下沒有特別說明讀寫鎖均可認為是ReentrantReadWriteLock)。

3.1 讀寫狀態的設計

讀寫鎖同樣依賴自訂同步器來實現同步功能,而讀寫狀態就是其同步器的同步狀態。回想ReentrantLock中自訂同步器的實現,同步狀態 表示鎖被一個線程重複擷取的次數,而讀寫鎖的自訂同步器需要在同步狀態一個整型變數)上維護多個讀線程和一個寫線程的狀態,使得該狀態的設計成為讀寫 鎖實現的關鍵。

如果在一個整型變數上維護多種狀態,就一定需要“按位切割使用”這個變數,讀寫鎖是將變數切分成了兩個部分,高16位表示讀,低16位表示寫,劃分方式1所示。

圖1. 讀寫鎖狀態的劃分方式

1所示,當前同步狀態表示一個線程已經擷取了寫鎖,且重進入了兩次,同時也連續擷取了兩次讀鎖。讀寫鎖是如何迅速的確定讀和寫各自的狀態呢? 答案是通過位元運算。假設當前同步狀態值為S,寫狀態等於 S & 0x0000FFFF將高16位全部抹去),讀狀態等於 S >>> 16無符號補0右移16位)。當寫狀態增加1時,等於S + 1,當讀狀態增加1時,等於S + (1 << 16),也就是S + 0×00010000。

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

3.2 寫鎖的擷取與釋放

寫鎖是一個支援重進入的排它鎖。如果當前線程已經擷取了寫鎖,則增加寫狀態。如果當前線程在擷取寫鎖時,讀鎖已經被擷取讀狀態不為0)或者該線程不是已經擷取寫鎖的線程,則當前線程進入等待狀態,擷取寫鎖的代碼如代碼清單2所示。

代碼清單2. ReentrantReadWriteLock的tryAcquire方法

 
  1. protected final boolean tryAcquire(int acquires) { 
  2.   Thread current = Thread.currentThread(); 
  3.   int c = getState(); 
  4.   int w = exclusiveCount(c); 
  5.   if (c != 0) { 
  6.     // 存在讀鎖或者當前擷取線程不是已經擷取寫鎖的線程 
  7.     if (w == 0 || current != getExclusiveOwnerThread()) 
  8.       return false; 
  9.     if (w + exclusiveCount(acquires) > MAX_COUNT) 
  10.       throw new Error("Maximum lock count exceeded"); 
  11.     setState(c + acquires); 
  12.     return true; 
  13.   } 
  14.   if (writerShouldBlock() || !compareAndSetState(c, c + acquires)) { 
  15.     return false; 
  16.   } 
  17.   setExclusiveOwnerThread(current); 
  18.   return true; 

該方法除了重入條件當前線程為擷取了寫鎖的線程)之外,增加了一個讀鎖是否存在的判斷。如果存在讀鎖,則寫鎖不能被擷取,原因在於:讀寫鎖要確保 寫鎖的操作對讀鎖可見,如果允許讀鎖在已被擷取的情況下對寫鎖的擷取,那麼正在啟動並執行其他讀線程就無法感知到當前寫線程的操作。因此只有等待其他讀線程都 釋放了讀鎖,寫鎖才能被當前線程所擷取,而寫鎖一旦被擷取,則其他讀寫線程的後續訪問均被阻塞。

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

3.3 讀鎖的擷取與釋放

讀鎖是一個支援重進入的共用鎖定,它能夠被多個線程同時擷取,在沒有其他寫線程訪問或者寫狀態為0)時,讀鎖總會成功的被擷取,而所做的也只是 安全執行緒的)增加讀狀態。如果當前線程已經擷取了讀鎖,則增加讀狀態。如果當前線程在擷取讀鎖時,寫鎖已被其他線程擷取,則進入等待狀態。擷取讀鎖的實 現從Java 5到Java 6變得複雜許多,主要原因是新增了一些功能,比如:getReadHoldCount()方法,返回當前線程擷取讀鎖的次數。讀狀態是所有線程擷取讀鎖次 數的總和,而每個線程各自擷取讀鎖的次數只能選擇儲存在ThreadLocal中,由線程自身維護,這使擷取讀鎖的實現變得複雜。因此,這裡將擷取讀鎖的 代碼做了刪減,保留必要的部分,代碼如代碼清單3所示。

代碼清單3. ReentrantReadWriteLock的tryAcquireShared方法

 
  1. protected final int tryAcquireShared(int unused) { 
  2.   for (;;) { 
  3.     int c = getState(); 
  4.     int nextc = c + (1 << 16); 
  5.     if (nextc < c) 
  6.       throw new Error("Maximum lock count exceeded"); 
  7.     if (exclusiveCount(c) != 0 && owner != Thread.currentThread()) 
  8.       return -1; 
  9.     if (compareAndSetState(c, nextc)) 
  10.       return 1; 
  11.   } 

在tryAcquireShared(int unused)方法中,如果其他線程已經擷取了寫鎖,則當前線程擷取讀鎖失敗,進入等待狀態。如果當前線程擷取了寫鎖或者寫鎖未被擷取,則當前線程(安全執行緒,依靠CAS保證)增加讀狀態,成功擷取讀鎖。

讀鎖的每次釋放均安全執行緒的,可能有多個讀線程同時釋放讀鎖)減少讀狀態,減少的值是(1 << 16)。

3.4 鎖降級

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

接下來看一個鎖降級的樣本:因為資料不常變化,所以多個線程可以並發的進行資料處理,當資料變更後,當前線程如果感知到資料變化,則進行資料的準備工作,同時其他處理線程被阻塞,直到當前線程完成資料的準備工作,範例程式碼如代碼清單4所示。

代碼清單4. processData方法

 
  1. public void processData() { 
  2.   readLock.lock(); 
  3.   if (!update) { 
  4.     // 必須先釋放讀鎖 
  5.     readLock.unlock(); 
  6.     // 鎖降級從寫鎖擷取到開始 
  7.     writeLock.lock(); 
  8.     try { 
  9.       if (!update) { 
  10.         // 準備資料的流程略) 
  11.         update = true; 
  12.       } 
  13.       readLock.lock(); 
  14.     } finally { 
  15.       writeLock.unlock(); 
  16.     } 
  17.     // 鎖降級完成,寫鎖降級為讀鎖 
  18.   } 
  19.   try { 
  20.     // 使用資料的流程略) 
  21.   } finally { 
  22.     readLock.unlock(); 
  23.   } 

上述樣本中,當資料發生變更後,update變數布爾類型且Volatile修飾)被設定為false,此時所有訪問processData() 方法的線程都能夠感知到變化,但只有一個線程能夠擷取到寫鎖,而其他線程會被阻塞在讀鎖和寫鎖的lock()方法上。當前程擷取寫鎖完成資料準備之後,再 擷取讀鎖,隨後釋放寫鎖,完成鎖降級。

鎖降級中讀鎖的擷取是否必要呢?答案是必要的。主要原因是保證資料的可見度,如果當前線程不擷取讀鎖而是直接釋放寫鎖,假設此刻另一個線程記作 線程T)擷取了寫鎖並修改了資料,則當前線程無法感知線程T的資料更新。如果當前線程擷取讀鎖,即遵循鎖降級的步驟,則線程T將會被阻塞,直到當前線程使 用資料並釋放讀鎖之後,線程T才能擷取寫鎖進行資料更新。

RentrantReadWriteLock不支援鎖定擴大把持讀鎖、擷取寫鎖,最後釋放讀鎖的過程)。原因也是保證資料可見度,如果讀鎖已被多個線程擷取,其中任意線程成功擷取了寫鎖並更新了資料,則其更新對其他擷取到讀鎖的線程不可見。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.