【Java併發編程的藝術】【學習筆記】併發基礎

2、併發基礎

2.1、AQS

​ 隊列同步器AbstractQueuedSynchronizer(以下簡稱同步器),是用來構建鎖或者其他同步組件的基礎框架,它使用了一個int成員變量表示同步狀態,通過內置的FIFO隊列來完成資源獲取線程的排隊工作,併發包的作者(Doug Lea)希望他能夠成爲實現大部分同步需求的基礎。

​ 同步器的設計是基於模板方法模式的,主要使用方式是繼承,子類通過繼承同步器並實現它的抽象方法來管理同步狀態,在抽象方法的實現過程中免不了要對同步狀態進行更改,這時就需要使用同步器提供的3個方法(getState()、getState(int new State)和compareAndSetState(int expect,int update))來進行操作,因爲它們能夠保證狀態的改變是安全的。子類推薦被定義爲自定義同步組件的靜態內部類,同步器自身沒有實現任何同步接口,它僅僅定義了若干同步狀態獲取和釋放的方法來供自定義同步組件使用,同步器即可以支持獨佔式地獲取同步狀態,也可以支持共享式地獲取同步狀態,這樣就可以方便實現不同類型的同步組件。

​ 同步器是實現鎖(也可以是任意同步組件)的關鍵,在鎖的實現中聚合同步器,利用同步器實現鎖的語義。可以這樣理解二者之間的關係:鎖是面向使用者的,它定義了使用者與鎖交互的接口(比如可以允許兩個線程並向訪問),隱藏了實現細節;同步器面向的是鎖的實現者,它簡化了鎖的實現方式屏蔽了同步狀態管理、線程的排隊、等待與喚醒等底層操作。鎖和同步器很好地隔離了使用者和實現者所需關注的領域。

同步器可重寫的方法:

方法名稱 描述
tryAcquire(int arg) 獨佔式獲取同步狀態,實現該方法需要查詢當前狀態(getState()獲取當前同步狀態)並判斷同步狀態是否符合預期,然後在進行CAS(compareAndSetState(int expect,int update))設置同步狀態
tryRelease(int arg) 獨佔式釋放同步狀態,等待獲取同步狀態的線程將有機會獲取同步狀態
tryAcquireShared(int arg) 共享式獲取同步狀態,返回大於等於0的值,表示獲取成功
tryReleaseShared(int arg) 共享式釋放同步狀態
isHeldExclusively() 當前同步器是否在獨佔模式下被線程佔用,一般該方法表示是否被當前線程所佔用

實現自定義同步組件(自定義鎖)時,將會調用同步器提供的模板方法:

方法名稱 描述
acquire(int arg) 獨佔式獲取同步狀態,如果當前線程獲取同步狀態成功,則由該方法返回,否則,將會進入同步隊列等待,該方法將會調用重寫的tryAcquire(int arg)方法
acquireInterruptibly(int arg) 與acquire(int arg)相同,但是該方法響應中斷,當前線程未獲取到同步狀態而進入同步隊列中,如果當前線程被中斷,則該方法會拋出InterruptedExecption並返回
tryAcquireNanos(int arg, long nanos) 在acquireInterruptibly(int arg)基礎上增加了超時限制,如果當前線程在超時時間內沒有獲取到同步狀態,那麼將會返回false
acquireShared(int arg) 共享式的獲取同步狀態,如果當前線程未獲取到同步狀態,將會進入同步隊列等待,與獨佔式獲取的主要區別是在同一時刻可以有多個線程獲取到同步狀態
acquireShareInterruptibly(int arg) 與acquireShared(int arg)相同,該方法響應中斷
tryAcquireShareNanos(int arg,long nanos) 在acquireSharedInterruptibly(int arg)基礎上增加了超時限制
release(int arg) 獨佔式的釋放同步狀態,該方法會在釋放同步狀態之後,將同步隊列中第一個節點包含的線程喚醒
releaseShared(int arg) 共享式的釋放同步狀態
getQueuedThreads() 獲取等待在同步隊列上的線程集合

總結:

AbstractQueuedSynchronizer,同步器,實現JUC核心基礎組件。

解決了子類實現同步器涉及的大量細節問題,例如獲取同步狀態,FIFO同步隊列。

採用模板方法模式,AQS實現大量通用方法,子類通過繼承方式實現其抽象方法來管理同步狀態。

自定義同步組件(自定義鎖)將使用同步器提供的模板方法來實現自己的同步語義。

同步器提供的模板方法基本分3類:獨佔式獲取與釋放同步狀態、共享式獲取與釋放同步狀態和查詢同步隊列中的等待線程情況。

AQS的實現分析

​ 從實現角度分析同步器是如何完成線程同步的,主要包括:同步隊列、獨佔式同步狀態獲取與釋放、共享式同步狀態獲取與釋放以及超時獲取同步狀態等同步器的核心數據結構與模板方法。

同步隊列

​ 同步器依賴內部的同步隊列(一個FIFO雙向隊列)來完成同步狀態的管理,當前線程獲取同步狀態失敗時,同步器會將當前線程以及狀態等信息構造成爲一個節點(Node)並將其加入同步隊列,同時會阻塞當前線程,當同步狀態釋放時,會把首節點中的線程喚醒,使其再次嘗試獲取同步狀態。

​ 同步隊列中的節點(Node)用來保存獲取同步狀態失敗線程引用等待狀態以及前驅後繼節點,節點的屬性類型名稱以及描述

節點的屬性類型和描述:

屬性類型與名稱屬性類型與名稱 描述
int waitStatus 等待狀態。1)cancelled,值爲1,由於同步隊列中等待的線程等待超時或者被中斷,需要從同步隊列中取消等待,節點進入該狀態將不會變化。2)signal,值爲-1,後繼節點的線程處於等待狀態,而當前節點的線程如果釋放了同步狀態或者被取消,將會通知後繼節點,使後繼節點的線程得以運行。3)condition,值爲-2,節點在等待隊列中,節點線程等待在Condition上,當其他線程對Condition調用了signal()方法後,該節點將會從等待隊列中轉移到同步隊列中,加入到對同步狀態的獲取中。4)propagate,值爲-3,表示下一次共享式同步狀態獲取將會無條件地被傳播下去。5)initial,值爲0,初始狀態
Node prev 前驅節點,當節點加入同步隊列時被設置(尾部添加)
Node next 後繼節點
Node nextWaiter 等待隊列中的後繼節點。如果當前節點是共享的,那麼這個字段將是一個shared常量,也就是說節點類型(獨佔和共享)和等待隊列中的後繼節點共用同一個字段
Thread thread 獲取同步狀態的線程

​ 節點是構成同步隊列(等待隊列,下面會介紹)的基礎,同步器擁有首節點和尾節點,沒有成功獲取同步狀態的線程將會成爲節點加入該隊列的尾部。

​ 同步器包含了兩個節點類型的引用,一個指向頭節點,而另一個指向尾節點。試想一下,當一個線程成功地獲取了同步狀態(或者鎖),其他線程將無法獲取到同步狀態,轉而被構造成爲節點並加入到同步隊列中,而這個加入隊列的過程必須要保證線程安全,因此同步器提供了一個基於CAS的設置尾節點的方法:compareAdnSetTail(Node expect,Node update),它需要傳遞當前線程”認爲”的尾節點和當前節點,只有設置成功後,當前節點才正式與之前的尾節點建立關聯。

​ 同步隊列遵循FIFO,首節點是獲取同步狀態成功的節點,首節點的線程在釋放同步狀態時,將會喚醒後繼節點,而後繼節點將會在獲取同步狀態成功時,將自己設置 爲首節點。

獨佔式同步狀態獲取與釋放

​ 通過調用同步器的acquire(int arg)方法可以獲取同步狀態,該方法對中斷不敏感,也就是說由於線程獲取同步狀態失敗後進入同步隊列中,後續對線程進行中斷操作時,線程不會從同步隊列中移出。

同步器的acquire方法:

public final void acquire(int arg) {
  if (!tryAcquire(arg) &&
      acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
    selfInterrupt();
}

上述代碼主要完成了同步狀態獲取節點構造加入同步隊列以及在同步隊列中自旋等待的相關工作,其主要邏輯是:

首先調用自定義同步器實現的tryAcquire(int arg)方法,該方法保證線程安全的獲取同步狀態。

如果同步狀態獲取失敗,則構建同步節點(獨佔式NodeEXCLUSIVE,同一時刻只有一個線程成功獲取同步狀態)並通過addWaiter(Node node)方法將該節點加入到同步隊列的尾部。

最後調用acquireQueued(Node node, int arg)方法,使得該節點以“死循環”的方式獲取同步狀態。

如果獲取不到則阻塞節點中的線程,而被組賽線程的喚醒主要依靠前驅節點的出隊或阻塞線程被中斷來實現。

節點的構造以及加入同步隊列分析:

private Node addWaiter(Node mode) {
  Node node = new Node(Thread.currentThread(), mode);
  // 快速嘗試在尾部添加
  Node pred = tail;
  if (pred != null) {
    node.prev = pred;
    if (compareAndSetTail(pred, node)) {
      pred.next = node;
      return node;
    }
  }
  enq(node);
  return node;
} 
private Node enq(final Node node) {
  for (;;) {
    Node t = tail;
    if (t == null) { // Must initialize
      if (compareAndSetHead(new Node()))
        tail = head;
    } else {
      node.prev = t;
      if (compareAndSetTail(t, node)) {
        t.next = node;
        return t;
      }
    }
  }
}

​ 上述代碼通過似乎用compareAndSetTail(Node expect, Node update)方法來確保節點能夠被線程安全添加。

​ 在enq(final Node node)方法中,同步器通過“死循環”來保證節點的正確添加,在“死循環”中只有通過CAS將節點設置成爲尾節點之後,當前線程才能從該方法返回,否則,當前線程不斷地嘗試設置。可以看出,enq(final Node node)方法將併發添加節點的請求通過CAS變得“串行化”

​ 節點進入同步隊列之後,就進入了一個自旋的過程,每個節點(或者說每個線程)都在自省地觀察,當條件滿足,獲取了同步狀態,就可以從這個自旋過程中退出,否則依舊留在這個自旋過程中(並會阻塞節點的線程)。

同步器的acquireQueued方法:

final boolean acquireQueued(final Node node, int arg) {
  boolean failed = true;
  try {
    boolean interrupted = false;
    for (;;) {
      final Node p = node.predecessor();//獲取前驅節點
      if (p == head && tryAcquire(arg)) {
        setHead(node);
        p.next = null; // help GC
        failed = false;
        return interrupted;
      }
      if (shouldParkAfterFailedAcquire(p, node) &&
          parkAndCheckInterrupt())
        interrupted = true;
    }
  } finally {
    if (failed)
      cancelAcquire(node);
  }
}

​ 在acquireQueued(final Node node, int arg)方法中,當前線程在”死循環”中嘗試獲取同步狀態,而只有前驅節點是頭節點才能夠嘗試獲取同步狀態,這是爲什麼?原因有兩個:

  1. 頭節點是成功獲取到同步狀態的節點,而頭節點的線程釋放了同步狀態之後,將會喚醒其他後繼節點,後繼節點的線程被喚醒後需要檢查自己的前驅節點是否是頭節點。
  2. 維護同步隊列的FIFO原則。該方法中,節點自旋獲取同步狀態。

​ 由於非首節點線程前驅節點出隊或者被中斷而從等待狀態返回,隨後檢查自己的前驅是否是頭節點,如果是則嘗試獲取同步狀態。節點和節點之間在循環檢查的過程中基本不互相通信,而是簡單的判斷自己的前驅是否爲頭節點,這樣就使得節點的釋放規則符合FIFO,並且也便於對過早通知的處理(過早通知是指前驅節點不是頭節點的線程由於中斷而被喚醒)。

​ 通過調用同步器的release(int arg)方法可以釋放同步狀態,該方法在釋放了同步狀態之後,會喚醒其後繼節點(進而使後繼節點重新重試獲取同步狀態)。

public final boolean release(int arg) {
  if (tryRelease(arg)) {
    Node h = head;
    if (h != null && h.waitStatus != 0)
      unparkSuccessor(h); //使用LockSupport來喚醒
    return true;
  }
  return false;
}

總結:

在獲取同步狀態時,同步器維護一個同步隊列,獲取狀態失敗的線程都會被加入到隊列中,並在隊列中進行自旋;

移出隊列(或者停止自旋)的條件是前驅節點爲頭節點,且成功獲取了同步狀態。

在釋放同步狀態時,同步器調用tryRelease(int arg)方法釋放同步狀態,然後喚醒頭節點的後繼節點。

共享式同步狀態獲取與釋放

​ 共享式獲取與獨佔式獲取最主要的區別在於同一時刻能否有多個線程同時獲取到同步狀態。共享式訪問資源時,其他共享式的訪問均被允許,而獨佔式訪問被阻塞。

​ 通過調用同步器的acquireShared(int arg)方法可以共享式地獲取同步狀態。

同步器的acquireShared和doAcquireShared方法:

public final void acquireShared(int arg) {
  if (tryAcquireShared(arg) < 0)
    doAcquireShared(arg);
} 
private void doAcquireShared(int arg) {
  final Node node = addWaiter(Node.SHARED);
  boolean failed = true;
  try {
    boolean interrupted = false;
    for (;;) {
      final Node p = node.predecessor();
      if (p == head) {
        int r = tryAcquireShared(arg);//獲取共享鎖
        if (r >= 0) {
          setHeadAndPropagate(node, r);
          p.next = null;
          if (interrupted)
            selfInterrupt();
          failed = false;
          return;
        }
      }
      if (shouldParkAfterFailedAcquire(p, node) &&
          parkAndCheckInterrupt())
        interrupted = true;
    }
  } finally {
    if (failed)
      cancelAcquire(node);
  }
}

​ 在acquireShared(int arg)方法中,同步器調用tryAcquireShared(int arg)方法嘗試獲取同步狀態,tryAcquireShared(int arg)方法返回值爲int類型,當返回值大於等於0時,表示能夠獲取到同步狀態。因此,在共享式獲取的自選過程中,成功獲取到同步狀態並退出自旋的條件就是tryAcquireShared(int arg)方法返回值大於等於0。可以看到,在doAcquireShared(int arg)方法的自旋過程中,如果當前節點的前驅爲頭節點時,嘗試獲取同步狀態,如果返回值大於等於0,表示該次獲取同步狀態成功並從自旋過程中退出。

共享式釋放同步狀態:

public final boolean releaseShared(int arg) {
  if (tryReleaseShared(arg)) {
    doReleaseShared();
    return true;
  }
  return false;
}

​ 對於能夠支持多個線程同時訪問的併發組件(比如Semaphore),它和獨佔式的區別在於tryReleaseShared(int arg)方法必須確保同步狀態(或者資源數)線程安全釋放,一般是通過循環和CAS來保證的,因爲釋放狀態的操作會同時來自多個線程。

線程阻塞和喚醒

​ 當有線程獲取鎖了,其他再次獲取時需要阻塞,當線程釋放鎖後,AQS負責喚醒線程。

private void unparkSuccessor(Node node) {

  int ws = node.waitStatus;
  if (ws < 0)
    compareAndSetWaitStatus(node, ws, 0);

  Node s = node.next;  //當期節點的後繼節點
  if (s == null || s.waitStatus > 0) {
    s = null;
    for (Node t = tail; t != null && t != node; t = t.prev)//從尾節點開始遍歷
      if (t.waitStatus <= 0) //如果小於等於0,則賦值給s,能得到當前同步隊列中的頭節點
        s = t;
  }
  if (s != null)
    LockSupport.unpark(s.thread); //喚醒頭節點
}
LockSupport

​ LockSupport是用來創建鎖和其他同步類的基本線程阻塞原語。

​ 當需要阻塞或喚醒一個線程的時候,都會使用LockSupport工具類來完成相應工作。LockSupport定義了一組的公共靜態方法,這些方法提供了最基本的線程阻塞和喚醒功能,而LockSupport也成構建同步組件的基礎工具。

​ 每個使用LockSupport的線程都會與一個許可(Condition)關聯,如果該許可可用,並且可在進程中使用,則調用park()將會立即返回,否則可能阻塞。如果許可尚不可用,則可以調用unpark使其可用。

​ 從線程的dump結果中,有阻塞對象的parkNanos方法能夠傳遞給開發人員功多現場信息,Java5推出的Lock等併發工具時,遺漏了這點。

2.2、CAS

​ Compare And Swap(比較交換),整個JUC體系最核心、最基礎理論。

​ 內存值V、舊的預期值A、要更新的值B,當前僅當內存值V的值等於舊的預期值A時纔會將內存值V的值修改爲B,否則什麼都不幹。

​ native方法。

原子操作的實現原理

​ 原子(atomic)本意是“不能被進一步分割的最小粒子”,而原子操作意爲“不可被中斷的一個或一些列操作”。

CPU術語定義:

術語名稱 英文 解釋
緩存行 Cache line 緩存的最小操作單位
比較並交換 Compare and Swap CAS操作需要輸入兩個數值,一箇舊值(期望操作前的值)和一個新值,在操作期間先比較舊值有沒有發生變化,如果沒有發生變化,才交換成新值,發生了變化則不交換。
CPU流水線 CPU pipeline CPU流水線的工作方式就像工業生產上的裝配流水線,在CPU中由5-6個不同功能電路單元組成一條指令處理流水線,然後將一條x86指令分成5-6步後再由這些電路單元分別執行,這樣就能實現在一個CPU時鐘週期完成一條指令,因此提供CPU的運算速度。
內存順序衝突 Memory order violation 內存順序衝突一般是由假共享引起的,假共享是指多個CPU同時修改同一個緩存行的不同部分而引起其中一個CPU的操作無效,當出現這個內存順序衝突時,CPU必須清空流水線。

處理器如何實現原子操作

​ 32位IA-32處理器使用基於對緩存加鎖總線加鎖的方式來實現多處理器之間的原子操作。首先處理器會自動保證基本的內存操作的原子性。處理器保證從系統內存中讀取或者寫入一個字節是原子的,意思是當一個處理器讀取一個字節時,其他處理器不同訪問這個字節的內存地址。但是複雜的內存操作處理器是不能自動保證其原子性的,比如跨總線寬度、跨多個緩存行和跨頁表的訪問。但是,處理器提供總線鎖定和緩存鎖定兩個機制來保證複雜內存操作的原子性。

​ 所謂總線鎖就是使用處理器提供的一個LOCK#信號,當一個處理器在總線上輸出此信號時,其他處理器的請求將被阻塞住,那麼該處理器可以獨佔共享內存。

​ 在同一時刻,我們只需要保證對某個內存地址的操作是原子性即可,但是總線鎖定把CPU和內存之間的通信鎖住了,這使得鎖定期間,其他處理器不能操作其他內存地址的數據,所以總線鎖定的開銷比較大,目前處理器在某些場合下使用緩存鎖定代替總線鎖定來進行優化。

​ 所謂緩存鎖定是指內存區域如果被緩存在處理器的緩存行中,並且在LOCK操作期間被鎖定,那麼當它執行鎖操作回寫到內存時,處理器不再總線上聲言LOCK#信號,而是修改內部的內存地址,並允許它的緩存一致性機制來保證操作的原子性,因爲緩存一致性機制會阻止同時修改由兩個處理器緩存的內存區域數據,當其他處理器回寫已被鎖定的緩存行的數據時,會使緩存行無效。

​ 下面兩種情況處理器不會使用緩存鎖定:

第一:當操作的數據不能被緩存在處理器內部,或操作的數據跨多個緩存行,則處理器會調用總線鎖定。

第二:有些處理器不支持緩存鎖定。

Java如何實現原子操作

​ 在Java中可以通過鎖和循環CAS的方式來實現原子操作。

​ 1)、使用循環CAS實現原子操作,JVM中CAS操作正是利用了處理器提供的CMPXCHG指令實現的。自旋CAS實現的基本思路就是循環進行CAS操作直到成功。

CAS實現原子操作有三大問題:

  1. ABA問題。

    CAS操作時候,檢查值有麼有發生變化,如果值從A->B->A,CAS檢查不出來。

    解決辦法一:在變量前面追加版本號,每次變量更新的時候把版本號加1,那麼A->B->A,就變成1A->2B->3A。

    解決方法二:使用AtomicStampeReference來解決ABA問題。這個類的compareAndSet方法的作用是首先檢查當前引用是否等於預期引用,並且檢查當前標誌是否等於預期標誌。

  2. 循環時間長開銷大。

    自旋CAS如果長時間不成功,會給CPU帶來非常大的執行開銷。

  3. 只能保證一個共享變量的原子操作。

    對多個共享變量操作時,無法保證操作的原子性,只能用鎖。

​ 2)、使用鎖實現原子操作

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章