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()结束”调度机制的中断。这便强迫调度机制将其中止,并于稍后重新启动,以便它能做完自己的事情,再进入休眠状态。必须多想一想,才能意识到事情真 正的麻烦程度。

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