Java中的多線程與鎖(三)(隊列同步器)

0. 隊列同步器(java.util.concurrent.locks.AbstractQueuedSynchronizer)
  1. 隊列同步器提供了更改鎖狀態的最基礎的 ‘原子操作’(上一篇文章 Java中的多線程與鎖(二) 中有提及) ,所以可以通過使用 隊列同步器 來實現自定義的鎖組件,這也是設計隊列同步器的初衷( java.util.concurrent.locks.AbstractQueuedSynchronizer 被設計爲抽象類,類的設計者明確要求通過繼承該類來實現自定義的鎖組件)。
  2. 獨佔鎖與共享鎖。首先說獨佔鎖,可能會有多個線程同時試圖獲取獨佔鎖,但是僅僅只允許一個線程能獲取成功,在成功獲取鎖的線程釋放鎖之前,其它任何試圖獲取鎖的操作都會失敗(獲取失敗的線程可能在會鎖上等待),所以獲取獨佔鎖的操作必須保證多線程安全,因爲任何時刻僅有一個線程成功獲取獨佔鎖,且只有持有鎖的線程才能釋放鎖,所以持有鎖的線程執行釋放獨佔鎖的操作是安全的,因爲在它釋放它持有的獨佔鎖之前絕不會有第二個線程同時執行釋放鎖的操作,所以釋放獨佔鎖的操作不需要被保護,獨佔式的獲取及持有鎖保證了鎖釋放操作的多線程安全再來看共享鎖,共享 即 可以被多個線程同時擁有,但是可能會對持有鎖的線程的數目進行約束,或者對試圖獲取鎖的線程有額外條件,如果條件不滿足則獲取失敗,所以獲取共享鎖的操作必須保證多線程安全,而且釋放共享鎖的操作也必須保證多線程安全,因爲可能會有多個線程同時執行釋放共享鎖的操作。
  3. 獨佔式獲取與共享式獲取。如果一個鎖同時支持獨佔式獲取和共享式獲取,則當該鎖被獨佔式成功獲取時,其它試圖獨佔式獲取或共享式獲取該鎖的操作都將失敗,而當該鎖被共享式成功獲取時,其它試圖共享式獲取該鎖的操作可以被允許成功,而試圖獨佔式獲取該鎖的操作則會告以失敗。獨佔式獲取與其它任何方式的獲取相對立,獨佔式獲取獨佔整個鎖資源,而多個共享式獲取則能夠共存。
  4. 如何處理獲取鎖失敗的線程。當一個線程獲取鎖失敗時,該獲取操作可以立刻返回,線程繼續執行(這取決於具體實現),當然,線程也可以在鎖上等待(這取決於具體實現),此時,鎖必須管理獲取失敗的線程,鎖通常維護一個內部隊列,用來管理等待鎖的線程,這個隊列可稱爲 ‘同步隊列’,因爲隊列中的線程都是爲了獲取鎖,從而實現對某些操作進行同步的目的。
  5. 同步隊列的實現。可以使用一個先進先出(FIFO)的鏈表來管理等待鎖的線程,其中每個線程都是鏈表中的一個節點,越排在前面的節點其等待時間也越長,先進先出(FIFO)的操作策略也相對公平。
1. 下面來分析 AbstractQueuedSynchronizer 抽象類的兩個方法:‘void acquire(int arg)’ 和 ‘boolean release(int arg)’
  • void acquire(int arg) 方法,該方法以獨佔的方式獲取鎖,並且在操作返回之前忽略線程中斷。acquire 方法沒有返回值,其返回值類型爲 void。
    1. 該方法源代碼如下:
    public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

if 語句塊中,先嚐試執行獲取操作 tryAcquire(int arg) (可以發現該方法僅僅是拋出異常,因爲需要在子類中覆蓋該方法,以實現我們自定義的需求/邏輯,暫時不管),如果獲取操作失敗則 addWaiter 方法將當前線程構造成一個獨佔模式的 Node 節點,並將該節點加入到同步隊列的尾部,此時節點的狀態 waitStatus 爲 0(初始狀態),重點在於 acquireQueued(Node node, int arg) 方法,該方法使得當前線程嘗試以獨佔且不可中斷的模式獲取鎖。
2. acquireQueued(Node node, int arg) 方法,該方法返回一個 boolean 值,如果返回值爲 true,則當前線程執行自我中斷,並從 acquire(int arg) 方法返回。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);
        }
    }

首先看 for 循環,如果當前節點(即 當前線程所在的節點)的前任節點爲頭節點 head 的話,當前線程會再次嘗試執行獲取操作,如果成功獲取,則將自己設置爲頭節點,並返回自己的中斷狀態。如果獲取失敗則進入 shouldParkAfterFailedAcquire 方法判斷是否 park(通過調用 LockSupport.park() 方法使得調用線程讓出 CPU,退出線程調度) 自己,進入 shouldParkAfterFailedAcquire 方法,我們發現,使得當前線程 park 自己的唯一條件(即 使得 shouldParkAfterFailedAcquire 返回 true)是當前節點的前任節點的狀態 waitStatus 值爲 Node.SIGNAL(該狀態表示後繼節點的線程需要被 unpark),注意,這裏是一個節點(當前線程所在的節點)設置另一個節點(當前節點的前任節點)的狀態 waitStatus 值。當前節點將它的前任節點的狀態設置爲 Node.SIGNAL 後會在緊接着的下一次 for 循環中 park 自己,設置前任節點的狀態爲 Node.SIGNAL 目的是告訴前任節點在它釋放鎖時通知自己,完成設置操作,當前線程才能安心的 park 自己,因爲它知道自己會被通知(signal)的。
for 循環可能發生異常,導致執行失敗(即 failed = true)(我認爲應該是爲了處理 tryAcquire 方法可能的異常,因爲該方法需要子類覆蓋以實現自定義的獲取操作邏輯,其行爲是未知的,考慮其異常處理是必要的),如果失敗(即 failed = true),則執行取消獲取 cancelAcquire,從同步隊列中移除自己,注意,只有 try 語句執行異常的線程纔會進而執行取消操作。

  • boolean release(int arg) 方法,以獨佔的方式釋放鎖,如果成功釋放,則返回 true,否則返回 flase
    1. 該方法源代碼如下
public final boolean release(int arg) {
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
    }

當成功釋放鎖(即 tryRelease(int arg) 方法返回 true,可以發現該方法僅僅是拋出異常,因爲需要在子類中覆蓋該方法,以實現我們自定義的需求/邏輯,暫時不管)時,會執行喚醒同步隊列中頭節點的後繼節點,重點在於 unparkSuccessor 方法,注意,在該方法中並沒有更新頭節點的操作,因爲當某個線程獲取到該獨佔鎖時,會將自己設置爲頭節點。

  • 綜上,可以發現,我們討論的兩個方法 ‘void acquire(int arg)’ 和 ‘boolean release(int arg)’ 中,主要說了線程怎樣加入同步隊列和 主動 park 自己以及 被動 unpark ,主要說的是同步隊列中等待線程的管理。
  • 我要說明,到目前爲止,我仍然沒有完全領悟類的設計者設計這個框架類的根本思想,因爲涉及到多線程,代碼中的每一個操作都必須考慮併發操作的影響,當改變來自多個地方且以不同的時間點,情況就變得異常複雜,處處都是陷阱。
  • 很慶幸,當需要實現自定義鎖組件時,我們實際要做的並沒有很複雜,併發包的作者已經爲我們完成了大部分複雜且易出錯的工作(例如,等待線程的管理,同步狀態管理)
2. 自定義的獨佔鎖組件,實現 tryAcquire(int arg) 和 tryRelease(int arg) 方法
  • 可以查看源碼中方法的註釋,tryRelease 方法以獨佔的模式修改鎖的狀態來反映一個釋放操作,tryAcquire 方法則嘗試以獨佔的模式獲取鎖,這裏並沒有說要做什麼,也沒有要求怎麼做,當然這可能是廢話,這兩個方法都是空的啊!類的設計者甚至沒有說明什麼是鎖,是的,到底什麼是鎖呢?這是鎖的語義,我們可以自定義,比如:某個對象 A ,它有一個 int 類型的狀態,其初始狀態值爲 0,我們規定 A 的狀態爲 0 時表示 ‘空閒’ 狀態,對象 A 處於‘空閒’狀態時,任何線程都可以嘗試 ‘持有‘ 該對象,但是任何時刻,只允許一個線程持有,且狀態值爲 1 時,表示該對象已被某個線程持有,持有該對象的線程可以重置該對象的狀態值爲 0,表示釋放該對象,這時對象 A 又回到 ‘空閒’ 狀態,這裏的對象 A 就是鎖(或者排它鎖)。在 tryAcquire 和 tryRelease 方法中,我們要做的就是更改對象的狀態來表達/表示‘持有鎖’以及‘釋放鎖’操作。
    查看 AbstractQueuedSynchronizer 抽象類源碼,我們可以根據需要在子類中覆蓋下面的可選方法以實現自定義的邏輯,很明顯,這些方法都沒有被定義爲抽象方法,所以並沒有強制要求重寫全部方法,不同的方法對應不同的鎖獲取模式,而且方法要配對使用。可選的覆蓋方法如下:
	protected boolean tryAcquire(int arg) {	// 獨佔式獲取
        throw new UnsupportedOperationException();
    }
    protected boolean tryRelease(int arg) { // 獨佔式釋放
        throw new UnsupportedOperationException();
    }
    protected int tryAcquireShared(int arg) { // 共享式獲取
        throw new UnsupportedOperationException();
    }
    protected boolean tryReleaseShared(int arg) { // 共享式釋放
        throw new UnsupportedOperationException();
    }
    protected boolean isHeldExclusively() {
        throw new UnsupportedOperationException();
    }

該類也提供了修改同步器內部狀態的方法,我們覆蓋上述獲取及釋放鎖的方法時,通過修改同步器內部狀態值來定義鎖的獲取及釋放語義。修改同步器內部狀態值的方法如下:

	/* 獲取同步器內部狀態 int 值 */
	protected final int getState() {
        return state;
    }
	/* 設置同步器內部狀態,該方法本身非線程安全, 
	 * 必須在確保線程安全的場景中才能使用該方法 */
    protected final void setState(int newState) {
        state = newState;
    }
	/* CAS 操作設置同步器內部狀態值,線程安全 */
    protected final boolean compareAndSetState(int expect, int update) {
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }
  • 綜上,通過繼承 AbstractQueuedSynchronizer 抽象類,實現一個簡單的排它鎖,代碼如下:
/* 自定義獨佔式鎖/排它鎖 */
class MMLock{
	private final Sync sync;
	/* 記錄當前持有鎖的線程對象,用來防止未持有鎖的線程試圖釋放鎖  */
	private volatile Thread threadOwnedLock;
	/* 將獲取鎖與釋放鎖的操作委託給內部實現類 Sync */
	public MMLock() {
		sync = new Sync();
	}
	/* 獲取鎖 */
	public void lock() {
		sync.acquire(1);
	}
	/* 釋放鎖 */
	public void unlock() {
		if (threadOwnedLock == Thread.currentThread()) {			
			sync.release(1);
		}
	}
	
	private class Sync extends AbstractQueuedSynchronizer{
		private static final long serialVersionUID = 1L;

		@Override
		protected boolean tryAcquire(int arg) {
			boolean res = super.compareAndSetState(0, arg);
			if (res) { // 記錄成功持有鎖的線程對象,用來防止未持有鎖的線程釋放鎖
				threadOwnedLock = Thread.currentThread();
			}
			return res;
		}

		@Override
		protected boolean tryRelease(int arg) {
			if (super.getState() == arg) {
				threadOwnedLock = null;
				setState(0); // 釋放鎖
			}
			return true;
		}
	}
}

可以看到這個自定義排它鎖的實現非常簡單(內部實現中,狀態 0 表示鎖 ’空閒‘,鎖可以被獲取,狀態 1 表示 ‘已加鎖’,而對於鎖的使用者,只需執行獲取及釋放鎖操作即可),僅僅選擇性覆蓋/實現了 tryAcquire 和 tryRelease 這兩個方法,其中 tryAcquire 方法通過 CAS 操作(即 compareAndSwap )提供了原子操作保證,因爲同一時間可能會有多個線程試圖獲取鎖,而 tryRelease 方法卻沒有使用 CAS 操作,因爲這是排它鎖,且通過變量 threadOwnedLock 記錄當前持有鎖的線程對象,使得僅僅持有鎖的線程才能執行釋放鎖操作,所以釋放操作不會有多線程同時執行的情況,注意,變量 threadOwnedLock 使用 volatile 關鍵字修飾,確保內存可見性。
將第一篇文章Java中的多線程與鎖(一)(關於同步)中累加器程序使用的利用 ‘等待/通知’機制 實現的鎖 MyLock 更換爲上述的自定義鎖組件 MMLock 類,可見,程序可以得到正確結果。

  • 在下面程序中使用上述自定義鎖組件 MMLock,來觀察鎖對象的內部狀態,程序代碼如下(主線程(main)一直持有鎖不釋放,其它 3 個線程(線程名稱分別爲:t-0,t-1,t-2)則進入同步隊列等待,使用 Java VisualVM 工具查看程序運行情況):
/* 觀察自定義鎖組件 MMLock 對象的內部狀態 */
import java.util.concurrent.locks.AbstractQueuedSynchronizer;

public class Temp_2 {
	public static void main(String[] args) throws InterruptedException {
		MMLock lock = new MMLock();
		int thread_num = 3; // 線程數量爲 3
		Thread[] threads = new Thread[thread_num];
		for(int i = 0;i < thread_num;i++) {
			threads[i] = new Thread(new Runnable() {
				@Override
				public void run() {
					try {
						Thread.sleep(1 * 1000);
					} catch (InterruptedException e) {
						e.printStackTrace();
					}
					// 嘗試獲取鎖,因爲主線程(main-thread)先獲取鎖,且一直不釋放鎖,導致該線程進入同步隊列等待
					lock.lock(); 
				}
			}, "t-" + i); // 線程名稱分別爲:t-0,t-1,t-2,共 3 個線程
		}
		for(int i = 0;i < thread_num;i++) {
			threads[i].start();
		}
		lock.lock(); // 主線程獲取鎖
		System.out.println("main-thread acquired lock, then into sleep");
		Thread.sleep(0); // 主線程持有鎖,且一直不釋放。只管睡覺。
	}
}

/* 自定義獨佔式鎖/排它鎖 */
class MMLock{
	private final Sync sync;
	/* 記錄當前持有鎖的線程對象,用來防止未持有鎖的線程試圖釋放鎖  */
	private volatile Thread threadOwnedLock;
	/* 將獲取鎖與釋放鎖的操作委託給內部實現類 Sync */
	public MMLock() {
		sync = new Sync();
	}
	
	public void lock() {
		sync.acquire(1);
	}
	
	public void unlock() {
		if (threadOwnedLock == Thread.currentThread()) {			
			sync.release(1);
		}
	}
	
	private class Sync extends AbstractQueuedSynchronizer{
		private static final long serialVersionUID = 1L;

		@Override
		protected boolean tryAcquire(int arg) {
			boolean res = super.compareAndSetState(0, arg);
			if (res) { // 記錄成功持有鎖的線程對象,用來防止未持有鎖的線程釋放鎖
				threadOwnedLock = Thread.currentThread();
			}
			return res;
		}

		@Override
		protected boolean tryRelease(int arg) {
			if (super.getState() == arg) {
				threadOwnedLock = null;
				setState(0); // 釋放鎖
			}
			return true;
		}
	}
}

先運行程序,然後使用 JDK 自帶的工具 Java VisualVM 查看運行中的程序,選中運行中的程序,在 ‘Monitor’ 面板,點擊 ‘Heap Dump’ 按鈕執行堆快照操作,然後在生成的快照面板,在 ‘Classes’ 界面使用類名稱 ‘mmlock’ 篩選類,可以看到結果顯示有一個該類的實例,顯示如下:
查看MMLock對象雙擊上圖中的第二行,進入查看該類型實例對象的界面,如下圖:
MMLock類型實例對象可以發現,當前持有鎖的線程爲主線程(main),而其它 3 個線程(t-0,t-1,t-2)都在同步隊列中等待,通過查看同步隊列中節點的狀態 waitStatus 值,可以發現除了最後一個節點即 尾節點的狀態值爲 0(初始狀態值),其它節點包括頭節點的狀態值都爲 -1 即 Node.SIGNAL 的值,這是因爲每個節點的線程在 park 自己之前,會設置它的前任節點的狀態值爲 Node.SIGNAL,以表示當前節點需要被通知(即 signal),而尾節點並沒有後繼節點,所以它還是初始值。可見,同步隊列中,從頭節點開始,每一個節點對緊跟它的後繼節點負責,負責 signal 它的後繼節點。通過查看 ‘Threads’ 面板可以看到,這個 3 個線程都在 park 方法上 waiting,如下圖:
線程狀態- 還沒有說共享鎖的自定義實現,在下一篇文章Java中的多線程與鎖(四)(隊列同步器)中繼續討論。


參考書籍
  • 《併發編程的藝術》(該書的介紹鎖的部分,大多是講解代碼,缺乏對類設計者的設計思想的解讀,而這一點卻是最關鍵的。通過代碼只能去猜測作者的意圖,但如果能夠知曉作者的意圖/設計思想,就能很清晰代碼的行爲/目的。仍然感謝作者的努力。)

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