1. 程式人生 > >鎖的記憶體語義

鎖的記憶體語義

鎖的釋放-獲取建立的happens-before關係

鎖除了讓臨界區互斥執行外,還可以讓釋放鎖的執行緒向獲取一個鎖的執行緒傳送訊息。

下面是鎖釋放-獲取的示例程式碼。

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關係可以分為3類。

1)根據程式次序規則,1 happens-before 2,2 happens-before 3;4 happens-before 5 5 happens-before 6。

2)根據監視器鎖規則,3 happens-before 4。

3)根據happens-before的傳遞性,2 happens-before 5。

上述happens-before關係的影象化形式如圖:

在上圖中,2 happens-before 5。因此,執行緒A在釋放鎖之前所有可見的共享變數,線上程B獲取同一個鎖之後,將立刻變得對B執行緒可見。

鎖的釋放和獲取的記憶體語義

當執行緒釋放鎖時,JMM會把該執行緒對應的本地記憶體中的共享變數重新整理到主記憶體中。以上面的MonitorExample程式為例,A執行緒釋放鎖後,共享資料的狀態示意圖如圖所示:

當執行緒獲取鎖時,JMM會把該執行緒對應的本地記憶體置為無效。從而使得被監視器保護的臨界區程式碼必須從主記憶體中讀取共享變數。所獲取的狀態示意圖:

對比鎖釋放-獲取的記憶體語義與volatile寫-讀的記憶體語義可以看出:鎖釋放與volatile寫有相同的記憶體語義;鎖獲取與volatile讀有相同的記憶體語義。

下面對鎖釋放和鎖獲取的記憶體語義做個總結。

  • 執行緒A釋放一個鎖,實質上是執行緒A向接下來將要獲取這個鎖的某個執行緒發出了(執行緒A對共享變數所作修改的)訊息。
  • 執行緒B獲取一個鎖,實質上是執行緒B接受了之前某個執行緒發出的(在釋放這個鎖之前對共享變數所做修改的)訊息。
  • 執行緒A釋放鎖,隨後執行緒B獲取這個鎖,這個過程實質上是執行緒A通過主記憶體向執行緒B傳送訊息。

鎖記憶體語義的實現

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()呼叫軌跡如下。

1)ReentrantLock:lock()。

2)FairSync:lock()。

3)AbstractQueuedSynchronizer:acquire(int arg)。

4)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()呼叫軌跡如下。

1)ReentrantLock:lock()。

2)AbstractQueuedSynchronizer:release(int arg)。、

3)Sync:tryRelease(int releases)。

在第3步真正開始釋放鎖,下面是該方法的原始碼。

protected final boolean tryRelease(int releases){
    int c = getState() - release;
    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()呼叫軌跡如下。

1)ReentrantLock:lock()。

2)NonfairSync:lock()。

3)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.Unsafa類的compareAndSwapInt()方法的原始碼。

public final natice boolean compareAndSwapInt(Object o,long offset,
                                             int expected,
                                             int x);

可以看到,這是一個本地方法呼叫。這個本地方法在openjdk中依次呼叫的C++程式碼為:unsafe.cpp,atomic.cpp和atomic_windows_x86.inline.hpp。這個本地方法的最終實現在x86\vm\atomic_windwos_x86.inline.hpp(對應於Windows作業系統,X86處理器位)。下面是對應於intel X86處理器的原始碼的片段。

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)
            comxchg dword ptr (edx), ecx
        }
    }

如上面原始碼所示,程式會根據當前處理器的型別來決定是否為cmpxchg指令新增lock字首。如果程式是在多處理器上執行,就為cmpxchg指令加上lock字首(Lock Cmpxchg)。反之,如果程式是在單處理器上執行,就省略lock字首(單處理器自身會維護單處理器內的順序一致性,不需要lock字首提供的記憶體屏障效果)。

Intel的手冊對lock字首的說明如下。

1)確保對記憶體的讀-改-寫操作原子執行。

2)禁止該指令,與之前和之後的讀和寫指令重排序。

3)把寫緩衝區中的所有資料重新整理到記憶體中。

上面的第2點和第3點所具有的記憶體屏障效果,足以同時實現volatile讀和volatile寫的記憶體語義。

經過上面的分析,現在我們終於能明白為什麼JDK文件說CAS同時具有volatile讀和volatile寫的記憶體語義了。

現在對公平鎖和非公平鎖的記憶體語義做個總結。

  • 公平鎖和非公平鎖釋放時,最後都要寫一個volatile變數state。
  • 公平鎖獲取時,首先會去讀volatile變數。
  • 非公平鎖獲取時,首先會用CAS更新volatile變數,這個操作同時具有volatile讀和volatile寫的記憶體語義。

鎖釋放-獲取的記憶體語義的實現至少有下面兩種方式。

1)利用volatile變數的寫-讀所具有的記憶體語義。

2)利用CAS所附帶的volatile讀和volatile寫的記憶體語義。