天天看點

深入了解Java記憶體模型(五)——鎖

鎖的釋放-擷取建立的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. 根據程式次序規則,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 關系的圖形化表現形式如下:

深入了解Java記憶體模型(五)——鎖

在上圖中,每一個箭頭連結的兩個節點,代表了一個happens before 關系。黑色箭頭表示程式順序規則;橙色箭頭表示螢幕鎖規則;藍色箭頭表示組合這些規則後提供的happens before保證。

上圖表示線上程A釋放了鎖之後,随後線程B擷取同一個鎖。在上圖中,2 happens before 5。是以,線程A在釋放鎖之前所有可見的共享變量,線上程B擷取同一個鎖之後,将立刻變得對B線程可見。

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

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

深入了解Java記憶體模型(五)——鎖

當線程擷取鎖時,JMM會把該線程對應的本地記憶體置為無效。進而使得被螢幕保護的臨界區代碼必須要從主記憶體中去讀取共享變量。下面是鎖擷取的狀态示意圖:

深入了解Java記憶體模型(五)——鎖

對比鎖釋放-擷取的記憶體語義與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();        //擷取鎖

        int i = a;

在ReentrantLock中,調用lock()方法擷取鎖;調用unlock()方法釋放鎖。

ReentrantLock的實作依賴于java同步器架構AbstractQueuedSynchronizer(本文簡稱之為AQS)。AQS使用一個整型的volatile變量(命名為state)來維護同步狀态,馬上我們會看到,這個volatile變量是ReentrantLock記憶體語義實作的關鍵。 下面是ReentrantLock的類圖(僅畫出與本文相關的部分):

深入了解Java記憶體模型(五)——鎖

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 : unlock()
  2. AbstractQueuedSynchronizer : release(int arg)
  3. 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;

        free = true;

        setExclusiveOwnerThread(null);

    setState(c);           //釋放鎖的最後,寫volatile變量state

    return free;

從上面的源代碼我們可以看出,在釋放鎖的最後寫volatile變量state。

公平鎖在釋放鎖的最後寫volatile變量state;在擷取鎖時首先讀這個volatile變量。根據volatile的happens-before規則,釋放鎖的線程在寫volatile變量之前可見的共享變量,在擷取鎖的線程讀取同一個volatile變量後将立即變的對擷取鎖的線程可見。

現在我們分析非公平鎖的記憶體語義的實作。

非公平鎖的釋放和公平鎖完全一樣,是以這裡僅僅分析非公平鎖的擷取。

使用非公平鎖時,加鎖方法lock()的方法調用軌迹如下:

  1. NonfairSync : lock()
  2. 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和atomic*windows*x86.inline.hpp

。這個本地方法的最終實作在openjdk的如下位置:

openjdk-7-fcs-src-b147-27*jun*2011\openjdk\hotspot\src\os*cpu\windows*x86\vm\atomic*windows*x86.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字首的說明如下:

  1. 確定對記憶體的讀-改-寫操作原子執行。在Pentium及Pentium之前的處理器中,帶有lock字首的指令在執行期間會鎖住總線,使得其他處理器暫時無法通過總線通路記憶體。很顯然,這會帶來昂貴的開銷。從Pentium 4,Intel Xeon及P6處理器開始,intel在原有總線鎖的基礎上做了一個很有意義的優化:如果要通路的記憶體區域(area of memory)在lock字首指令執行期間已經在處理器内部的緩存中被鎖定(即包含該記憶體區域的緩存行目前處于獨占或以修改狀态),并且該記憶體區域被完全包含在單個緩存行(cache line)中,那麼處理器将直接執行該指令。由于在指令執行期間該緩存行會一直被鎖定,其它處理器無法讀/寫該指令要通路的記憶體區域,是以能保證指令執行的原子性。這個操作過程叫做緩存鎖定(cache locking),緩存鎖定将大大降低lock字首指令的執行開銷,但是當多處理器之間的競争程度很高或者指令通路的記憶體位址未對齊時,仍然會鎖住總線。
  2. 禁止該指令與之前和之後的讀和寫指令重排序。
  3. 把寫緩沖區中的所有資料重新整理到記憶體中。

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

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

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

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

從本文對ReentrantLock的分析可以看出,鎖釋放-擷取的記憶體語義的實作至少有下面兩種方式:

  1. 利用volatile變量的寫-讀所具有的記憶體語義。
  2. 利用CAS所附帶的volatile讀和volatile寫的記憶體語義。

concurrent包的實作

由于java的CAS同時具有 volatile 讀和volatile寫的記憶體語義,是以Java線程之間的通信現在有了下面四種方式:

  1. A線程寫volatile變量,随後B線程讀這個volatile變量。
  2. A線程寫volatile變量,随後B線程用CAS更新這個volatile變量。
  3. A線程用CAS更新一個volatile變量,随後B線程用CAS更新這個volatile變量。
  4. A線程用CAS更新一個volatile變量,随後B線程讀這個volatile變量。

Java的CAS會使用現代處理器上提供的高效機器級别原子指令,這些原子指令以原子方式對記憶體執行讀-改-寫操作,這是在多處理器中實作同步的關鍵(從本質上來說,能夠支援原子性讀-改-寫指令的計算機器,是順序計算圖靈機的異步等價機器,是以任何現代的多處理器都會去支援某種能對記憶體執行原子性讀-改-寫操作的原子指令)。同時,volatile變量的讀/寫和CAS可以實作線程之間的通信。把這些特性整合在一起,就形成了整個concurrent包得以實作的基石。如果我們仔細分析concurrent包的源代碼實作,會發現一個通用化的實作模式:

  1. 首先,聲明共享變量為volatile;
  2. 然後,使用CAS的原子條件更新來實作線程之間的同步;
  3. 同時,配合以volatile的讀/寫和CAS所具有的volatile讀和寫的記憶體語義來實作線程之間的通信。

AQS,非阻塞資料結構和原子變量類(java.util.concurrent.atomic包中的類),這些concurrent包中的基礎類都是使用這種模式來實作的,而concurrent包中的高層類又是依賴于這些基礎類來實作的。從整體來看,concurrent包的實作示意圖如下:

深入了解Java記憶體模型(五)——鎖

參考文獻

  1.  Concurrent Programming in Java: Design Principles and Pattern
  2.  JSR 133 (Java Memory Model) FAQ
  3.  JSR-133: Java Memory Model and Thread Specification
  4.  Java Concurrency in Practice
  5.  Java™ Platform, Standard Edition 6 API Specification
  6.  The JSR-133 Cookbook for Compiler Writers
  7.  Intel® 64 and IA-32 ArchitecturesvSoftware Developer’s Manual Volume 3A: System Programming Guide, Part 1
  8.  The Art of Multiprocessor Programming