1. 程式人生 > >Java之路:執行緒間的通訊

Java之路:執行緒間的通訊

同屬於一個程序的多個執行緒,是共享地址空間的,它們可以一起協作來完成指定的任務。因此,執行緒之間必須相互通訊,才能完成協作。

一、引入問題

下面通過一個應用案例來講解執行緒間的通訊。把一個數據儲存空間劃分為兩個部分:一部分用於儲存使用者的姓名,另一部分用於儲存使用者的性別。

這個案例包含兩個執行緒:一個執行緒向資料儲存空間新增資料(生產者),另一個執行緒從資料儲存空間中取出資料(消費者)。這個程式有兩種意外需要考慮:

第一種意外,假設生產者執行緒剛向資料儲存空間中添加了一個人的姓名,還沒有加入這個人的性別,CPU就切換到了消費者執行緒,消費者執行緒則把這個人的姓名和上一個人的性別聯絡到一起。這個過程可用下圖表示:
在這裡插入圖片描述

第二種意外,生產者放入了若干次資料,消費者才開始取資料,或者是,消費者取完一個數據後,還沒等到生產者放入新的資料,又重新取出已取過的資料。

在作業系統裡,上面的案例屬於經典的同步問題——生產者消費者問題,下面我們通過執行緒間的通訊來解決上面提到的意外:

二、解決問題

下面先來構思這個程式,程式中的生產者執行緒和消費者執行緒執行的是不同的程式程式碼,因此這裡需要編寫兩個包含有run方法的類來完成這兩個執行緒,一個是生產者類Producer,另一個是消費者類Consumer。

01  class Producer implements Runnable
02  {
03    public
void run() 04 { 05 while(true) 06 { 07 //編寫往資料儲存空間中放入資料的程式碼 08 } 09 } 10 }

下面是消費者執行緒的程式碼:

01  class Consumer implements Runnable
02  {
03    public void run()
04    {
05      while(true)
06      {
07        //編寫從資料儲存空間中讀取資料的程式碼
08      }
09    }
10  }

當程式寫到這裡,還需要定義一個新的資料結構Person,用來作為資料儲存空間。

在這個資料結構中,類Person只有資料,而沒有對資料的操作,非常類似於C語言的結構體。

01  class Person
02  {
03    String name;
04    String sex;
05  }

Producer和Consumer執行緒中的run()方法都需要操作類Person的同一物件例項。

接下來,對Producer和Consumer這兩個類做如下修改,順便寫出程式的主呼叫類ThreadCommunation:

package com.xy.thread;

class Person {
	String name = "小四";
	String sex = "女";
}
class Producer implements Runnable {
	Person p = null;
	public Producer(Person p) {
		this.p = p;
	}
	public void run() {
		for(int i = 0; i < 10; i++) {
			if(i%2 == 0) {
				p.name = "小三";
				try {
					Thread.sleep(1000);
				}
				catch (InterruptedException e) {
					e.printStackTrace();
				}
				p.sex = "男";
			}
			else {
				p.name = "小四";
				try {
					Thread.sleep(1000);
				}
				catch (InterruptedException e) {
					e.printStackTrace();
				}
				p.sex = "女";
			}
		}
	}
}

class Consumer implements Runnable {
	Person q = null;
	public Consumer(Person q) {
		this.q = q;
	}
	public void run() {
		for(int i = 0; i < 10; i++) {
			System.out.println(q.name + "---->" + q.sex);
			try {
				Thread.sleep(1000);
			}
			catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
	}
}
public class ThreadCommunation {
	public static void main(String[] args) {
		Person pp = new Person();
		new Thread(new Producer(pp)).start();
		new Thread(new Consumer(pp)).start();
	}
}

【結果】
在這裡插入圖片描述
從輸出結果可以看到,原本“小四是女”、“小三是男”,現在卻列印了“小四是男”、“小三是女”的奇怪現象

從程式中可以看到,Producer類和Consumer類都是操縱了一個Person類,這就有可能Producer類還未操縱完P類,Consumer類就已經將P類中的內容取走了,這就是資源不同步的原因

程式為了模擬生產者和消費者的生產(消費)耗時,分別使用了sleep(1000)方法做了模擬。為了避免這類“生產者沒有生產完,消費者就來消費”或“消費者沒有消費完,生產者又來生產,覆蓋了還沒有來得生產及消費的資料”情況,我們在Person類中新增兩個同步方法,put() 和get(),這兩個方法都使用了synchronized關鍵詞,從而保證了生產或消費操作過程的原子性——即正在生產過程中,不能消費,或消費過程中,不能生產。

具體程式碼如下範例所示:(僅改變了Person、Producer、Consumer)

class Person {
	String name = "小四";
	String sex = "女";
	public synchronized void set(String name, String sex) {
		this.name = name;
		this.sex = sex;
	}
	public synchronized void get() {
		System.out.println(this.name + "---->" + this.sex);
	}
	
}
class Producer implements Runnable {
	Person p = null;
	public Producer(Person p) {
		this.p = p;
	}
	public void run() {
		for(int i = 0; i < 10; i++) {
			if(i%2 == 0) {
				p.set("小三", "男");
			}
			else {
				p.set("小四", "女");
			}
		}
	}
}

class Consumer implements Runnable {
	Person q = null;
	public Consumer(Person q) {
		this.q = q;
	}
	public void run() {
		for(int i = 0; i < 10; i++) {
			q.get();
		}
	}
}

【結果】
在這裡插入圖片描述
可以看到程式的輸出結果是正確的,能夠保證“李四是女的”。但是另外一個問題又產生了,從程式的執行結果來看,Consumer執行緒對Producer執行緒放入的一次資料連續地讀取了多次,多次輸出:“李四 ---->女”,這並不符合實際的要求。

合理的結果應該是,Producer放一次資料,Consumer就取一次;反之,Producer也必須等到Consumer取完後才能放入新的資料,而這一問題的解決就需要使用執行緒間的通訊。

三、執行緒間的通訊

Java是通過Object類的wait()、notify ()、notifyAll ()這幾個方法來實現執行緒間的通訊的,又因為所有的類都是從Object繼承的,因此任何類都可以直接使用這些方法。

下面是這3個方法的簡要說明:

wait():通知當前執行緒進入睡眠狀態,直到其他執行緒進入並呼叫notify()或notifyAll()為止.在當前執行緒睡眠之前,該執行緒會釋放所佔有的“鎖標誌”,即其佔有的所有synchronized標識的程式碼塊可被其他執行緒使用。

notify():喚醒在該同步程式碼塊中第1個呼叫wait()的執行緒。

這類似排隊買票,一個人買完之後,後面的人才可以繼續買。

notifyAll():喚醒該同步程式碼塊中所有呼叫wait的所有執行緒,具有最高優先順序的執行緒首先被喚醒並執行。

如果想讓上面的程式符合預先的設計需求,就必須在類Person中定義一個新的成員變數bFull來表示資料儲存空間的狀態。當Consumer執行緒取走資料後,bFull值為false,當Producer執行緒放入資料後,bFull值為true。只有bFull為true時,Consumer執行緒才能取走資料,否則就必須等待Producer執行緒放入新的資料後的通知;反之,只有bFull為false,Producer執行緒才能放入新的資料,否則就必須等待Consumer執行緒取走資料後的通知。修改後的P類的程式程式碼如下:

package com.xy.thread;

class Person {
	String name = "小四";
	String sex = "女";
	private boolean bFull = false;
	public synchronized void set(String name, String sex) {
		if(bFull) {
			try {
				wait();	// 後來的執行緒要等待
			}
			catch(InterruptedException e) {
				e.printStackTrace();
			}
		}
		this.name = name;
		this.sex = sex;
		bFull = true;
		notify();	// 喚醒最先到達的執行緒
	}
	public synchronized void get() {
		if(!bFull) {
			try {
				wait();
			}
			catch(InterruptedException e) {
				e.printStackTrace();
			}
		}
		System.out.println(this.name + "---->" + this.sex);
		bFull = false;
		notify();
	}
	
}
class Producer implements Runnable {
	Person p = null;
	public Producer(Person p) {
		this.p = p;
	}
	public void run() {
		for(int i = 0; i < 10; i++) {
			if(i%2 == 0) {
				p.set("小三", "男");
			}
			else {
				p.set("小四", "女");
			}
		}
	}
}

class Consumer implements Runnable {
	Person q = null;
	public Consumer(Person q) {
		this.q = q;
	}
	public void run() {
		for(int i = 0; i < 10; i++) {
			q.get();
		}
	}
}
public class ThreadCommunation {
	public static void main(String[] args) {
		Person pp = new Person();
		new Thread(new Producer(pp)).start();
		new Thread(new Consumer(pp)).start();
	}
}

【結果】
在這裡插入圖片描述

需要注意的是,wait()、notify()、notifyAll()這3個方法只能在synchronized方法中呼叫,即無論執行緒呼叫的是wait()還是notify()方法,該執行緒必須先得到該物件的所有權。這樣,notify()就只能喚醒同一物件監視器中呼叫wait()的執行緒。而使用多個物件監視器,就可以分別有多個wait()、notify()的情況,同組裡的wait()只能被同組的notify()喚醒。