一、首先在了解多线程之前我们先介绍一下进程和线程的概念:
1.1 进程的概念:
对于一般程序而言,其结构大多可以划分为一个入口,一个出口和一个顺次执行的语句序列。在程序要投入运行时,系统从程序入口开始按语句的顺序(包括顺序,分支和循环结构)完成相应指令直至结尾,再从出口退出,整个程序结束。这样的语句结构被成为进程,它是程序的一次动态执行,对应了从代码加载、执行至执行完毕的一次完整过程;或者说进程就是程序在处理机中的一次运行。在这样的一个结构中不仅包括了程序代码,同时也包括系统资源的概念。
具体来说,一个进程既包括其所要执行的代码,又包括执行指令所需的任何系统资源,如CPU、内存空间、I/O端口等,不同进程所占用的系统资源相对独立。
1.2 线程的概念:
线程是进程执行过程中产生的多条执行线索,是比进程单位更小的执行单元,在形式上同进程十分相似——都是用一个按序执行的语句序列来完成特定的功能。不同的是它没有入口也没有出口,因此自身不能自动运行,而是必须栖身于某一个进程中,由进程触发执行。
在系统资源的使用上,属于同一进程的所有线程共享该进程的系统资源,但是线程之间切换速度比进程切换要快得多。
在单CPU的计算机内部,从微观上讲,一个时刻只能有一个作业被执行。实现多线程就是要在宏观上使多个作业被同时执行。多线程可以使系统资源特别是CPU的利用率得到提高,整个程序的执行效率也可以得到提高。
为了达到多线程的效果,Java语言把线程或执行环境(Execution Context)当作一个封装对象,包含CPU及自己的程序代码和数据,由虚拟机提供控制。Java类库中的类java.lang.Thread允许创建这样的线程,并可控制所创建的线程。
二、线程的三部分组成:
- 虚拟CPU,封装在java.lang.Thread类中,它控制着整个线程的运行。
- 执行的代码,传递给Thread类,由Thread类控制按序执行。
- 处理的数据,传递给Thread类,是在代码执行过程中所要处理的数据。
当一个线程被构造时,它由构造方法参数、执行代码、操作数据来初始化。这三个方面是各自独立的。一个线程所执行的代码与其他线程可以相同也可以不同,一个线程访问的数据与其他线程可以相同也可以不同。
与传统的进程相比,多线程编程简单、效率高。使用多线程可以在线程间直接共享数据和资源,而多进程之间不能做到这一点。多线程适合于开发有多种交互接口的程序。多线程的机制可以减轻编写交互频繁、涉及面多的程序的困难,如侦听网络端口的程序。程序中可以同时侦听多种设备,如网络端口、串口、并口以及其他外设等。
对多线程的支持是Java语言的一个重要特色,它提供了Thread类来实现多线程。
三、线程的状态:
Java的线程是通过java.lang中定义的类Thread来实现的。当生成一个Thread类的对象后就产生了一个线程。通过该对象的实例,可以启动线程,终止线程,或者暂时挂起线程等。
Thread类本身只是线程的虚拟CPU,线程所执行的代码,或者说线程所要完成的功能,是通过方法run()来完成的,方法run()称为线程体,包含在一个特定的对象中。实现线程体的特定对象是在初始化线程时传递给线程的。在一个线程被建立并初始化以后,Java运行时系统自动调用run()方法,建立线程的目的得以实现。
线程一共有4种状态,分别是新建(NEW)、可运行状态(Runnable)、死亡(Dead)及阻塞(Blocked),如下所示:
3.1 新建:
线程对象刚刚创建,还没有启动,此时还处于不可运行状态。此时刚创建的线程处于新建状态,但已有了相应的内存空间以及其他资源。
3.2 可运行状态:
此时的线程已经启动,处于线程的run()方法之中。这种情况下线程可能正在运行,也可能没有运行,只要CPU一空闲,马上就会运行。可以运行但没有运行的线程都排在一个队列中,这个队列称为就绪队列。
可运行状态中,正在运行的线程处于运行状态,等待运行的线程处于就绪状态。一般地,单CPU情况下,最多只有一个线程处于运行状态,可能会有多个线程处于就绪状态。调用线程的start()方法可使线程处于可运行状态。
3.3 死亡:
线程死亡的原因有两个:一是run()方法中最后一个语句执行完毕,二是当线程遇到异常退出时便进入了死亡状态。
3.4 阻塞:
一个正在执行的线程因特殊原因,被暂时执行,就进入阻塞状态。阻塞时线程不能进入就绪队列排队,必须等到引起阻塞的原因消除,才可重新进入队列排队。
3.5 中断线程:
在程序中常常调用interrupt()来终止线程。interrupt()不仅可中断正在运行的线程,而且也能中断处于blocked状态的线程,此时interrupt()会抛出一个InterruptedException异常。
Java提供了几个用于测试线程是否被中断的方法。
①void interrupt(): 向一个线程发送一个中断请求,同时把这个线程的"interrupted"状态置为true。若该线程处于"blocked"状态,会抛出InterruptException异常。
②static boolean interrupted(): 检测当前线程是否已被中断,并重置状态"interrupted"值。即如果连续两次调用该方法,则第二次调用将返回false。
③boolean isInterrupted(): 检测当前线程是否已被中断,不改变状态"interrupted"值。
四、创建线程:
创建线程有两种方法,一种是定义一个继承Thread类的子类,另一种是实现Runnable接口。
1.继承Thread类创建线程
java.lang.Thread是Java中用来表示线程的类,如果将一个类定义为Thread的子类,那么这个类的对象就可以用来表示线程。
Thread类中一个典型的构造方法如下。
Thread(ThreadGroup group, Runnable target, String name)
其中,name作为新线程的名称,且是线程组group中的一员,而target必须实现Runnable接口,它是另一个线程对象,当本线程启动时,将调用target的run()方法;当target为null时,启动本线程的run()方法。在Runnable接口中只定义了一个方法,即void run(),该方法作为线程体。
任何实现Runnable接口的对象都可以作为一个线程的目标对象。构造方法中各参数都可以缺省。Thread类本身也实现了Runnable接口。
定义一个线程类,它继承Thread类并重写方法run()。由于Java只支持单重继承,用这种方法定义的类不能再继承其他类。
用Thread类的子类创建线程的过程包括以下3步。
①从Thread类派生出一个子类,在类中一定要实现run()。
class Lefthand extends Thread {
public void run() { ... }
}
②然后用该类创建一个对象。如
Lefthand left = new Lefthand();
③用start()方法启动线程。如
left.start();
程序示例请查看程序10_1.java
// 用Thread类的子类创建线程
class LeftHand extends Thread{
public void run() { // 线程体
for(int i = 0; i <= 5; i++) {
System.out.println("I am LeftHand"); // 输出6次信息
try {
sleep(500);
} catch(InterruptedException e){ }
}
}
}
class Righthand extends Thread{
public void run() { // 线程体
for(int i = 0; i <= 5; i++) {
System.out.println("I am RightHand"); // 输出6次信息
try {
sleep(300);
} catch(InterruptedException e){ }
}
}
}
public class 程序10_1 {
static LeftHand left;
static Righthand right;
public static void main(String[] args) {
left = new LeftHand();
right = new Righthand();
left.start();
right.start();
}
}
程序10_1中定义了两个类Lefthand和Righthand,它们都是Thread类的子类,所以都是线程类,也都覆盖了父类的run()方法。在程序10_1类中,分别创建了线程对象,并且不需要给出任何参数。然后使用start()方法启动线程。两个线程的线程体是不一样的,它们输出不同的信息。当启动线程后,它们的执行顺序依系统来决定,所以输出的结果带有部分随机性。
2.实现Runnable接口创建线程
Runnable是Java中用以实现线程的接口,从根本上讲,任何实现线程功能的类都必须实现该接口。前面所用到的Thread类实际上就是因为实现了Runnable接口,所以它的子类才相应具有线程功能。
Runnable接口中只定义了一个方法就是run()方法,也就是线程体。用Runnable接口实现多线程时,也必须实现run()方法,也需要使用start()启动线程,但此时常用Thread类的构造方法来创建线程对象。
Thread的构造方法中包含有一个Runnable实例的参数,即必须定义一个实现Runnable接口的类并产生一个该类的实例,对该实例的引用就是适合于这个构造方法的参数。
例:编写线程体
public class xyz extends Runnable {
int i;
public void run() {
while(true) {
System.out.println("Hello" + i++);
}
}
}
利用类xyz可以构造以下线程:
Runnable r = new xyz();
Thread t = new Thread(r);
这样就定义了一个由t表示的线程,它用来执行xyz类的run()方法中的程序代码(接口Runnable要求实现方法public void run())。这个线程使用的数据由r所引用的xyz类的对象提供。
示例MyThreadTest:Runnable接口创建线程:
class TwoThread implements Runnable {
private int i;
public void run() {
for( ; i < 20;i++) {
System.out.println(Thread.currentThread().getName() + "\t" + i);
if(i==20){
System.out.println(Thread.currentThread().getName() + "\t" + "over");
}
}
}
}
public class MyThreadTest {
public static void main(String[] args) {
for(int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName() + "\t" + i);
if(i == 5) {
TwoThread t1 = new TwoThread();
Thread thread1 = new Thread(t1, "线程1");
Thread thread2 = new Thread(t1, "线程2");
thread1.start();
thread2.start();
}
}
}
}
在以上示例中,TwoThread是实现了Runnable接口的类,所以可以使用它创建线程t1。然后又将t1作为Thread构造方法中的target创建了两个线程thread1和thread2.这两个线程使用的是同一个t1,它们共享t1,即都执行t1的run()方法。
执行过程中,先输出几次主线程main的名字。之后,主线程与两个新线程共享的信息随机输出。
3.创建线程两种方法的适用条件
既然两种方法创建线程效果相同,那么使用哪种方法更好?如何决定选择两种方法的哪一种呢?下面分别列出了每种方法的适用范围。
3.1 适用于采用实现Runnable接口方法的情况
因为Java只允许单重继承,如果一个类已经继承了Thread,就不能再继承其他类,在一些情况下,这就被迫采用实现Runnable的方法。另外,由于原来的线程采用的是实现Runnable接口的方法,可能会出于保持程序风格的一贯性而继续使用这种方法。
3.2 适用于采用继承Thread方法的情况
当一个run()方法置于Thread类的子类中时,this实际上引用的是控制当前运行系统的Thread实例,所以,代码不必写的像这样: Thread.currentThread().getState(); 而是简写为: getState(); 因为代码稍微简洁一些,所以许多程序员愿意使用继承Thread的方法。
五、线程的基本控制
1.线程的启动:
虽然一个线程已经被创建,但它实际上并没有立刻运行。要使线程真正地在Java环境中运行,必须通过方法start()来启动,start()方法也在Thread类中。
在程序10.2中,只需要执行:
thread1.start();
thread2.start();
此时,线程中的虚拟CPU已就绪。
API中提供了以下有关线程的操作方法。
start(): 启动线程对象,让线程从新建状态转为就绪状态。
run(): 用来定义线程对象被调度后所执行的操作,用户必须重写run()方法。
yield(): 强制终止线程的执行。
isAlive(): 测试当前线程是否在活动。
sleep(int millsecond): 使线程休眠一段时间,时间长短由millsecond决定,单位为ms。
void wait(): 使线程处于等待状态。
2.线程的调度:
虽然就绪线程已经可以运行,但这并不意味着这个线程一定能够立刻运行。显然,在一台实际上只具有一个CPU的机器上,CPU在同一时间只能分配给一个线程做一件事。那么当有多于一个的线程工作时,CPU是如何分配的?这就是线程的调度问题。
在Java中,线程调度通常是抢占式,而不是时间片式。抢占式调度是指可能有多个线程准备运行,但只有一个在真正运行。一个线程获得执行权,这个线程将持续运行下去,直到它允许结束或因为某种原因而阻塞,再或者有另一个高优先级线程就绪,最后一种情况称为低优先级线程被高优先级线程所抢占。
每个线程都有一个优先级,Java的线程调度采用如下的优先级策略。
①优先级高的先执行,优先级低的后执行。
②每个线程创建时都会被自动分配一个优先级,默认时,继承其父类的优先级。
③任务紧急的线程,其优先级较高。
④同优先级的线程按"先进先出"的调度原则。
Thread类有3个与线程优先级有关的静态量,分别如下:
①MAX_PRIORITY: 最高优先级,值为10。
②MIN_PRIORITY: 最低优先级,值为1。
③NORM_PRIORITY: 默认优先级,值为5。
java.lang.Thread类中有关优先级的几个常用方法如下。
①void setPriority(int newPriority): 重置线程优先级。
②int getPriority(): 获得当前线程的优先级。
③static void yield(): 暂停当前正在执行的线程,即让当前线程放弃执行权。
一个线程被阻塞的原因是多种多样的,可能是因为执行了Thread.sleep()调用,故意让它暂停一段时间;也可能是因为需要等待一个较慢的外部设备,例如磁盘或用户操作的键盘。所有被阻塞的线程按次序排列,组成一个阻塞队列。而所有就绪但没有运行的线程则根据其优先级进入一个就绪队列。
当CPU空闲时,如果就绪队列不空。队列中第一个具有最高优先级的线程将运行。当一个线程被抢占而停止运行时,它的运行状态被改变并放到就绪队列的队尾; 同样,一个被阻塞(可能因为睡眠或等待I/O设备)的线程就绪后通常也放到就绪队列的队尾。
由于Java线程调度不是时间片式,所以在程序设计时要合理安排不同线程之间的运行顺序,以保证给其他线程留有执行的机会。为此,可以通过间隔地调用sleep()做到这一点。
例子如下, 调度sleep():
public class Xyz extends Thread {
public void run() {
while(true) {
......// 执行若干操作
// 给其他线程运行的机会
try {
Thread.sleep(10);
}catch(interruptedException e) {}
}
}
}
sleep()是Thread类中的静态方法,因此可以通过Thread.sleep(x)直接调用。参数x指定了线程在再次启动前必须休眠的最小时间,以毫秒为单位。
同时该方法可能引发中断异常InterruptedException,因此要进行捕获和处理。
这里说"最小时间"是因为这个方法只保证在一段时间后线程回到就绪状态,至于它是否能够获得CPU运行,则要视线程调度而定,所以,通常线程实际被暂停的时间都比指定的时间要长。
除sleep()方法以外,Thread类中的另一个方法yield()可以给其他同优先级线程一个运行的机会。如果在就绪队列中有其他同优先级的线程,yield()把调用者放到就绪队列尾,并允许其他线程运行;如果没有这样的线程,则yield()不做任何工作。
sleep()调用允许低优先级进程运行,而yield()方法只给同优先级进程以运行机会。
3.结束线程:
当一个线程从run()方法的结尾处返回时,它自动消亡并且不能再被运行,可以将其理解为自然死亡。另一种情况是遇到异常使得线程结束,可以将其理解为强迫死亡。还可以使用interrupt()方法中断线程的执行。
在程序代码中,可以利用Thread类中的静态方法currentThread()来引用正在运行的线程。有时候可能不知道一个线程的运行状态,这时可以使用方法isAlive()来获取一个线程是否还在活动状态的信息。活动状态不意味着这个线程正在执行,而只说明这个线程已被启动。
4.挂起线程:
有几种方法可以用来暂停一个线程的运行,暂停一个线程也称为挂起。在挂起之后,必须重新唤醒线程进入运行状态,只是线程执行命令的速度非常慢。挂起线程的方法有以下几种。
4.1 sleep()
sleep()方法用于暂时停止一个线程的执行。通常,线程不是休眠期满后就立刻被唤醒,因为此时其他线程可能正在执行,重新调度只在以下几种情况下才会发生。
>被唤醒的线程具有更高的优先级。
>正在执行的线程因为其他原因被阻塞。
>程序处于支持时间片的系统中。
大多数情况下,后两种条件不会立刻发生。
4.2 wait()和notify()/notifyAll()
wait()方法导致当前的线程等待,直到其他线程调用此对象的notify()方法或者notifyAll()方法,才能唤醒线程。
4.3 join()
join()方法将引起现行线程等待,直至join()方法所调用的线程结束。比如在线程B中调用了线程A的join()方法,直到线程A执行完毕后,才会继续执行线程B。可以想象成将线程A加入到当前线程B中。
join()方法在调用时也可以使用一个以毫秒计的时间值:
void join(long timeout);
此时join()方法将挂起现行线程timeout毫秒,或直到调用的线程结束,实际挂起时间以二者中较少的为准。
六、线程的互斥
1.互斥问题的提出
通常,一些同时运行的线程需要共享数据。此时,每个线程就必须要考虑与它一起共享数据的其他线程的状态与行为,否则的话就不能保证共享数据的一致性,因而也就不能保证程序的正确性。
下面设计一个代表栈的类。这个类没有采取措施处理溢出,栈的能力也很有限。
栈示例10.3:
class Stack {
int idx = 0;
char data[] = new char[6];
public void push(char c) {
data[idx] = c;
idx ++;
}
public char pop() {
idx --;
return data[idx];
}
}
栈具有"后进先出"模式,它使用下标值idx表示栈中下一个放置元素的位置。现在设想有两个独立的线程A和线程B都具有对这个类的同一个对象的引用,线程A负责入栈,线程B负责出栈。要求线程A放入栈中的数据都要由线程B读出,不重不漏。
表面上,通过以上的代码,数据将被成功地移入移出,但因为入栈方法push()及出栈方法pop()中含有多条语句,执行过程中仍然存在着潜在的问题。
假设此时栈中已经有字符1和2,当前线程A要入栈一个字符3,调用push(3),执行了语句data[idx] = c; 后被其他线程抢占了,此时尚未执行idx ++语句。故idx指向最后入栈的字符的下标,示意如下:
data 1 2 3
idx=2 ^
如果此时线程A马上被唤醒,可以继续修正idx的值,从而完成一次完成的入栈操作。如若不然,入栈操作执行了一半。若恰巧线程B此时占用CPU,调用pop(),执行出栈操作,则它返回的字符是2,因为它先执行idx--语句,idx的值变为1,返回的是data[1]处的字符,即字符2。字符3被漏掉了。
这个简单的例子说明的就是多线程访问共享数据时通常会引起的问题。产生这种问题的原因是对共享资源访问的不完整性。为了解决这种问题,需要寻找一种机制来保证对共享数据操作的完整性,这种完整性被称为共享数据操作的同步,共享数据叫作条件变量。
可以选择的一种方法是禁止线程在完成代码关键部分时被切换。这个关键代码部分,对于线程A就是入栈操作及下标值增加这两个动作,对于线程B就是下标值递减及出栈操作这两个动作。它们要么一起完成,要么都不执行。在Java中,提供了一个特殊的锁定标志来处理共享数据的访问。
2.对象的锁定标志
在Java语言中,引入了“对象互斥锁”的概念,也称为监视器,使用它来实现不同线程对共享数据操作的同步。“对象互斥锁”阻止对各线程同时访问同一个条件变量。Java可以为每一个对象的实例配有一个“对象互斥锁”。
在Java语言中,有两种方法可以实现“对象互斥锁”。
①用关键字volatile来声明一个共享数据(变量)。
②用关键字synchronized来声明操作共享数据的一个方法或一段代码
在这样的处理方法下,可以将一个对象想象成一间实验室,它为众多实验人员共同使用,但任何时候实验室只允许一组实验人员在里面做实验,否则就会引起混乱。为了进行控制,在门口设置一把锁。实验室没人的时候锁是开放的,有人员进入后第一件事就是将门锁上,然后开始工作,直到里面的实验人员完成工作后将锁打开才允许再次进入。
这种机制保证了在一组人员工作的过程中不会被另一组人员打断,也保证了数据操作的完整性。在同一时刻只能有一个任务访问的代码区称为临界区。
现在修改例10.3,增加对象访问的同步性,如下:
class stack {
int idx = 0;
char data[] = new char[6];
public void push(char c) {
synchronized(this) {
data[idx] = c;
idx ++;
}
}
}
当线程执行到被同步的语句时,它将传递的对象参数设为锁定标志,禁止其他线程对该对象的访问。同样地,如果pop()方法不进行修改,则当它被其他线程调用时,仍会破坏对象的一致性。因此,必须用同样办法修改pop()方法,如下:
例10.4 锁定标志:
public char pop() {
synchronized(this) { //增加同步标志
idx --;
return data[idx];
}
}
现在pop()和push()操作的部分增加了一个对synchronized(this)的调用,在第一个线程拥有锁定标记时,如果另一个线程企图执行synchronized(this)中的语句时,它将从对象this中索取锁定标记。因为这个标记不可得,故该线程不能继续执行。实际上这个线程将加入一个等待队列,该等待队列与对象锁定标志相连,当标志被返还给对象时,等待标志的第一个线程将得到该标志并继续运行。
因为等待一个对象的锁定标志的线程要等到持有该标志的线程将其返还后才能继续运行,所以在不使用该标志时将其返还就显得十分重要了。 事实上,当持有锁定标志的线程运行synchronized()调用包含的程序块后,这个标志将会被自动返还。
Java保证了该标志通常能够被正确地返还,即使被同步的程序块产生了一个异常,或者某个循环中断跳出了该程序块,这个标志也能被正确返还。同样,如果一个线程两次调用了同一个对象,在退出最外层后这个标志也将被正确释放,而在退出内层时则不会执行释放。
用synchronized标识的代码段或方法即为"对象互斥锁"锁住的部分。如果一个程序内有两个或以上的方法使用synchronized标志,则它们在同一个"对象互斥锁"的管理下。
一般情况下,使用synchronized关键字在方法的层次上实现对共享资源操作的同步,很少使用volatile关键字声明共享变量。
synchronized()语句的标准写法为:
public void push(char c) {
synchronized(this) {
......
}
}
由于synchronized()语句的参数必须是this,因此,Java语言允许使用下面这种简洁的写法:
public synchronized void push(char c) {
......
}
比较以上两种写法,可以看出,前一种比后一种更为妥帖。如果把synchronized用作方法的修饰字,则整个方法都将被视为同步块,这可能会使持有锁定标记的时间比实际需要的时间要长,从而降低效率。另一方面,使用前一种方法来做标记可以提醒他人同步在发生。
七、线程的同步
为了完成多个任务,常创建多个线程,它们可能毫不相干,但有时它们完成的任务在某种程度上有一定的关系,此时就需要线程之间有一些交互。在Java中,使用一对方法wait()和notify()/notifyall()实现线程的交互。
1.同步问题的提出:
操作系统中的生产者消费者问题,就是一个经典的同步问题。举个简单例子:有两个人,一个人在刷盘子,另一个在烘干。这两个人各自代表一个线程,他们之间有一个共享的对象为盘架,刷好而等待烘干的盘子放在盘架上。两个人在没有事情做的时候都愿意歇着。
显然,盘架上有刷好的盘子时,烘干的人才能开始工作;而如果刷盘子的人刷的太快,刷好的盘子占满了盘架时,他就不能再继续工作了,而要等到盘架上有空位置才行。
这个示例要说明的问题是,生产者生产一个产品后就放入共享对象中,而不管共享对象中是否已有产品。消费者从共享对象中取用产品,但不检测是否已经取过。
若共享对象中只能存放一个数据时,可能会出现以下问题:
①生产者比消费者快时,消费者会漏掉一些数据没有取到。
②消费者比生产者快时,消费者会取到相同的数据。
在Java语言中,可以用wait()和notify()/notifyAll()方法来协调线程间的运行速度关系,这些方法都定义在java.lang.Object类中。
2.解决方法:
为了解决线程运行速度问题,java提供了一种建立在对象实例之上的交互方法。java中的每个对象实例都有两个线程队列和它相连。第一个用来排列等待锁定标志的线程。第二个则用来实现wait()和notify()的交互机制。
类java.lang.Object中定义了3个方法 ,即wait()、notify()和notifyAll()。
①wait()方法导致当前的线程等待,它的作用是让当前线程释放其所持有的"对象互斥锁",进入wait队列(等待队列);而notify()/notifyAll()方法的作用是唤醒一个或所有正在等待队列中等待的线程,并将它(们)移入等待同一个"对象互斥锁"的队列。
②notify()/notifyAll()方法和wait()方法都只能被声明在synchronized的方法或代码段中调用。方法notify()最多只能释放等待对列中的第一个线程,如果有多个线程在等待,则其他的线程将继续留在队列中。notifyAll()方法能够释放所有等待线程。
再来看前面刷盘子的例子。线程A代表刷盘子,线程B代表烘干,它们都有对盘架drainingBoard的访问权。假设线程B(烘干线程)想要进行烘干工作,而此时盘架是空的,则应表示如下:
if(drainingBoard.isEmpty())
drainingBoard.wait(); // 盘架空时则等待
当线程B执行了wait()调用后,它不可再执行,并加入到对象drainingBoard的等待队列中。在有线程将它从这个队列中释放之前,它不能再次运行。
那么烘干程序怎样才能重新运行呢? 这应该由洗刷线程来通知它已经有工作可以做了,运行drainingBoard的notify()调用可以做到这一点。
drainingBoard.addItem(plate); // 放入一个盘子
drainingBoard.notify();
此时,drainingBoard的等待队列中第一个阻塞线程从队列中释放出来,并可重新参加运行的竞争。
注意,在这里使用notify()调用时,没考虑是否有正在等待的线程。事实上,应该只在增加盘子后使得盘架不再空时才执行这个调用。如果等待队列中没有阻塞线程时调用了方法notify(),则这个调用不做任何工作。notify()调用不会被保留到以后再发生效用。
使用这个机制,程序能够非常简单地协调洗刷线程和烘干线程,而且并不需要了解这些线程的身份。每当执行了一项操作,使得另一个线程能够开始工作,就通知对象drainingBoard(调用notify());每当由于盘架空或满而不能继续工作时,就等待对象drainingBoard(调用wait())。
在调用一个对象的wait()、notify()和notifyAll()时,必须首先持有该对象的锁定标志,因此这些方法必须在同步程序块中调用。这样,应该将代码改写如下:
synchronized(drainingBoard) {
if(drainingBoard.isEmpty())
drainingBoard.wait();
}
和
synchronized(drainingBoard) {
drainingBoard.addItem(plate);
drainingBoard.notify();
}
线程执行被同步的语句时必须要拥有对象的锁定标志,因此如果烘干线程被阻塞在wait()状态,洗刷线程不就永远不会执行到notify()语句了吗?
实际的实现过程是不会出现这种情况的,因为在执行wait()调用时,Java将首先把锁定标志返回给对象,因此即使一个线程由于执行wait()调用而被阻塞,它也不会影响其他等待锁定标志的线程的运行。
然而,为了避免打断程序的运行,当一个线程被notify()后,它并不立即变为可执行状态,而仅仅是从等待队列中移入锁定标志队列中。这样,在重新获得锁定标志之前,它仍旧不能继续运行。
另一方面,在实际实现中,wait()方法既可以被notify()终止,又可以通过调用线程的interrupt()方法来终止。后一种情况下,wait()会抛出一个interruptedException异常,所以需要把它放在try/catch结构中。
小结:
介绍线程的概念,线程各种状态之间转换的条件,线程的优先级,以及使用Thread类和Runnable接口创建线程的两种方式,还介绍了控制线程互斥和同步的基本知识。