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,用來作為資料儲存空間。
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()喚醒。