線程間協作——等待與通知

前言

系統的穩定運行,在單線程程序中得益於類與類之間的協作,在多線程程序中,還得益於線程與線程之間的協作。

一段邏輯代碼塊的執行可能會依賴於某個先決條件,在單線程程序中可以使用if來構建分支,在多線程程序中可以使用Java提供的等待-通知功能。

例如:生產者消費者模式中,消費者工作的先決條件是產品的數量大於0,生產者工作的先決條件是產品沒有造成堆積。

等待:線程因執行目標動作的先決條件暫時沒有滿足而被暫停的過程。
通知:線程修改了先決條件的結果,使得其他線程可以繼續工作而被喚醒的過程。

wait和notify

在Java中,Object類是所有類的父類,Object類提供了實現等待-通知功能的方法,意味着所有對象都具有等待-通知功能,方法如下:

調用wait()會釋放當前線程持有的鎖並阻塞,直到被其他線程喚醒或等待超時。

  • Object::wait()
  • Object::wait(long timeout)
  • Object::wait(long timeout, int nanos)

notify()會隨機喚醒一個線程,notifyAll()會喚醒所有線程。

  • Object::notify()
  • Object::notifyAll()

不管是wait()還是notify(),既然是方法就意味着可以被多個線程反覆執行,因此一個對象可能存在多個等待線程。
JVM除了會爲每個對象維護一個入口集(Entry Set),用於存儲申請對象監視器鎖的線程外,還會維護一個等待集(Wait Set)的隊列,用於存儲該對象上的等待線程。
Object.wait()會釋放當前線程搶佔的對象鎖,然後將當前線程暫停並加入到對象的等待集中。
Object.notify()會喚醒對象等待集中的任意一個線程,被喚醒的線程並不會立馬從等待集中剔除,而是繼續搶佔對象鎖,只有當線程成功搶到鎖後,纔會從等待集中剔除。

wait、notify、notifyAll調用的先決條件是線程已經獲得對象鎖,因此只能在臨界區中調用。

存在的問題

  • 過早喚醒
    一個類可能存在多個先決條件,有的線程滿足先決條件A時執行,有的線程滿足先決條件B時執行。使用notify()可能發生漏喚醒,這時不得不使用notifyAll(),但是notifyAll()會喚醒所有等待線程,使得不該喚醒的線程被喚醒,線程喚醒後搶佔不到鎖又會被阻塞,會導致過多的線程上下文切換,影響性能。

  • 信號丟失
    如果線程在進入wait()前沒有判斷先決條件是否成立,就會導致其他線程已經修改先決條件結果併發出通知,但是此時等待線程還沒有被暫停,也就沒法被喚醒,錯過了通知信號。將先決條件的判斷和wait()放在循環語句中可以解決。

  • 虛假喚醒
    等待線程存在沒有任何線程通知的情況下被喚醒的可能,雖然概率非常低,但是OS和Java是允許這種情況存在的,如果等待線程被虛假喚醒,但是先決條件卻沒有成立就會導致問題。將先決條件的判斷和wait()放在循環語句中可以解決。

  • 線程調度開銷
    notify()本身並不會釋放鎖,只有當同步代碼塊執行完畢纔會釋放,這會導致等待線程雖然被喚醒,但是由於搶佔不到對象鎖而被再次掛起,無故增加了操作系統線程上下文切換的開銷。

notify和notifyAll用哪個?

notify()只會喚醒一個線程,存在漏喚醒信號丟失的可能,notifyAll()效率不高,會喚醒本不應該被喚醒的線程,但是它在正確性方面有保障。

如果滿足以下條件,那麼優先使用notify(),否則使用notifyAll():

  • 每次最多隻會喚醒一個線程。
  • 等待集中的所有線程均爲同質線程(線程乾的活一模一樣)。

生產者消費者實戰

如下例子,分別啓動5個生產者和消費者,庫存最多爲1,生產完即通知消費,消費完即通知生產。
如果不把先決條件和wait()放在循環裏,將導致產品被重複消費和生產。

public class Store {
	int stock = 0;

	synchronized void consumer() {
		if (stock <= 0) {
			try {
				wait();
				// 先決條件和wait()應該放在循環中,因爲被喚醒後stock可能已經被其他線程消費掉了。
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
		stock--;
		System.out.println(Thread.currentThread().getName() + " 消費成功,庫存:" + stock);
		notifyAll();
	}

	synchronized void provider() {
		if (stock > 0) {
			try {
				wait();
				// 先決條件和wait()應該放在循環中,因爲被喚醒後stock可能已經被其他線程生產,導致重複生產。
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
		stock++;
		notifyAll();
	}

	public static void main(String[] args) {
		Store store = new Store();
		for (int i = 0; i < 5; i++) {
			new Thread(() -> {
				while (true) {
					store.consumer();
				}
			}).start();
		}
		for (int i = 0; i < 5; i++) {
			new Thread(() -> {
				while (true) {
					store.provider();
				}
			}).start();
		}
	}
}

程序運行錯誤結果:

Thread-0 消費成功,庫存:6
Thread-0 消費成功,庫存:5
Thread-4 消費成功,庫存:4
Thread-4 消費成功,庫存:3
Thread-4 消費成功,庫存:2
Thread-4 消費成功,庫存:1
Thread-4 消費成功,庫存:0
Thread-2 消費成功,庫存:-1
Thread-1 消費成功,庫存:-2
Thread-2 消費成功,庫存:-3
Thread-4 消費成功,庫存:-4

將if判斷改爲while循環即可解決該問題。

條件變量Condition

除了使用Object提供的通知-等待功能外,JUC提供了功能更加強大的條件變量類Condition。

Condition需要配合顯示鎖Lock使用,增強功能如下:

  • 支持等待超時喚醒、被通知喚醒的判斷。
  • 支持納秒級的等待超時。
  • 支持多條件等待隊列,避免了過早喚醒。

使用lock.newCondition()即可創建一個Condition實例,每個Condition實例內部都維護了一個存儲等待線程的隊列,調用不同Condition實例的signal()只會喚醒該隊列裏的線程,其他隊列中的線程不會受影響,解決了notifyAll()線程過早喚醒的問題。

Object.wait()雖然支持等待超時,但是程序無法判斷線程被喚醒是因爲超時喚醒還是被通知喚醒,Condition支持這種判斷。
awaitUntil(Date deadline)返回一個boolean值,false表示等待超時,true表示被通知喚醒。

如下示例代碼,構建了兩個Condition實例,不用的業務邏輯執行取決於不同的先決條件:

public class ConditionDemo {
	private Lock lock = new ReentrantLock();
	//先決條件A
	private Condition conditionA = lock.newCondition();
	//先決條件B
	private Condition conditionB = lock.newCondition();

	//邏輯A
	void logicA(){
		lock.lock();
		try {
			conditionA.await();
			System.out.println(Thread.currentThread().getName() + " logicA...");
		} catch (InterruptedException e) {
			e.printStackTrace();
		} finally {
			lock.unlock();
		}
	}

	//邏輯B
	void logicB(){
		lock.lock();
		try {
			conditionB.await();
			System.out.println(Thread.currentThread().getName() + " logicB...");
		} catch (InterruptedException e) {
			e.printStackTrace();
		} finally {
			lock.unlock();
		}
	}

	//只喚醒隊列A
	void signalA(){
		lock.lock();
		try {
			conditionA.signalAll();
		}finally {
			lock.unlock();
		}
	}

	//只喚醒隊列B
	void signalB(){
		lock.lock();
		try {
			conditionB.signalAll();
		}finally {
			lock.unlock();
		}
	}

	public static void main(String[] args) throws InterruptedException {
		ConditionDemo demo = new ConditionDemo();
		for (int i = 0; i < 5; i++) {
			new Thread(()->{
				demo.logicA();
			}).start();
		}

		for (int i = 0; i < 5; i++) {
			new Thread(()->{
				demo.logicB();
			}).start();
		}
		Thread.sleep(1000);
		demo.signalA();

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