多執行緒之間的通訊

zzeric發表於2019-02-11

執行緒開始執行,擁有自己的的棧空間,就如同一個指令碼一樣,按照既定的程式碼一步一步地執行,直到終止。但是,每個執行中的執行緒,如果僅僅是孤立的執行,那麼沒有一點兒價值,或者說價值很少。如果多個執行緒能夠相互配合完成工作,這將會帶來巨大的價值。

volatile 和 synchronized 關鍵字

Java 支援多個執行緒同時訪問一個物件或者物件的成員變數,由於每個執行緒可以擁有這個變數的拷貝(雖然物件以及成員變數分配的記憶體是在共享記憶體中的,但是每個執行的執行緒還是可以擁有一份拷貝,這樣做的目的是加速程式的執行,這是現代多核處理器的一個顯著特性),所以程式在執行過程中,一個執行緒看到的變數並不一定是最新的。

volatile

關鍵字volatile可以用來修飾欄位(成員變數),就是告知程式任何對該變數的訪問均需要從共享記憶體中獲取,而對它的改變必須同步重新整理回共享記憶體,它能保證所有執行緒對變數訪問的可見性。 舉個例子,定義一個表示程式是否執行的成員變數boolean on = true,那麼另一個執行緒可能對它執行關閉動作(on = false),這裡涉及多個執行緒對變數的訪問,因此需要將其定義成為volatile boolean on = true,這樣其他執行緒對它進行改變時,可以讓所有執行緒感知到變化,因為所有對on變數的訪問和修改都需要以共享記憶體為準。但是,過多的使用volatile 是不惜要的,因為它會降低程式執行的效率

synchronized

關鍵字synchronized可以修飾方法或者以同步塊的形式來進行使用,它主要確保多個執行緒在同一時刻,只能有一個執行緒處於方法或者同步塊中,它保證了執行緒對變數訪問的可見性和排他性。 在如下程式碼清單中,使用了同步塊和同步方法,通過使用javap工具檢視生成的class檔案資訊來分析synchronized關鍵字的實現細節

public class synchronized{
	public static void main(String[] args){
	// 對synchronized class 物件進行加鎖
		synchronized (Synchronized.class){
		}
		//靜態同步方法,對Synchronized Class物件進行枷鎖
		m();
	}
	public static synchronized void m(){
	};
}
複製程式碼

在Synchronized.class 統計目錄執行javap-v Synchronized.class 部分相關輸出如下所示:

public static void main(java.lang.String[]);
	//方法修飾符,表示:public staticflags: ACC_PUBLIC, ACC_STATIC
	Code:
		stack=2, locals=1, args_size=1
		0:1dc    #1 //class com/murdock/books/multithread/book/Synchronized
		2: dup
		3: monitorenter //monitorenter:監視器進入,獲取鎖
		4: monitorenter //monitorexit:監視器退出,釋放鎖
		5: invokestatic  #16 //Method m:V
		8: return
		public static synchronized void m();
		//方法修飾符,表示:public static synchronized
		flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
			Code:
				stack=0,locals=0,args_size=0
				0: return
複製程式碼

上面class資訊中,對於同步快的實現使用了monitorenter和monitorexit指令,而同步方法則是依靠方法修飾符上的ACC_SYNCHRONIZED 來完成的。無論採用哪種方式,其本質是對一個物件的監視器(monitor)進行獲取,而這個獲取是排他的,也就是同一時刻只能有一個執行緒獲取到有synchronized所保護物件的監視器。 任意一個物件都擁有自己的監視器,當這個物件由同步塊或者這個物件的同步方法呼叫時,執行方法的執行緒必須先獲取到該物件的監視器才能進入同步塊或者同步方法,而沒有獲取到監視器(執行該方法)的執行緒將會被阻塞在同步塊和同步方法的入口處,進入BLOCKED狀態。

在這裡插入圖片描述
圖1-1 物件、監視器、同步佇列和執行執行緒之間的關係

如圖1-1可以看出,任意執行緒對Object(Object 由synchronized保護)的訪問,首先要獲得Object的監視器。如果獲取失敗,執行緒進入同步佇列,執行緒狀態變為BLOCKED。當訪問Object的前驅(獲得了鎖的執行緒)釋放了鎖,則該釋放鎖操作喚醒阻塞在同步佇列中的執行緒,使其重新嘗試對監視器的獲取。

等待/通知機制

一個執行緒修改了一個物件的值,而另一個執行緒感知到了變化,然後進行相應的操作,整個過程開始於一個執行緒,而最終執行又是另一個執行緒。前者是生產者,後者是消費者,這種模式隔離了“做什麼(what)和怎麼做(How)”,在功能層面上實現瞭解耦,體系結構上具備了良好的伸縮性,但是在Java語言中如何實現類似的功能呢?

簡單的辦法是讓消費者執行緒不斷的迴圈檢查變數是否符合預期,如下面程式碼所示,在while迴圈中設定不滿足的條件,如果條件滿足則退出while迴圈,從而完成消費者的工作

	while (value != desire){
		Thread.sleep(1000);
	}
	doSomething();
複製程式碼

上面這段虛擬碼在條件不滿足時就睡眠一段時間,這樣做的目的是防止過快的“無效”嘗試,這總方式看似能夠解實現所需的功能,但是卻存在如下問題。

  1. 難以確保及時性。在睡眠時,基本不消耗處理器資源,但是如果睡得過久,就不能及時發現條件已經變化,也就是及時性難以保證。
  2. 難以減低開銷。如果降低睡眠的時間,比如休閒1毫秒,這樣消費者能更加迅速地發現條件變化,但是卻可能消耗更多的處理器資源,造成了無端的浪費。

以上兩個問題,看似矛盾難以調和,但是Java通過內建的等待/通知機制能夠很好的解決這個矛盾並實現所需的功能。

等待/通知的相關方法是任意Java物件都是具備的,因為這些方法被定義在所有物件的超類java.lang.Object 方法和描述如下表所示:

方法名稱 描述
notify() 通知一個在物件上等待的執行緒,使其從wait()方法返回,而返回的前提是該執行緒獲取到了物件的鎖
notifyAll() 通知所有等待在該物件上的執行緒
wait() 呼叫該方法的執行緒進入WAITING狀態,只有等待另外執行緒的通知或被中斷才會返回,需要注意,呼叫wait()方法後,會釋放物件的鎖
wait(long) 超時等待一段時間,這裡的引數時間是毫秒,也就是等待長達n毫秒,如果沒有通知就超時返回
wait(long,int) 對於超時時間更細粒度的控制,可以達到納秒

等待/通知機制,是指一個執行緒A呼叫了物件O的wait()方法進入等待狀態,而另一個執行緒B呼叫了物件O的notify()或者notifyAll()方法,執行緒A收到通知後從物件O的wait()方法返回,進而執行後續操作。上述兩個執行緒通過物件O來完成互動,而物件上的wait()和notify()/notifyAll()的關係就如同開關訊號一樣,用來完成等待方和通知方之間的互動工作

如下程式碼,建立了兩個執行緒 WaitTHreadNotifyThread,前者檢查flag值是否為false。如果符合要求,進行後續操作,否則在lock上等待,後者在睡眠了一段時間後對lock進行通知,示例如下:

public class WaitNotify{
	static boolean flag = true;
	static Object lock = new Object();;
	
	public static void main(String[] args) throws Exception{
		Thread waitThread = new Thread(new Wait(),"WaitThread");
		waitThread.start();
		TimeUnit.SECONDS.sleep(1);
		Thread notifyThread = new Thread(new Notify(),"NotifyThread");
		notifyThread.start();
	}
	
	static class Wait implements Runnable{
		public void run{
			//加鎖,擁有lock的Monitor
			synchronized(lock){
				//當條件不滿足時,繼續wait,同時釋放了lock的鎖
				while(flag){
					System.out.println(Thread.currentThread()+"flag is true,wait@"+new SimpleDateFormat("HH:mm:ss").format(new Date()));
					lock.wait();
				}
			}
			//條件滿足時,完成工作
			System.out.pringln(Thread.currentThread()+"flag is false. running@"+new SimpleDateFormat("HH:mm:ss").format(new Date()));
		}
	}
	static class Notify implements Runnable{
		 public void run(){
			//加鎖,擁有lock的Monitor
			synchronized(lock){
				//獲取lock的鎖,然後進行通知,通知時不會釋放lock的鎖
				//知道當前執行緒釋放了lock後,WaitThread才能從wait方法中返回
				System.out.pringln(Thread.currentThread()+" hold lock.notify@"+new SimpleDateFormat("HH:mm:ss").format(new Date()));
				lock.notifyAll();
				SleepUtils.second(5);
			}
			synchronized(lock){
				System.out.println(Thread.currentThread()+"hold lock again. sleep@"+new SimpleDateFormat("HH:mm:ss").format(new Data()));
			}
		 }
	}
}
複製程式碼
輸出如下:(輸出內容可能不容,主要去唄在時間上)
1.Thread[WaitThread,5,main] flag is true.wait @ 17:12:03
2.Thread[NotifyThread,5,main] hold lock.notify @ 17:12:04
3.Thread[NotifyThread,5,main] hold lock again. sleep @ 17:12:05
4.Thread[WaitThread,5,main] flag is false. running @ 17:12:06
複製程式碼

上述第三行和第四行輸出的順序可能會互換,而上述例子主要說明了呼叫wait()、norify()、以及notifyAll() 時需要注意的細節,如下:

  1. 使用wait()、notify()、和notifyAll()時需要先對呼叫物件加鎖
  2. 呼叫wait()方法後,執行緒狀態有RUNNING變為WAITING,並將當前執行緒放置到物件的等待佇列
  3. notify() 或 notifyAll() 方法呼叫後,等待執行緒依舊不會從wait()返回,需要呼叫notify()或者notifyAll()的執行緒釋放鎖之後,等待執行緒才有機會從wait()返回
  4. notify()方法將等待佇列中的一個等待執行緒從等待佇列中移動到同步佇列中,而notifyAll()方法則是將等待佇列中所有的執行緒全部移到同步佇列,被移動的執行緒狀態由WAITING變為BLOCKED.
  5. 從wait()方法返回的前提是獲得了呼叫物件的鎖

從上述細節中可以看到,等待/通知機制依託於同步機制,其目的就是確保等待執行緒從wait()方法返回時能夠感知到執行緒對變數做出的修改

在這裡插入圖片描述
如上圖所示,WaitThread首先獲取了物件的鎖,然後呼叫物件的wait()方法,從而放棄了鎖並進入了物件的等待佇列WaitQueue中,進入等待狀態。由於WaitThread釋放了物件的鎖,NotifyThread隨後獲取了物件的鎖,並呼叫物件的Notify()方法,將WaitThread從WaitQueue移到SynchronizedQueue中,此時WaitThread的狀態變為阻塞狀態。NotifyThread釋放了鎖之後,WaitThread再次獲取到鎖並從wait()返回繼續執行

注意:只要同步佇列內等待的執行緒未獲取到物件鎖,該(等待)執行緒始終未從wait()方法返回

生產經典模式

以上示例中可以提煉出等待/通知的經典正規化,該正規化分為兩部分,分別針對等待方(消費者)和通知方(生產者) 等待方遵循如下原則。

  1. 獲取物件的鎖
  2. 如果條件不滿足,那麼呼叫物件的wait()方法,被通知後仍要檢查條件
  3. 條件滿足則執行對應的邏輯。 對應的虛擬碼如下: synchronized(物件){ while(條件不滿足){ 物件.wait(); } 對應的處理邏輯 }

通知方遵循如下原則

  1. 獲得物件的鎖
  2. 改變條件
  3. 通知所有等待在物件上的執行緒 對應的虛擬碼如下: synchronized(物件){ 改變條件 物件.notifyAll(); }

相關文章