天天看點

《Java并發程式設計的藝術》-Java并發包中的讀寫鎖及其實作分析

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

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

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

一般情況下,讀寫鎖的性能都會比排它鎖要好,因為大多數場景讀是多于寫的。在讀多于寫的情況下,讀寫鎖能夠提供比排它鎖更好的并發性和吞吐量。java并發包提供讀寫鎖的實作是reentrantreadwritelock,它提供的特性如表1所示。

表1. reentrantreadwritelock的特性

特性

說明

公平性選擇

支援非公平(預設)和公平的鎖擷取方式,吞吐量還是非公平優于公平

重進入

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

鎖降級

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

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

上述示例中,cache組合了一個非線程安全的hashmap作為緩存的實作,同時使用讀寫鎖的讀鎖和寫鎖來保證cache是線程安全的。在讀操作get(string key)方法中,需要擷取讀鎖,這使得并發通路該方法時不會被阻塞。寫操作put(string key, object value)和clear()方法,在更新hashmap時必須提前擷取寫鎖,當寫鎖被擷取後,其他線程對于讀鎖和寫鎖的擷取均被阻塞,而隻有寫鎖被釋放之後,其他讀寫操作才能繼續。cache使用讀寫鎖提升讀操作并發性,也保證每次寫操作對所有的讀寫操作的可見性,同時簡化了程式設計方式。

接下來将分析reentrantreadwritelock的實作,主要包括:讀寫狀态的設計、寫鎖的擷取與釋放、讀鎖的擷取與釋放以及鎖降級(以下沒有特别說明讀寫鎖均可認為是reentrantreadwritelock)。

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

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

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

《Java并發程式設計的藝術》-Java并發包中的讀寫鎖及其實作分析

        如圖1所示,目前同步狀态表示一個線程已經擷取了寫鎖,且重進入了兩次,同時也連續擷取了兩次讀鎖。讀寫鎖是如何迅速的确定讀和寫各自的狀态呢?答案是通過位運算。假設目前同步狀态值為s,寫狀态等于 s & 0x0000ffff(将高16位全部抹去),讀狀态等于 s >>> 16(無符号補0右移16位)。當寫狀态增加1時,等于s + 1,當讀狀态增加1時,等于s + (1 << 16),也就是s + 0x00010000。

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

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

代碼清單2. reentrantreadwritelock的tryacquire方法

該方法除了重入條件(目前線程為擷取了寫鎖的線程)之外,增加了一個讀鎖是否存在的判斷。如果存在讀鎖,則寫鎖不能被擷取,原因在于:讀寫鎖要確定寫鎖的操作對讀鎖可見,如果允許讀鎖在已被擷取的情況下對寫鎖的擷取,那麼正在運作的其他讀線程就無法感覺到目前寫線程的操作。是以隻有等待其他讀線程都釋放了讀鎖,寫鎖才能被目前線程所擷取,而寫鎖一旦被擷取,則其他讀寫線程的後續通路均被阻塞。

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

讀鎖是一個支援重進入的共享鎖,它能夠被多個線程同時擷取,在沒有其他寫線程通路(或者寫狀态為0)時,讀鎖總會成功的被擷取,而所做的也隻是(線程安全的)增加讀狀态。如果目前線程已經擷取了讀鎖,則增加讀狀态。如果目前線程在擷取讀鎖時,寫鎖已被其他線程擷取,則進入等待狀态。擷取讀鎖的實作從java 5到java 6變得複雜許多,主要原因是新增了一些功能,比如:getreadholdcount()方法,傳回目前線程擷取讀鎖的次數。讀狀态是所有線程擷取讀鎖次數的總和,而每個線程各自擷取讀鎖的次數隻能選擇儲存在threadlocal中,由線程自身維護,這使擷取讀鎖的實作變得複雜。是以,這裡将擷取讀鎖的代碼做了删減,保留必要的部分,代碼如代碼清單3所示。

代碼清單3. reentrantreadwritelock的tryacquireshared方法

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

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

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

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

代碼清單4. processdata方法

上述示例中,當資料發生變更後,update變量(布爾類型且volatile修飾)被設定為false,此時所有通路processdata()方法的線程都能夠感覺到變化,但隻有一個線程能夠擷取到寫鎖,而其他線程會被阻塞在讀鎖和寫鎖的lock()方法上。目前程擷取寫鎖完成資料準備之後,再擷取讀鎖,随後釋放寫鎖,完成鎖降級。

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

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