Java編程思想 | 第14章 多線程

利用對象,可將一個程序分割成互相獨立的區域。我們通常也需要將一個程序轉換成多個獨立運行的子任務。

像這樣的每個子任務都叫作一個"線程"(Thread)。編寫程序時,可將每個線程都想象成獨立運行,而且都有自己專用的 CPU。一些基礎機制實際會爲我們自動分割CPU的時間。

理解一些定義對以後的學習很有幫助。"進程"是指一種"自包容"的運行程序,有自己的地址空間。"多任務"操作系統能同時運行多個進程(程序)——但實際是由於 CPU分時機制的作用,使每個進程都能循環獲得自己的 CPU 時間片。但由於輪換速度非常快,使得所有後才能像好像是在"同時"運行一樣。"線程"是進程內部單一的一個順序控制流。因此,一個進程可能容納了多個同時執行的線程。

14.1.1 從線程繼承

爲創建一個線程,最簡單的方法就是從 Thread 類繼承。這個類包含了創建和運行線程所需的一切東西。 Thread 最重要的方法是run()。但爲了使用 run(),必須對其進行過載或者覆蓋,使其能充分按自己的吩咐行事。因此,run()屬於那些會與程序中的其他線程“併發”或“同時”執行的代碼。

下面這個例子可創建任意數量的線程,並通過爲每個線程分配一個獨一無二的編號(由一個靜態變量產生),從而對不同的線程進行跟蹤。Thread 的run()方法在這裏得到了覆蓋,每通過一次循環,計數就減1——計數爲 0 時則完成循環(此時一旦返回 run(),線程就中止運行)。

//: SimpleThread.java
// Very simple Threading example
public class SimpleThread extends Thread {
 private int countDown = 5;
 private int threadNumber;
 private static int threadCount = 0;
 public SimpleThread() {
 threadNumber = ++threadCount;
 System.out.println("Making " + threadNumber);
 }
 public void run() {
 while(true) {
 System.out.println("Thread " + 
 threadNumber + "(" + countDown + ")");
 if(--countDown == 0) return;
 }
 }
 public static void main(String[] args) {
 for(int i = 0; i < 5; i++)
 new SimpleThread().start();
 System.out.println("All Threads Started");
 }
}

run()方法幾乎肯定含有某種形式的循環——它們會一直持續到線程不再需要爲止。因此,我們必須規定特定的條件,以便中斷並退出這個循環(或者在上述的例子中,簡單地從 run()返回即可)。run()通常採用一種 無限循環的形式。也就是說,通過阻止外部發出對線程的 stop()或者destroy()調用,它會永遠運行下去(直到程序完成)。

在main()中,可看到創建並運行了大量線程。Thread 包含了一個特殊的方法,叫作 start(),它的作用是對線程進行特殊的初始化,然後調用 run()。所以整個步驟包括:調用構建器來構建對象,然後用 start()配置線程,再調用run()。如果不調用 start()——如果適當的話,可在構建器那樣做——線程便永遠不會啓動。

可注意到這個例子中到處都調用了 sleep(),然而輸出結果指出每個線程都獲得了屬於自己的那一部分CPU執行時間。從中可以看出,儘管sleep()依賴一個線程的存在來執行,但卻與允許或禁止線程無關。它只不過是另一個不同的方法而已。

亦可看出線程並不是按它們創建時的順序運行的。事實上,CPU 處理一個現有線程集的順序是不確定的——除非我們親自介入,並用Thread 的setPriority()方法調整它們的優先級。

main()創建 Thread 對象時,它並未捕獲任何一個對象的句柄。普通對象對於垃圾收集來說是一種“公平競賽”,但線程卻並非如此。每個線程都會“註冊”自己,所以某處實際存在着對它的一個引用。這樣一來,垃圾收集器便只好對它“瞠目以對”了。

14.2 共享有限的資源

可將單線程程序想象成一種獨立的實體,它能遍歷我們的問題空間,而且一次只能做一件事情。由於只有一個實體。所以永遠不必單線會有兩個實體試圖使用相同的資源,就像兩個人同時都想停到一個車位,同時都想通過一扇門......

進入多線程環境後,它們則再也不是孤立的。可能會有兩個甚至更多的線程試圖同時訪問同一個有限的資源。必須對這種潛在資源衝突進行預防,否則就可能發生兩個線程同時訪問一個銀行賬號,打印到同一臺計算機,以及對同一個值進行調整等等。

14.2.1 資源訪問的錯誤方法

現在考慮換成另一種方式來使用本章頻繁見到的計數器。在下面的例子中,每個線程都包含了兩個計數器,它們在 run()裏增值以及顯示。除此以外,我們使用了 Watcher 類的另一個線程。它的作用是監視計數器,檢查它們是否保持相等。這表面是一項無意義的行動,因爲如果查看代碼,就會發現計數器肯定是相同的。 但實際情況卻不一定如此。下面是程序的第一個版本:

//: Sharing1.java
// Problems with resource sharing while threading
import java.awt.*;
import java.awt.event.*;
import java.applet.*;
class TwoCounter extends Thread {
 private boolean started = false;
 private TextField 
 t1 = new TextField(5),
 t2 = new TextField(5);
 private Label l = 
 new Label("count1 == count2");
 private int count1 = 0, count2 = 0;
    
 // Add the display components as a panel
  // to the given container:
 public TwoCounter(Container c) {
 Panel p = new Panel();
 p.add(t1);
 p.add(t2);
 p.add(l);
 c.add(p);
 }
 public void start() {
 if(!started) {
 started = true;
 super.start();
 }
 }
 public void run() {
 while (true) {
 t1.setText(Integer.toString(count1++));
 t2.setText(Integer.toString(count2++));
 try {
 sleep(500);
 } catch (InterruptedException e){}
 }
 }
 public void synchTest() {
 Sharing1.incrementAccess();
 if(count1 != count2)
 l.setText("Unsynched");
 }
}
class Watcher extends Thread {
 private Sharing1 p;
 public Watcher(Sharing1 p) { 
 this.p = p;
 start();
 }
 public void run() {
 while(true) {
 for(int i = 0; i < p.s.length; i++)
 p.s[i].synchTest();
 try {
 sleep(500);
 } catch (InterruptedException e){}
 }
 }
}
public class Sharing1 extends Applet {
 TwoCounter[] s;
 private static int accessCount = 0;
 private static TextField aCount = 
 new TextField("0", 10);  
    
 public static void incrementAccess() {
 accessCount++;
 aCount.setText(Integer.toString(accessCount));
 }
 private Button 
 start = new Button("Start"),
 observer = new Button("Observe");
 private boolean isApplet = true;
 private int numCounters = 0;
 private int numObservers = 0;
 public void init() {
 if(isApplet) {
 numCounters = 
 Integer.parseInt(getParameter("size"));
 numObservers = 
 Integer.parseInt(
 getParameter("observers"));
 }
 s = new TwoCounter[numCounters];
 for(int i = 0; i < s.length; i++)
 s[i] = new TwoCounter(this);
 Panel p = new Panel();
 start.addActionListener(new StartL());
 p.add(start);
 observer.addActionListener(new ObserverL());
 p.add(observer);
 p.add(new Label("Access Count"));
 p.add(aCount);
 add(p);
 }
 class StartL implements ActionListener {
 public void actionPerformed(ActionEvent e) {
 for(int i = 0; i < s.length; i++)
 s[i].start();
 }
 }
 class ObserverL implements ActionListener {
 public void actionPerformed(ActionEvent e) {
 for(int i = 0; i < numObservers; i++)
 new Watcher(Sharing1.this);
 }
 }
 public static void main(String[] args) {
 Sharing1 applet = new Sharing1();
     
 // This isn't an applet, so set the flag and
 // produce the parameter values from args:
 applet.isApplet = false;
 applet.numCounters = 
 (args.length == 0 ? 5 :
 Integer.parseInt(args[0]));
 applet.numObservers =
 (args.length < 2 ? 5 : 
 Integer.parseInt(args[1]));
 Frame aFrame = new Frame("Sharing1");
 aFrame.addWindowListener(
 new WindowAdapter() {
 public void windowClosing(WindowEvent e){
 System.exit(0);
 }
 });
 aFrame.add(applet, BorderLayout.CENTER);
 aFrame.setSize(350, applet.numCounters *100);
 applet.init();
 applet.start();
 aFrame.setVisible(true);
 }
}  

和往常一樣,每個計數器都包含了自己的顯示組件:兩個文本字段以及一個標籤。根據它們的初始值,可知道計數是相同的。這些組件在 TwoCounter 構建器加入 Container。由於這個線程是通過用戶的一個“按下按鈕”操作啓動的,所以start()可能被多次調用。但對一個線程來說,對Thread.start()的多次調用時非法的(會產生違例)。在started標記和過載的start()方法中,大家可看到針對這一情況採取的防範措施。在run()中,count1 和count2 的增值與顯示方式表面上似乎能保持它們完全一致。隨後會調用 sleep();若沒有這個調用,程序便會出錯,因爲那會造成 CPU 難於交換任務。

synchTest()方法採取的似乎是沒有意義的行動,它檢查 count1 是否等於count2;如果不等,就把標籤設爲“Unsynched”(不同步)。但是首先,它調用的是類Sharing1 的一個靜態成員,以便增值和顯示一個訪問計數器,指出這種檢查已成功進行了多少次(這樣做的理由會在本例的其他版本中變得非常明顯)。

Watcher 類是一個線程,它的作用是爲處於活動狀態的所有 TwoCounter 對象都調用 synchTest()。其間,它會對Sharing1 對象中容納的數組進行遍歷。可將 Watcher 想象成它掠過 TwoCounter 對象的肩膀不斷地“偷 看”。

Sharing1 包含了 TwoCounter 對象的一個數組,它通過 init()進行初始化,並在我們按下“start”按鈕後作爲線程啓動。以後若按下“Observe”(觀察)按鈕,就會創建一個或者多個觀察器,並對毫不設防的TwoCounter 進行調查。

下面纔是最讓人“不可思議”的。在TwoCounter.run()中,無限循環只是不斷地重複相鄰的行:

t1.setText(Integer.toString(count1++)); 

t2.setText(Integer.toString(count2++)); 

(和“睡眠”一樣,不過在這裏並不重要)。但在程序運行的時候,你會發現count1 和 count2 被“觀察”(用Watcher 觀察)的次數是不相等的!這是由線程的本質造成的——它們可在任何時候掛起(暫停)。所以在上述兩行的執行時刻之間,有時會出現執行暫停現象。同時,Watcher 線程也正好跟隨着進來,並正好在這個時候進行比較,造成計數器出現不相等的情況。

這個例子揭示了使用線程時一個非常基本的問題。我們根本無從知道一個線程什麼時候運行。想象自己坐在一張桌子前面,桌上放有一把叉子,準備叉起自己的最後一塊食物。當叉子要碰到食物時,食物卻突然消失了(因爲這個線程已被掛起,同時另一個線程進來"偷"走了食物)

有的時候,我們並不介意一個資源在嘗試使用它的時候是否正被訪問(食物在另一些盤子裏)。但爲了讓多線程機制能夠正常運轉,需要採取一些措施來防止兩個線程訪問相同的資源——至少在關鍵的時期。爲了防止出現這樣的衝突,只需在線程使用一個資源時爲其加鎖即可。訪問資源的第一個線程會爲其加上鎖,其他線程便不能再使用那個資源,除非被解鎖。如果車子的前座是有限的資源,高喊"這是我的!"的孩子會主張把它鎖起來。

14.2.2 Java 如何共享資源

對一種特殊的資源——對象中的內存——Java 提供了內建的機制來防止它們的衝突。由於我們通常將數據元素設爲從屬於 private(私有)類,然後只通過方法訪問那些內存,所以只需將一個特定的方法設爲synchronized(同步的),便可有效地防止衝突。在任何時刻,只可有一個線程調用特定對象的一個synchronized 方法(儘管那個線程可以調用多個對象的同步方法)。

每個對象都包含了一把鎖(也叫作"監視器"),它會自動成爲對象的一部分(不必爲此寫任何特殊代碼)。調用任何synchronized 方法時,對象就會被鎖定,不可再調用那個對象的其他任何synchronized 方法,除非第一個方法完成了自己的工作,並解除鎖定。在上面的例子中,如果爲一個對象調用f(),便不能再爲同樣的對象調用g(),除非 f()完成並解除鎖定。因此,一個特定對象的所有 synchronized 方法都共享着一把鎖,而且這把鎖能防止多個方法對通用內存同時進行寫操作(比如同時有多個線程)。

每個類也有自己的一把鎖(作爲類的Class 對象的一部分),所以synchronized static 方法可在一個類的範圍內被相互間鎖定起來,防止與 static 數據的接觸。

注意如果想保護其他某些資源不被多個線程同時訪問,可以強制通過 synchronized 方訪問那些資源。

計數器的同步

裝備了這個新關鍵字後,我們能夠採取的方案就更靈活了:可以只爲 TwoCounter 中的方法簡單地使用synchronized 關鍵字。下面這個例子是對前例的改版,其中加入了新的關鍵字:

//: Sharing2.java
// Using the synchronized keyword to prevent
// multiple access to a particular resource.
import java.awt.*;
import java.awt.event.*;
import java.applet.*;
class TwoCounter2 extends Thread {
 private boolean started = false;
 private TextField 
 t1 = new TextField(5),
 t2 = new TextField(5);
 private Label l = 
 new Label("count1 == count2");
 private int count1 = 0, count2 = 0;
 public TwoCounter2(Container c) {
 Panel p = new Panel();
 p.add(t1);
 p.add(t2);
 p.add(l);
 c.add(p);
 } 
 public void start() {
 if(!started) {
 started = true;
 super.start();
 }
  }
 public synchronized void run() {
 while (true) {
 t1.setText(Integer.toString(count1++));
 t2.setText(Integer.toString(count2++));
 try {
 sleep(500);
 } catch (InterruptedException e){}
 }
 }
 public synchronized void synchTest() {
 Sharing2.incrementAccess();
 if(count1 != count2)
 l.setText("Unsynched");
 }
}

class Watcher2 extends Thread {
 private Sharing2 p;
 public Watcher2(Sharing2 p) { 
 this.p = p;
 start();
 }
 public void run() {
 while(true) {
 for(int i = 0; i < p.s.length; i++)
 p.s[i].synchTest();
 try {
 sleep(500);
 } catch (InterruptedException e){}
 }
 }
}
public class Sharing2 extends Applet {
 TwoCounter2[] s;
 private static int accessCount = 0;
 private static TextField aCount = 
 new TextField("0", 10);
 public static void incrementAccess() {
 accessCount++;
 aCount.setText(Integer.toString(accessCount));
 }
 private Button 
 start = new Button("Start"),
 observer = new Button("Observe");
 private boolean isApplet = true;
 private int numCounters = 0;
 private int numObservers = 0;
 public void init() {
 if(isApplet) {
 numCounters =
 Integer.parseInt(getParameter("size"));
 numObservers = 
 Integer.parseInt(
 getParameter("observers"));
 }
 s = new TwoCounter2[numCounters];
 for(int i = 0; i < s.length; i++)
 s[i] = new TwoCounter2(this);
 Panel p = new Panel();
 start.addActionListener(new StartL());
 p.add(start);
 observer.addActionListener(new ObserverL());
 p.add(observer);
 p.add(new Label("Access Count"));
 p.add(aCount);
 add(p);
 }
 class StartL implements ActionListener {
 public void actionPerformed(ActionEvent e) {
 for(int i = 0; i < s.length; i++)
 s[i].start();
 }
 }
 class ObserverL implements ActionListener {
 public void actionPerformed(ActionEvent e) {
 for(int i = 0; i < numObservers; i++)
 new Watcher2(Sharing2.this);
 }
 }
 public static void main(String[] args) {
 Sharing2 applet = new Sharing2();
 // This isn't an applet, so set the flag and
 // produce the parameter values from args:
 applet.isApplet = false;
 applet.numCounters = 
 (args.length == 0 ? 5 :
 Integer.parseInt(args[0]));
 applet.numObservers =
 (args.length < 2 ? 5 :
 Integer.parseInt(args[1]));
 Frame aFrame = new Frame("Sharing2");
 aFrame.addWindowListener(
 new WindowAdapter() {
 public void windowClosing(WindowEvent e){
 System.exit(0);
 }
 });
 aFrame.add(applet, BorderLayout.CENTER);
 aFrame.setSize(350, applet.numCounters *100);
 applet.init();
 applet.start();
 aFrame.setVisible(true);
  }
}    

我們注意到無論run()還是synchTest()都是“同步的”。如果只同步其中的一個方法,那麼另一個就可以自由忽視對象的鎖定,並可無礙地調用。所以必須記住一個重要的規則:對於訪問某個關鍵共享資源的所有方法,都必須把它們設爲 synchronized,否則就不能正常地工作。

現在又遇到了一個新問題。Watcher2 永遠都不能看到正在進行的事情,因爲整個run()方法已設爲“同步”。而且由於肯定要爲每個對象運行run(),所以鎖永遠不能打開,而synchTest()永遠不會得到調用。之所以能看到這一結果,是因爲accessCount 根本沒有變化。

爲解決這個問題,我們能採取的一個辦法是隻將run()中的一部分代碼隔離出來。想用這個辦法隔離出來的那部分代碼叫作“關鍵區域”,而且要用不同的方式來使用synchronized 關鍵字,以設置一個關鍵區域。Java 通過“同步塊”提供對關鍵區域的支持;這一次,我們用 synchronized 關鍵字指出對象的鎖用於對其中封閉的代碼進行同步。如下所示:

synchronized(syncObject) {
 // This code can be accessed by only
 // one thread at a time, assuming all
 // threads respect syncObject's lock
}

在能進入同步塊之前,必須在 synchObject 上取得鎖。如果已有其他線程取得了這把鎖,塊便不能進入,必須等候那把鎖被釋放。

可從整個run()中刪除 synchronized 關鍵字,換成用一個同步塊包圍兩個關鍵行,從而完成對 Sharing2 例子的修改。但什麼對象應作爲鎖來使用呢?那個對象已由 synchTest()標記出來了——也就是當前對象 (this)!所以修改過的 run()方法象下面這個樣子:

public void run() {
 while (true) {
 synchronized(this) {
 t1.setText(Integer.toString(count1++));
 t2.setText(Integer.toString(count2++));
 }
 try {
 sleep(500);
 } catch (InterruptedException e){}
 }
 }

這是必須對 Sharing2.java 作出的唯一修改,我們會看到儘管兩個計數器永遠不會脫離同步(取決於允許Watcher 什麼時候檢查它們),但在run()執行期間,仍然向 Watcher 提供了足夠的訪問權限。

當然,所有同步都取決於程序員是否勤奮:要訪問共享資源的每一部分代碼都必須封裝到一個適當的同步塊裏。

同步的效率

由於要爲同樣的數據編寫兩個方法,所以無論如何都不會給人留下效率很高的印象。看來似乎更好的一種做法是將所有方法都設爲自動同步,並完全消除 synchronized 關鍵字(當然,含有synchronized run()的例子顯示出這樣做是很不通的)。但它也揭示出獲取一把鎖並非一種“廉價”方案——爲一次方法調用付出的 代價(進入和退出方法,不執行方法主體)至少要累加到四倍,而且根據我們的具體現方案,這一代價還有可能變得更高。所以假如已知一個方法不會造成衝突,最明智的做法便是撤消其中的 synchronized 關鍵字。

14.3 堵塞

一個線程可以有四種狀態:

  • (1)新(New):線程對象已經創建,但尚未啓動,所以不可運行
  • (2)可運行(Runnable):意味着一旦時間分片機制有空閒的CPU週期提供一個線程,便可立即開始運行。因此,線程可能在、也可能不在運行當中,但一旦條件許可。沒有什麼能阻止它的運行——它既沒有"死"掉,也未被"堵塞"。
  • (3)死(Dead):從自己的run()方法中返回後,一個線程便已"死"掉。亦可調用 stop()令其死掉,但會產生一個違例——屬於 Error的一個子類(也就是說,我們通常不捕獲它)。記住一個違例的"擲"出應當是一個特殊事件,而不是正常程序運行的一部分。所以不建議你使用 stop()。另外還有一個destroy()方法(它永遠不會實現),應該儘可能地避免調用它,因爲它非常武斷,根本不會解除對象的鎖定。
  • (4)堵塞(Blocked):線程可以運行,但某種東西阻礙了它。若線程處於堵塞狀態,調度機制可以簡單地跳過它,不給它分配任何CPU時間。除非線程再次進入"可運行"狀態,否則不會採取任何操作。

14.3.1 爲何會堵塞

線程被堵塞可能是由下述五方面原因造成的:

  • (1)調用sleep(毫秒數),使線程進入"睡眠"狀態。在規定的時間內。這個線程是不會運行的。
  • (2)用suspend()暫停了線程的執行。除非線程收到 resume()消息,否則不會返回"可運行"狀態。
  • (3)用wait()暫停了線程的執行。除非線程收到nofity()或者notifyAll()消息,否則不會變成"可運行"。
  • (4)線程正在等候一些IO(輸入輸出)操作完成。
  • (5)線程試圖調用另一個對象的"同步"方法,但那個線程處於鎖定狀態,暫時無法使用。

亦可調用 yield()(Thread類的一個方法)自動放棄 CPU,以便其他線程能夠運行。然而,假如調度機制覺得我們的線程已擁有足夠的時間,並跳轉到另一個線程,就會發生同樣的事情。也就是說,沒什麼能防止調度機制重新啓動我們的線程。線程被堵塞後,便有一些原因造成它不能繼續運行。

下面這個例子展示了進入堵塞狀態的全部五種途徑。它們全都存在於名爲 Blocking.java 的一個文件中,但在這兒採用散落的片斷進行解釋(大家可注意到片斷前後的“Continued”以及“Continuing”標誌。利用第17 章介紹的工具,可將這些片斷連結到一起)。首先讓我們看看基本的框架:

//: Blocking.java
// Demonstrates the various ways a thread
// can be blocked.
import java.awt.*;
import java.awt.event.*;
import java.applet.*;
import java.io.*;
//////////// The basic framework ///////////
class Blockable extends Thread {
 private Peeker peeker;
 protected TextField state = new TextField(40);
 protected int i;
 public Blockable(Container c) {
 c.add(state);
 peeker = new Peeker(this, c);
 }
 public synchronized int read() { return i; }
 protected synchronized void update() {
 state.setText(getClass().getName()
 + " state: i = " + i);
 }
 public void stopPeeker() { 
 // peeker.stop(); Deprecated in Java 1.2
 peeker.terminate(); // The preferred approach
 }
}

class Peeker extends Thread {
 private Blockable b;
 private int session;
 private TextField status = new TextField(40);
 private boolean stop = false;
 public Peeker(Blockable b, Container c) {
 c.add(status);
 this.b = b;
 start();
 }
 public void terminate() { stop = true; }
 public void run() {
 while (!stop) {
 status.setText(b.getClass().getName()
 + " Peeker " + (++session)
 + "; value = " + b.read());
 try {
 sleep(100);
 } catch (InterruptedException e){}
 }
 }
} ///:Continued

class Peeker extends Thread {
 private Blockable b;
 private int session;
 private TextField status = new TextField(40);
 private boolean stop = false;
 public Peeker(Blockable b, Container c) {
 c.add(status);
 this.b = b;
 start();
 }
 public void terminate() { stop = true; }
 public void run() {
 while (!stop) {
 status.setText(b.getClass().getName()
 + " Peeker " + (++session)
 + "; value = " + b.read());
 try {
 sleep(100);
 } catch (InterruptedException e){}
 }
 }
} ///:Continued

Blockable 類打算成爲本例所有類的一個基礎類。一個Blockable 對象包含了一個名爲state 的TextField(文本字段),用於顯示出對象有關的信息。用於顯示這些信息的方法叫作 update()。我們發現它用 getClass.getName()來產生類名,而不是僅僅把它打印出來;這是由於 update 不知道自己爲其調用的那個類的準確名字,因爲那個類是從Blockable 衍生出來的。

在Blockable中,變動指示符是一個 int i;衍生類的 run()方法會爲其增值。

針對每個Bloackable 對象,都會啓動Peeker 類的一個線程。Peeker 的任務是調用 read()方法,檢查與自己關聯的 Blockable 對象,看看 i 是否發生了變化,最後用它的 status 文本字段報告檢查結果。注意 read()和update()都是同步的,要求對象的鎖定能自由解除,這一點非常重要。

1.睡眠

這個程序的第一項測試就是用 sleep() 作出的:

///:Continuing
///////////// Blocking via sleep() ///////////
class Sleeper1 extends Blockable {
 public Sleeper1(Container c) { super(c); }
 public synchronized void run() {
 while(true) {
 i++;
 update();
 try {
 sleep(1000);
 } catch (InterruptedException e){}
 }
 }
}

class Sleeper2 extends Blockable {
 public Sleeper2(Container c) { super(c); }
 public void run() {
 while(true) {
 change();
 try {
 sleep(1000);
 } catch (InterruptedException e){}
 }
 }
 public synchronized void change() {
 i++;
 update();
 }
} ///:Continued


在Sleeper1 中,整個 run()方法都是同步的。我們可看到與這個對象關聯在一起的Peeker 可以正常運行,直到我們啓動線程爲止,隨後 Peeker 便會完全停止。這正是“堵塞”的一種形式:因爲 Sleeper1.run()是同步的,而且一旦線程啓動,它就肯定在 run()內部,方法永遠不會放棄對象鎖定,造成 Peeker 線程的堵 塞。

Sleeper2 通過設置不同步的運行,提供了一種解決方案。只有change()方法纔是同步的,所以儘管run()位於sleep()內部,Peeker 仍然能訪問自己需要的同步方法——read()。在這裏,我們可看到在啓動了Sleeper2 線程以後,Peeker 會持續運行下去。

2. 暫停和恢復

這個例子接下來的一部分引入了“掛起”或者“暫停”(Suspend)的概述。Thread 類提供了一個名爲suspend()的方法,可臨時中止線程;以及一個名爲 resume()的方法,用於從暫停處開始恢復線程的執行。 顯然,我們可以推斷出 resume()是由暫停線程外部的某個線程調用的。在這種情況下,需要用到一個名爲Resumer(恢復器)的獨立類。演示暫停/恢復過程的每個類都有一個相關的恢復器。如下所示:

///:Continuing
/////////// Blocking via suspend() ///////////
class SuspendResume extends Blockable {
 public SuspendResume(Container c) {
 super(c); 
 new Resumer(this); 
 }
}

class SuspendResume1 extends SuspendResume {
 public SuspendResume1(Container c) { super(c);}
 public synchronized void run() {
 while(true) {
 i++;
 update();
 suspend(); // Deprecated in Java 1.2
 }
 }
}

class SuspendResume2 extends SuspendResume {
 public SuspendResume2(Container c) { super(c);}
 public void run() {
 while(true) {
 change();
 suspend(); // Deprecated in Java 1.2
 }
 }
 public synchronized void change() {
 i++;
 update();
 }
}

class Resumer extends Thread {
 private SuspendResume sr;
 public Resumer(SuspendResume sr) {
 this.sr = sr;
 start();
 }
 public void run() {
 while(true) {
 try {
 sleep(1000);
 } catch (InterruptedException e){}
 sr.resume(); // Deprecated in Java 1.2
 }
 }
} ///:Continued

SuspendResume1 也提供了一個同步的run()方法。同樣地,當我們啓動這個線程以後,就會發現與它關聯的Peeker 進入“堵塞”狀態,等候對象鎖被釋放,但那永遠不會發生。和往常一樣,這個問題在SuspendResume2 裏得到了解決,它並不同步整個run()方法,而是採用了一個單獨的同步 change()方法。

對於Java 1.2,大家應注意 suspend()和resume()已獲得強烈反對,因爲 suspend()包含了對象鎖,所以極易出現“死鎖”現象。換言之,很容易就會看到許多被鎖住的對象在傻乎乎地等待對方。這會造成整個應用程序的“凝固”。儘管在一些老程序中還能看到它們的蹤跡,但在你寫自己的程序時,無論如何都應避免。

3. 等待和通知

通過前兩個例子的實踐,我們知道無論sleep()還是suspend()都不會在自己被調用的時候解除鎖定。需要用到對象鎖時,請務必注意這個問題。在另一方面,wait()方法在被調用時卻會解除鎖定,這意味着可在執行wait()期間調用線程對象中的其他同步方法。但在接着的兩個類中,我們看到 run()方法都是“同步”的。在wait()期間,Peeker 仍然擁有對同步方法的完全訪問權限。這是由於 wait()在掛起內部調用的方法時, 會解除對象的鎖定。

我們也可以看到wait()的兩種形式。第一種形式採用一個以毫秒爲單位的參數,它具有與sleep()中相同的含義:暫停這一段規定時間。區別在於在 wait()中,對象鎖已被解除,而且能夠自由地退出wait(),因爲一個notify()可強行使時間流逝。

第二種形式不採用任何參數,這意味着wait()會持續執行,直到 notify()介入爲止。而且在一段時間以後,不會自行中止。

wait()和 notify()比較特別的一個地方是這兩個方法都屬於基礎類 Object 的一部分,不象sleep(),suspend()以及resume()那樣屬於Thread 的一部分。儘管這表面看有點兒奇怪——居然讓專門進行線程處理 的東西成爲通用基礎類的一部分——但仔細想想又會釋然,因爲它們操縱的對象鎖也屬於每個對象的一部 分。因此,我們可將一個wait()置入任何同步方法內部,無論在那個類裏是否準備進行涉及線程的處理。事 實上,我們能調用 wait()的唯一地方是在一個同步的方法或代碼塊內部。若在一個不同步的方法內調用wait()或者 notify(),儘管程序仍然會編譯,但在運行它的時候,就會得到一個IllegalMonitorStateException(非法監視器狀態違例),而且會出現多少有點莫名其妙的一條消息:“current thread not owner”(當前線程不是所有人”。注意 sleep(),suspend()以及resume()都能在不同步的方法內調用,因爲它們不需要對鎖定進行操作。

只能爲自己的鎖定調用 wait()和 notify()。同樣地,仍然可以編譯那些試圖使用錯誤鎖定的代碼,但和往常一樣會產生同樣的 IllegalMonitorStateException 違例。我們沒辦法用其他人的對象鎖來愚弄系統,但可要 求另一個對象執行相應的操作,對它自己的鎖進行操作。所以一種做法是創建一個同步方法,令其爲自己的 對象調用notify()。但在 Notifier 中,我們會看到一個同步方法內部的 notify():

synchronized(wn2) {
 wn2.notify();
}

其中,wn2 是類型爲WaitNotify2 的對象。儘管並不屬於 WaitNotify2 的一部分,這個方法仍然獲得了wn2 對象的鎖定。在這個時候,它爲wn2 調用notify()是合法的,不會得到IllegalMonitorStateException 違例。

///:Continuing
/////////// Blocking via wait() ///////////
class WaitNotify1 extends Blockable {
 public WaitNotify1(Container c) { super(c); }
 public synchronized void run() {
 while(true) {
 i++;
 update();
 try {
 wait(1000);
 } catch (InterruptedException e){}
 }
 }
}

class WaitNotify2 extends Blockable {
 public WaitNotify2(Container c) {
 super(c);
 new Notifier(this); 
 }
 public synchronized void run() {
 while(true) {
 i++;
 update();
 try {
 wait();
 } catch (InterruptedException e){}
 }
 }
}


class Notifier extends Thread {
 private WaitNotify2 wn2;
 public Notifier(WaitNotify2 wn2) {
 this.wn2 = wn2;
 start();
 }
 public void run() {
 while(true) {
 try {
 sleep(2000);
 } catch (InterruptedException e){}
 synchronized(wn2) {
 wn2.notify();
 }
 }
 }
} ///:Continued

若必須等候其他某些條件(從線程外部加以控制)發生變化,同時又不想在線程內一直傻乎乎地等下去,一般就需要用到wait()。wait()允許我們將線程置入“睡眠”狀態,同時又“積極”地等待條件發生改變。而且只有在一個notify()或 notifyAll()發生變化的時候,線程纔會被喚醒,並檢查條件是否有變。因此,我們認爲它提供了在線程間進行同步的一種手段。

4. IO 堵塞

若一個數據流必須等待一些 IO活動,便會自動進入 "堵塞"狀態。在本例下面列出的部分中,有兩個類協同通用的 Reader 以及 Writer 對象工作。但在測試模型中,會設置一個管道化的數據流,使兩個線程相互間能安全地傳遞數據(這正是使用管道流的目的)。

Sender 將數據置入 Writer,並“睡眠”隨機長短的時間。然而,Receiver 本身並沒有包括sleep(),suspend()或者wait()方法。但在執行read()的時候,如果沒有數據存在,它會自動進入“堵塞”狀態。如下所示:

///:Continuing
class Sender extends Blockable { // send
 private Writer out;
 public Sender(Container c, Writer out) {
 super(c);
 this.out = out; 
 }
 public void run() {
 while(true) {
 for(char c = 'A'; c <= 'z'; c++) {
 try {
 i++;
 out.write(c);
 state.setText("Sender sent: " 
 + (char)c);
 sleep((int)(3000 * Math.random()));
 } catch (InterruptedException e){}
 catch (IOException e) {}
 }
 }
 }
}

class Receiver extends Blockable {
 private Reader in;
 public Receiver(Container c, Reader in) { 
 super(c);
 this.in = in; 
 }
 public void run() {
 try {
 while(true) {
 i++; // Show peeker it's alive
 // Blocks until characters are there:
 state.setText("Receiver read: "
 + (char)in.read());
 }
 } catch(IOException e) { e.printStackTrace();}
 }
} ///:Continued

這兩個類也將信息送入自己的 state 字段,並修改 i 值,使 Peeker 知道線程仍在運行。

14.3.2 死鎖

由於線程可能進入堵塞狀態,而且由於對象可能擁有"同步"方法——除非同步鎖定被解除,否則線程不能訪問那個對象——所以一個線程完全可能等候另一個對象,而另一個對象又在等候下一個對象,一次類推。這個"等候"鏈最可怕的情形就是進入封閉狀態——最後那個對象等候的是一個對象!此時,所有線程都會陷入無休止的相互等待狀態,大家都動彈不得。我們將這種情況稱爲"死鎖"。

1.Java 1.2 對 stop(),suspend(),resume()以及destroy()的反對

爲減少出現死鎖的可能,Java 1.2 作出的一項貢獻是“反對”使用 Thread 的 stop(),suspend(),resume() 以及destroy()方法。

之所以反對使用stop(),是因爲它不安全。它會解除由線程獲取的所有鎖定,而且如果對象處於一種不連貫狀態(“被破壞”),那麼其他線程能在那種狀態下檢查和修改它們。結果便造成了一種微妙的局面,我們很難檢查出真正的問題所在。所以應儘量避免使用 stop(),應該採用 Blocking.java 那樣的方法,用一個標 志告訴線程什麼時候通過退出自己的run()方法來中止自己的執行。

如果一個線程被堵塞,比如在它等候輸入的時候,那麼一般都不能象在Blocking.java 中那樣輪詢一個標誌。但在這些情況下,我們仍然不該使用 stop(),而應換用由 Thread 提供的 interrupt()方法,以便中止並退出堵塞的代碼。

//: Interrupt.java
// The alternative approach to using stop()
// when a thread is blocked
import java.awt.*;
import java.awt.event.*;
import java.applet.*;
class Blocked extends Thread {
 public synchronized void run() {
 try {
 wait(); // Blocks
} catch(InterruptedException e) {
 System.out.println("InterruptedException");
 }
 System.out.println("Exiting run()");
 }
}

public class Interrupt extends Applet {
 private Button 
 interrupt = new Button("Interrupt");
 private Blocked blocked = new Blocked();
 public void init() {
 add(interrupt);
 interrupt.addActionListener(
 new ActionListener() {
 public 
 void actionPerformed(ActionEvent e) {
 System.out.println("Button pressed");
 if(blocked == null) return;
 Thread remove = blocked;
 blocked = null; // to release it
 remove.interrupt();
 }
 });
 blocked.start();
 }
 public static void main(String[] args) {
 Interrupt applet = new Interrupt();
 Frame aFrame = new Frame("Interrupt");
 aFrame.addWindowListener(
 new WindowAdapter() {
 public void windowClosing(WindowEvent e) {
 System.exit(0);
 }
 });
 aFrame.add(applet, BorderLayout.CENTER);
 aFrame.setSize(200,100);
 applet.init();
 applet.start();
 aFrame.setVisible(true);
 }
}

Blocked.run()內部的 wait()會產生堵塞的線程。當我們按下按鈕以後blocked(堵塞)的句柄就會設爲null,使垃圾收集器能夠將其清除,然後調用對象的interrupt()方法。如果是首次按下按鈕,我們會看到線程正常退出。但在沒有可供“殺死”的線程以後,看到的便只是按鈕被按下而已。

suspend()和resume()方法天生容易發生死鎖。調用 suspend()的時候,目標線程會停下來,但卻仍然持有在這之前獲得的鎖定。此時,其他任何線程都不能訪問鎖定的資源,除非被“掛起”的線程恢復運行。對任何線程來說,如果它們想恢復目標線程,同時又試圖使用任何一個鎖定的資源,就會造成令人難堪的死鎖。所以我們不應該使用 suspend()和 resume(),而應在自己的Thread 類中置入一個標誌,指出線程應該活動還是掛起。若標誌指出線程應該掛起,便用wait()命其進入等待狀態。若標誌指出線程應當恢復,則用一個notify()重新啓動線程。我們可以修改前面的Counter2.java 來實際體驗一番。儘管兩個版本的效果是差不520多的,但大家會注意到代碼的組織結構發生了很大的變化——爲所有“聽衆”都使用了匿名的內部類,而且Thread 是一個內部類。這使得程序的編寫稍微方便一些,因爲它取消了 Counter2.java 中一些額外的記錄工作。

//: Suspend.java
// The alternative approach to using suspend()
// and resume(), which have been deprecated
// in Java 1.2.
import java.awt.*;
import java.awt.event.*;
import java.applet.*;
public class Suspend extends Applet {
 private TextField t = new TextField(10);
 private Button 
 suspend = new Button("Suspend"),
 resume = new Button("Resume");
 class Suspendable extends Thread {
 private int count = 0;
 private boolean suspended = false;
 public Suspendable() { start(); }
 public void fauxSuspend() { 
 suspended = true;
 }
 public synchronized void fauxResume() {
 suspended = false;
 notify();
 }
 public void run() {
 while (true) {
 try {
 sleep(100);
 synchronized(this) {
 while(suspended)
 wait();
 }
 } catch (InterruptedException e){}
 t.setText(Integer.toString(count++));
 }
 }
 } 
 private Suspendable ss = new Suspendable();
 public void init() {
 add(t);
 suspend.addActionListener(
 new ActionListener() {
 public 
 void actionPerformed(ActionEvent e) {
 ss.fauxSuspend();
 }
 });
 add(suspend);

resume.addActionListener(
 new ActionListener() {
 public 
 void actionPerformed(ActionEvent e) {
 ss.fauxResume();
 }
 });
 add(resume);
 }
 public static void main(String[] args) {
 Suspend applet = new Suspend();
 Frame aFrame = new Frame("Suspend");
 aFrame.addWindowListener(
 new WindowAdapter() {
 public void windowClosing(WindowEvent e){
 System.exit(0);
 }
 });
 aFrame.add(applet, BorderLayout.CENTER);
 aFrame.setSize(300,100);
 applet.init();
 applet.start();
 aFrame.setVisible(true);
 }
}     

Suspendable 中的suspended(已掛起)標誌用於開關“掛起”或者“暫停”狀態。爲掛起一個線程,只需調用fauxSuspend()將標誌設爲 true(真)即可。對標誌狀態的偵測是在 run()內進行的。就象本章早些時候提到的那樣,wait()必須設爲“同步”(synchronized),使其能夠使用對象鎖。在fauxResume()中,suspended 標誌被設爲 false(假),並調用 notify()——由於這會在一個“同步”從句中喚醒wait(),所 以fauxResume()方法也必須同步,使其能在調用notify()之前取得對象鎖(這樣一來,對象鎖可由要喚醍的那個wait()使用)。如果遵照本程序展示的樣式,可以避免使用wait()和notify()。

14.6 總結

何時使用多線程技術,以及何時避免用它,這是我們需要掌握的重要課題。它的主要目的是對大量任務進行有序的管理。通過多個任務的混合使用,可以更有效地利用計算機資源,或者對用戶來說顯得更方便。資源均衡的經典問題是在 IO 等候期間如何利用 CPU。至於用戶方面的方便性,最經典的問題就是如何在一個長時間的下載過程中監視並靈敏地反應一個"停止"(stop)按鈕的按下。

多線程的主要缺點包括:

  • (1) 等候使用共享資源時造成程序的運行速度變慢。
  • (2) 對線程進行管理要求的額外CPU 開銷。
  • (3) 複雜程度無意義的加大,比如用獨立的線程來更新數組內每個元素的愚蠢主意。
  • (4) 漫長的等待、浪費精力的資源競爭以及死鎖等多線程症狀。

線程另一個優點是它們用“輕度”執行切換(100 條指令的順序)取代了“重度”進程場景切換(1000 條指 令)。由於一個進程內的所有線程共享相同的內存空間,所以“輕度”場景切換隻改變程序的執行和本地變量。而在“重度”場景切換時,一個進程的改變要求必須完整地交換內存空間。

線程處理看來好象進入了一個全新的領域,似乎要求我們學習一種全新的程序設計語言——或者至少學習一系列新的語言概念。由於大多數微機操作系統都提供了對線程的支持,所以程序設計語言或者庫裏也出現了對線程的擴展。不管在什麼情況下,涉及線程的程序設計:

(1) 剛開始會讓人摸不着頭腦,要求改換我們傳統的編程思路;

(2) 其他語言對線程的支持看來是類似的。所以一旦掌握了線程的概念,在其他環境也不會有太大的困難。 儘管對線程的支持使Java 語言的複雜程度多少有些增加,但請不要責怪 Java。畢竟,利用線程可以做許多有益的事情。

多個線程可能共享同一個資源(比如一個對象裏的內存),這是運用線程時面臨的臭第一個麻煩。必須保證多個線程不會同時試圖讀取和修改那個資源。這要求技巧性地運用 sychronized(同步)關鍵字。它是一個有用的工具,但必須真正掌握它,因爲假若操作不當,極易出現死鎖。

除此以外,運用線程時還要注意一個非常特殊的問題。由於根據 Java 的設計,它允許我們根據需要創建任意數量的線程——至少理論上如此(例如,假設爲一項工程方面的有限元素分析創建數以百萬的線程,這對Java 來說並非實際)。然而,我們一般都要控制自己創建的線程數量的上限。因爲在某些情況下,大量線程 會將場面變得一團糟,所以工作都會幾乎陷於停頓。臨界點並不象對象那樣可以達到幾千個,而是在100 以下。一般情況下,我們只創建少數幾個關鍵線程,用它們解決某個特定的問題。這時數量的限制問題不大。 但在較常規的一些設計中,這一限制確實會使我們感到束手束腳。

大家要注意線程處理中一個不是十分直觀的問題。由於採用了線程“調度”機制,所以通過在run()的主循環中插入對 sleep()的調用,一般都可以使自己的程序運行得更快一些。這使它對編程技巧的要求非常高, 特別是在更長的延遲似乎反而能提高性能的時候。當然,之所以會出現這種情況,是由於在正在運行的線程準備進入“休眠”狀態之前,較短的延遲可能造成“sleep()結束”調度機制的中斷。這便強迫調度機制將其中止,並於稍後重新啓動,以便它能做完自己的事情,再進入休眠狀態。必須多想一想,才能意識到事情真 正的麻煩程度。

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