標籤:
鎖的釋放-擷取建立的happens before 關係
鎖是java並發編程中最重要的同步機制。鎖除了讓臨界區互斥執行外,還可以讓釋放鎖的線程向擷取同一個鎖的線程發送訊息。
下面是鎖釋放-擷取的範例程式碼:
class MonitorExample { int a = 0; public synchronized void writer() { //1 a++; //2 } //3 public synchronized void reader() { //4 int i = a; //5 …… } //6}
假設線程A執行writer()方法,隨後線程B執行reader()方法。根據happens before規則,這個過程包含的happens before 關係可以分為兩類:
- 根據程式次序規則,1 happens before 2, 2 happens before 3; 4 happens before 5, 5 happens before 6。
- 根據監視器鎖規則,3 happens before 4。
- 根據happens before 的傳遞性,2 happens before 5。
上述happens before 關係的圖形化表現形式如下:
在中,每一個箭頭連結的兩個節點,代表了一個happens before 關係。黑色箭頭表示程式順序規則;橙色箭頭表示監視器鎖規則;藍色箭頭表示組合這些規則後提供的happens before保證。
表示線上程A釋放了鎖之後,隨後線程B擷取同一個鎖。在中,2 happens before 5。因此,線程A在釋放鎖之前所有可見的共用變數,線上程B擷取同一個鎖之後,將立刻變得對B線程可見。
鎖釋放和擷取的記憶體語義
當線程釋放鎖時,JMM會把該線程對應的本地記憶體中的共用變數重新整理到主記憶體中。以上面的MonitorExample程式為例,A線程釋放鎖後,共用資料的狀態如下:
當線程擷取鎖時,JMM會把該線程對應的本地記憶體置為無效。從而使得被監視器保護的臨界區代碼必須要從主記憶體中去讀取共用變數。下面是鎖擷取的狀態:
對比鎖釋放-擷取的記憶體語義與volatile寫-讀的記憶體語義,可以看出:鎖釋放與volatile寫有相同的記憶體語義;鎖擷取與volatile讀有相同的記憶體語義。
下面對鎖釋放和鎖擷取的記憶體語義做個總結:
- 線程A釋放一個鎖,實質上是線程A向接下來將要擷取這個鎖的某個線程發出了(線程A對共用變數所做修改的)訊息。
- 線程B擷取一個鎖,實質上是線程B接收了之前某個線程發出的(在釋放這個鎖之前對共用變數所做修改的)訊息。
- 線程A釋放鎖,隨後線程B擷取這個鎖,這個過程實質上是線程A通過主記憶體向線程B發送訊息。
鎖記憶體語義的實現
本文將藉助ReentrantLock的原始碼,來分析鎖記憶體語義的具體實現機制。
請看下面的範例程式碼:
class ReentrantLockExample {int a = 0;ReentrantLock lock = new ReentrantLock();public void writer() { lock.lock(); //擷取鎖 try { a++; } finally { lock.unlock(); //釋放鎖 }}public void reader () { lock.lock(); //擷取鎖 try { int i = a; …… } finally { lock.unlock(); //釋放鎖 }}}
在ReentrantLock中,調用lock()方法擷取鎖;調用unlock()方法釋放鎖。
ReentrantLock的實現依賴於java同步器架構AbstractQueuedSynchronizer(本文簡稱之為AQS)。AQS使用一個整型的volatile變數(命名為state)來維護同步狀態,馬上我們會看到,這個volatile變數是ReentrantLock記憶體語義實現的關鍵。 下面是ReentrantLock的類圖(僅畫出與本文相關的部分):
ReentrantLock分為公平鎖和非公平鎖,我們首先分析公平鎖。
使用公平鎖時,加鎖方法lock()的方法調用軌跡如下:
- ReentrantLock : lock()
- FairSync : lock()
- AbstractQueuedSynchronizer : acquire(int arg)
- ReentrantLock : tryAcquire(int acquires)
在第4步真正開始加鎖,下面是該方法的原始碼:
protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); //擷取鎖的開始,首先讀volatile變數state if (c == 0) { if (isFirst(current) && 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;}
從上面原始碼中我們可以看出,加鎖方法首先讀volatile變數state。
在使用公平鎖時,解鎖方法unlock()的方法調用軌跡如下:
- ReentrantLock : unlock()
- AbstractQueuedSynchronizer : release(int arg)
- Sync : tryRelease(int releases)
在第3步真正開始釋放鎖,下面是該方法的原始碼:
protected final boolean tryRelease(int releases) { int c = getState() - releases; if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false; if (c == 0) { free = true; setExclusiveOwnerThread(null); } setState(c); //釋放鎖的最後,寫volatile變數state return free;}
從上面的原始碼我們可以看出,在釋放鎖的最後寫volatile變數state。
公平鎖在釋放鎖的最後寫volatile變數state;在擷取鎖時首先讀這個volatile變數。根據volatile的happens-before規則,釋放鎖的線程在寫volatile變數之前可見的共用變數,在擷取鎖的線程讀取同一個volatile變數後將立即變的對擷取鎖的線程可見。
現在我們分析非公平鎖的記憶體語義的實現。
非公平鎖的釋放和公平鎖完全一樣,所以這裡僅僅分析非公平鎖的擷取。
使用公平鎖時,加鎖方法lock()的方法調用軌跡如下:
- ReentrantLock : lock()
- NonfairSync : lock()
- AbstractQueuedSynchronizer : compareAndSetState(int expect, int update)
在第3步真正開始加鎖,下面是該方法的原始碼:
protected final boolean compareAndSetState(int expect, int update) { return unsafe.compareAndSwapInt(this, stateOffset, expect, update);}
該方法以原子操作的方式更新state變數,本文把java的compareAndSet()方法調用簡稱為CAS。JDK文檔對該方法的說明如下:如果目前狀態值等於預期值,則以原子方式將同步狀態設定為給定的更新值。此操作具有 volatile 讀和寫的記憶體語義。
這裡我們分別從編譯器和處理器的角度來分析,CAS如何同時具有volatile讀和volatile寫的記憶體語義。
前文我們提到過,編譯器不會對volatile讀與volatile讀後面的任意記憶體操作重排序;編譯器不會對volatile寫與volatile寫前面的任意記憶體操作重排序。組合這兩個條件,意味著為了同時實現volatile讀和volatile寫的記憶體語義,編譯器不能對CAS與CAS前面和後面的任意記憶體操作重排序。
下面我們來分析在常見的intel x86處理器中,CAS是如何同時具有volatile讀和volatile寫的記憶體語義的。
下面是sun.misc.Unsafe類的compareAndSwapInt()方法的原始碼:
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);
可以看到這是個本地方法調用。這個本地方法在openjdk中依次調用的c++代碼為:unsafe.cpp,atomic.cpp和atomicwindowsx86.inline.hpp。這個本地方法的最終實現在openjdk的如下位置:openjdk-7-fcs-src-b147-27jun2011\openjdk\hotspot\src\oscpu\windowsx86\vm\ atomicwindowsx86.inline.hpp(對應於windows作業系統,X86處理器)。下面是對應於intel x86處理器的原始碼的片段:
// Adding a lock prefix to an instruction on MP machine// VC++ doesn‘t like the lock prefix to be on a single line// so we can‘t insert a label after the lock prefix.// By emitting a lock prefix, we can define a label after it.#define LOCK_IF_MP(mp) __asm cmp mp, 0 __asm je L0 __asm _emit 0xF0 __asm L0:inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) { // alternative for InterlockedCompareExchange int mp = os::is_MP(); __asm { mov edx, dest mov ecx, exchange_value mov eax, compare_value LOCK_IF_MP(mp) cmpxchg dword ptr [edx], ecx }}
如上面原始碼所示,程式會根據當前處理器的類型來決定是否為cmpxchg指令添加lock首碼。如果程式是在多處理器上運行,就為cmpxchg指令加上lock首碼(lock cmpxchg)。反之,如果程式是在單一處理器上運行,就省略lock首碼(單一處理器自身會維護單一處理器內的順序一致性,不需要lock首碼提供的記憶體屏障效果)。
intel的手冊對lock首碼的說明如下:
- 確保對記憶體的讀-改-寫操作原子執行。在Pentium及Pentium之前的處理器中,帶有lock首碼的指令在執行期間會鎖住匯流排,使得其他處理器暫時無法通過匯流排訪問記憶體。很顯然,這會帶來昂貴的開銷。從Pentium 4,Intel Xeon及P6處理器開始,intel在原有匯流排鎖的基礎上做了一個很有意義的最佳化:如果要訪問的記憶體地區(area of memory)在lock首碼指令執行期間已經在處理器內部的緩衝中被鎖定(即包含該記憶體地區的緩衝行當前處於獨佔或以修改狀態),並且該記憶體地區被完全包含在單個緩衝行(cache line)中,那麼處理器將直接執行該指令。由於在指令執行期間該緩衝行會一直被鎖定,其它處理器無法讀/寫該指令要訪問的記憶體地區,因此能保證指令執行的原子性。這個操作過程叫做緩衝鎖定(cache locking),緩衝鎖定將大大降低lock首碼指令的執行開銷,但是當多處理器之間的競爭程度很高或者指令訪問的記憶體位址未對齊時,仍然會鎖住匯流排。
- 禁止該指令與之前和之後的讀和寫指令重排序。
- 把寫緩衝區中的所有資料重新整理到記憶體中。
上面的第2點和第3點所具有的記憶體屏障效果,足以同時實現volatile讀和volatile寫的記憶體語義。
經過上面的這些分析,現在我們終於能明白為什麼JDK文檔說CAS同時具有volatile讀和volatile寫的記憶體語義了。
現在對公平鎖和非公平鎖的記憶體語義做個總結:
- 公平鎖和非公平鎖釋放時,最後都要寫一個volatile變數state。
- 公平鎖擷取時,首先會去讀這個volatile變數。
- 非公平鎖擷取時,首先會用CAS更新這個volatile變數,這個操作同時具有volatile讀和volatile寫的記憶體語義。
從本文對ReentrantLock的分析可以看出,鎖釋放-擷取的記憶體語義的實現至少有下面兩種方式:
- 利用volatile變數的寫-讀所具有的記憶體語義。
- 利用CAS所附帶的volatile讀和volatile寫的記憶體語義。
concurrent包的實現
由於java的CAS同時具有 volatile 讀和volatile寫的記憶體語義,因此Java線程之間的通訊現在有了下面四種方式:
- A線程寫volatile變數,隨後B線程讀這個volatile變數。
- A線程寫volatile變數,隨後B線程用CAS更新這個volatile變數。
- A線程用CAS更新一個volatile變數,隨後B線程用CAS更新這個volatile變數。
- A線程用CAS更新一個volatile變數,隨後B線程讀這個volatile變數。
Java的CAS會使用現代處理器上提供的高效機器層級原子指令,這些原子指令以原子方式對記憶體執行讀-改-寫操作,這是在多處理器中實現同步的關鍵(從本質上來說,能夠支援原子性讀-改-寫指令的電腦器,是順序計算圖靈機的非同步等價機器,因此任何現代的多處理器都會去支援某種能對記憶體執行原子性讀-改-寫操作的原子指令)。同時,volatile變數的讀/寫和CAS可以實現線程之間的通訊。把這些特性整合在一起,就形成了整個concurrent包得以實現的基石。如果我們仔細分析concurrent包的原始碼實現,會發現一個通用化的實現模式:
- 首先,聲明共用變數為volatile;
- 然後,使用CAS的原子條件更新來實現線程之間的同步;
- 同時,配合以volatile的讀/寫和CAS所具有的volatile讀和寫的記憶體語義來實現線程之間的通訊。
AQS,非阻塞資料結構和原子變數類(java.util.concurrent.atomic包中的類),這些concurrent包中的基礎類都是使用這種模式來實現的,而concurrent包中的高層類又是依賴於這些基礎類來實現的。從整體來看,concurrent包的實現如下:
深入理解Java記憶體模型(五)——鎖