iAfoot-Java多线程(详解)

286 阅读37分钟

概述:

Java对多线程编程(multithreaded propramming)提供了内置支持。多线程程序包含同时运行的两个或更多个部分。这种程序的每一部分被称为一个线程,并且每个线程定义了单独的执行路径。因此,多线程是特殊形式的多任务处理。

几乎可以肯定,你对多任务处理有所了解,因为实际上所有现代操作系统都支持多任务处理。但是,多任务处理有两种不同的类型:基于进程的多任务处理和基于线程的多任务处理。理解这两者之间的区别很重要。对于许多读者,往往更熟悉基于进程的多任务形式。进程本质上时正在执行的程序。因此,基于进程的多任务处理就是允许计算机同时运行两个或更多个程序的特性。例如,基于进程的多任务处理可以在运行Java编译器的同时使用文本编辑器或浏览网站。在基于进程的多任务处理中,程序是调度程序能够调度的最小代码单元。

基于线程的多任务环境中,最小的可调度代码单元是线程,这意味着单个程序可以同时执行两个或更多个任务。例如,文本编辑器可以在打印的同时格式化文本,只要这两个动作是通过两个独立的线程执行即可。因此,基于进程的多任务处理“大局”,而基于线程的多任务处理“细节”。 多任务线程需要的开销比多任务进程小。进程是重量级的任务,它们需要自己的地址空间。进程间通信开销很大并且有许多限制。从一个进程上下文切换到另一个进程上下文的开销也很大。另一方面,线程是轻量级的任务。它们共享相同的地址空间,并且合作共享同一个重量级的进程。线程间通信的开销不大,并且从一个线程上下文切换到另一个线程上下文的开销更小。虽然Java程序使用基于多进程的多任务环境,但是基于多进程的多任务处理不是由Java控制的。不过,基于多线程的多任务是由Java控制的。 使用多线程可以编写出更加高效的程序,以最大限度地利用系统提供的处理功能。多线程实现最大限度利用系统功能的一种重要方式是使空闲时间保持最少。对于交互网络环境中的Java操作这很重要,因为对于这种情况空闲时间很普遍。例如,网络上的数据的传输速率比计算机能够处理的速率低很多。即使读本地文件系统资源,速度也比CPU的处理速度慢很多。并且,用户输入速度当然也比计算机的处理速度慢很多。在单线程环境中,程序在处理这些任务中的下一任务之前必须等待当前任务完成——尽管在等待输入时,程序在大部分时间是空闲的。多线程有助于减少空闲时间,因为当等待输入时可以运行另一个线程

如果曾经写过基于Windows这类操作系统的程序,那么你肯定已经熟悉多线程编程了。但是,Java管理线程这一事实是的多线程编程特别方便,因为Java为你处理了许多细节。

Java线程模型

Java运行时系统在许多方面依赖于线程,并且所有类库在设计时都考虑了多线程。事实上,Java通过利用线程使得整个环境能够异步执行。这有助于通过防止浪费CPU时钟周期来提高效率。

通过与单线程环境进行比较,可以更好地理解多线程环境的价值。单线程系统使用一种称为轮询事件循环(event loop with polling)的方法。在这种模型中,单个线程在一个无限循环中控制运行,轮询一个事件队列以决定下一步做什么。一旦轮询返回一个信号,比如准备读取网络文件的信号,事件循环就将控制调度至适当的事件处理程序。在这个事件处理程序返回之前,程序不能执行任何其他工作。这浪费了CPU时间,并且会导致程序的一部分支配着系统而阻止对所有其他部分进行处理。通常,在单线程环境中,当线程因为等待某些资源而阻塞(即挂起执行)时,整个程序会停止执行

Java多线程的优点消除了主循环/轮询机制。可以暂停一个线程而不会停止程序的其他部分。例如,由于线程从网络读取数据或等待用户输入而造成的空闲时间,可以在其他地方得以利用。多线程允许当前激活的循环在两帧之间休眠,而不会造成整个系统暂停。当Java程序中的线程阻塞时,只有被阻塞的线程会暂停,所有其他线程仍将继续运行

大部分读者都知道,在过去几年,多核系统已经变得很普遍了。当然,单核系统仍然在广泛使用。Java的多线程系统在这两种类型的系统中都可以工作,理解这一点很重要。在单核系统中,并发执行的线程共享CPU,每个线程得到一片CPU时钟周期。所以,在单核系统中,两个或更多个线程不是真正同时运行的,但是空闲时间被利用了。然而,在多核系统中,两个或更多个线程可能是真正同步执行的。在许多情况下,这会进一步提高程序的效率并提供特定操作的速度

注意:

最近,Java中增加了Fork/Join框架,该框架为创建能够自动伸缩以充分利用多核环境的多线程应用程序提供了强大的方法。Fork/Join框架是Java对并行编程(parallel programming)支持的一部分,并行编程通常是指优化某些类型的算法,以便能够在多CPU系统中并行运行的一种技术。对Fork/Join框架及其他并行实用工具的讨论,请查看第28章。在此将介绍Java的传统多线程功能。

线程有多种状态,下面是一般描述。线程可以处于运行(running)状态,只要获得CPU时间就准备运行运行的线程可以被挂起(suspended),这会临时停止线程的活动。挂起的线程可以被恢复(resumed),从而允许线程从停止处恢复执行当等待资源时,线程会被阻塞(blocked)。在任何时候,都可以终止线程,这会立即停止线程的执行。线程一旦终止,就不能再恢复

线程优先级

Java为每个线程都指定了优先级,优先级决定了相对于其他线程应当如何处理某个线程。线程优先级是一些整数,它们指定了一个线程相对于另一个线程的优先程度。优先级的绝对数值没有意义;如果只有一个线程在执行,优先级高的线程不会比优先级低的线程运行快。反而,线程的优先级用于决定何时从一个运行的线程切换到下一个,这称为上下文切换(context switch)。决定上下文切换发生时机的规则比较简单

线程自愿地放弃控制。线程显示地放弃控制权、休眠或在I/O之前阻塞,都会出现这种情况。在这种情况下,检查所有其他线程,并且准备运行的线程中优先级最高的那个线程会获得CPU资源。
线程被优先级更高的线程取代。对于这种情况,没有放弃控制权的低优先级线程不管正在做什么,都会被高优先级线程简单地取代。基本上,只要高优先级线程希望运行,它就会取代低优先级线程,这称为抢占式多任务处理(preemptive multitasking)。

如果具有相同优先级的两个线程竞争CPU资源,这种情况有些复杂。对于Windows这类操作系统,优先级相等的线程以循环方式自动获得CPU资源。对于其他类型的操作系统,优先级相等的线程必须自愿地向其他线程放弃控制权,否则其他线程就不能运行

警告:

操作系统以不同的方式对具有相等优先级的线程进行上下文切换,可能会引起可移植性问题。

同步

因为多线程为程序引入了异步行为,所以必须提供一种在需要时强制同步的方法。例如,如果希望两个线程进行通信并共享某个复杂的数据结构,如链表,就需要以某种方式确保它们互相之间不会发生冲突。也就是说,当一个线程正在读取该数据结构时,必须阻止另外一个线程向该数据结构写入数据。为此,Java以监视器这一年代久远的进程间同步模型为基础,实现了一种巧妙的方案。监视器最初是由C.A.R.Hoare定义的一种控制机制,可以将监视器看作非常小的只能包含一个线程的盒子。一旦某个线程进入监视器,其他所有线程就必须等待,直到该线程退出监视器。通过这种方式,可以将监视器用于保护共享的资源,以防止多个线程同时对资源进行操作

Java没有提供“Monitor”类;相反,每个对象都有自己的隐式监视器。如果调用对象的同步方法,就会自动进入对象的隐式监视器。一旦某个线程位于一个同步方法中,其他线程就不能调用同一对象的任何其他同步方法。因为语言本身内置了同步支持,所以可以编写出非常清晰并且简明的多线程代码。

消息传递

将程序分割到独立的线程之后,需要定义它们之间相互通信的方式。当使用某些其他语言编写程序时,必须依赖操作系统建立线程之间的通信。当然,这会增加系统开销。相反,通过调用所有对象都具有的预先定义的方法,Java为两个或更多线程之间的相互通信提供了一种简洁的低成本方式。Java的消息传递系统允许某个线程进入对象的同步方法,然后进行等待,直到其他线程显示地通知这个线程退出为止。

Thread类和Runnable

Java的多线程系统是基于Thread类、Thread类的方法及其伴随接口Runnable而构建的Thread类封装了线程的执行。因为不能直接引用正在运行的线程的细微状态,所以需要通过代理进行处理,Thread实例就是线程的代理。为了创建新线程,程序可以扩展Thread类或实现Runable接口。

Thread类定义了一些用于帮助管理线程的方法,表11-1中显示的是本章将要用到的几个方法。

剩余部分将解释如何使用Thread类和Runnable接口创建和管理线程,首先介绍所有Java程序都有的线程——主线程。

主线程

当Java程序启动时,会立即开始运行一个线程,因为它是程序开始时执行的线程,所以这个线程通常称为程序的主线程。主线程很重要,有以下两个原因:

其他子线程都是主线程产生的。
通常,主线程必须是最后才结束执行的线程,因为它要执行各种关闭动作。

尽管主线程是在程序启动时自动创建的,但是可以通过Thread对象对其进行控制。为此,必须调用currentThread()方法获取对主线程的一个引用。该方法是Thread类的公有静态程序,它的一般形式如下所示:

这个方法返回对调用它的线程的引用。一旦得到对主线程的引用,就可以像控制其他线程那样控制主线程了。首先分析下面的例子:

在这个程序中,通过调用currentThread()方法来获取对当前线程(在本例中是主线程)的引用,并将这个引用存储在局部变量t中。接下来,程序显示有关线程的信息,然后程序调用setName()方法更改线程的内部名称。然后再次显示有关线程的信息。接下来是一个从5开始递减的循环,在两次循环之间暂停1秒。暂停是通过sleep()方法实现的。传递给sleep()方法的参数以毫秒为单位指定延迟的间隔时间。请注意封装循环的try/catch代码块。Thread类的sleep()方法可能会抛出InterruptedException异常。如果其他线程视图中断这个正在睡眠的线程,就会发生这种情况。在这个例子中,如果线程被中断,只会输出一条消息。在真实的程序中,可能需要以不同的方式处理这种情况。下面是该程序生成的输出:

注意当将t用作println()方法的参数时产生的输出,这将依次显示线程的名称、优先级以及线程所属线程组的名称。默认情况下,主线程的名称是main,优先级是5,这就是默认值,并且main也是主线程所属线程组的名称线程组(thread group)是将一类线程作为整体来控制状态的数据结构。在更改了线程的名称后,再次输出t,这一次将显示线程新的名称。

下面进一步分析在程序中使用Thread类定义的方法。sleep()方法使线程从调用时挂起,暂缓执行指定的时间间隔(毫秒数),它的一般形式如下所示:

挂起的毫秒数由milliseconds指定,这个方法可能会抛出InterruptedException异常。 sleep()方法还有第二种形式,如下所示,这种形式允许按照毫秒加纳秒的形式指定挂起的时间间隔
只有在计时周期精确到纳秒级的环境中,sleep()方法的第二种形式才有用。
正如前面的程序所示,使用setName()方法可以设置线程的名称。通过getName()方法可以获得线程的名称(不过,上述程序没有演示改方法)。这些方法都是Thread类的成员,它们的声明如下所示:
其中,threadName指定了线程的名称。

创建线程

在最通常的情况下,通过实例化Thread类型的对象创建线程。Java定义了创建线程的两种方法:

实现Runnable接口
扩展Thread类本身

接下来依次分析每种方法。

实现Runnable接口

**创建线程的最简单方式是创建实现了Runnable接口的类。Runnable接口抽象了一个可执行代码单元。可以依托任何实现了Runnable接口的对象来创建线程。为了实现Runnable接口,类只需实现run()方法,**改方法的声明如下所示:

在run()方法内部,定义组成新线程的代码。run()方法可以调用其他方法,使用其他类,也可以声明变量,就像main线程那样,理解这一点很重要。唯一的区别是:run()方法为程序中另外一个并发线程的执行建立了入口点。当run()方法返回时,这个线程将结束。

在创建实现了Runnable接口的类之后,可以在类中实例化Thread类型的对象。Thread类定义了几个构造函数。我们将使用的那个构造函数如下所示:

在这个构造函数中,thread0b是实现了Runnable接口的类的实例,这定义了从何处开始执行线程。新线程的名称由threadName指定。
在创建了新线程之后,只有调用线程的start()方法,线程才会运行,该方法是在Thread类中声明的本质上,start()方法执行对run()方法的调用。start()方法的声明如下所示:

下面的例子创建了一个新的线程并开始运行:

在NewThread类的构造函数中,通过下面这条预计创建了一个新的Thread对象:

传递this作为第一个参数,以表明希望新线程调用this对象的run()方法接下来调用start()方法,从run()方法开始启动线程的执行。这会导致开始执行子线程的for循环。调用完start()方法之后,NewThread类的构造函数返回到main()方法。当恢复主线程时,会进入主线程的for循环。两个线程继续运行,在单核系统中它们会共享CPU,直到它们的循环结束。这个程序生成的输出如下所示(基于特定的执行环节,输出可能有所变化):
如前所述,在多线程程序中,主线程通常必须在最后结束执行。事实上,对于某些旧的JVM,如果主线程在子线程完成之前结束,Java运行时系统可能会“挂起”。上面的程序确保主线程在最后结束,因为主线程在每次迭代之前休眠1 000毫秒,而子线程只休眠500毫秒。这使得子线比主线程终止的更早。稍后,你将会看到等待线程结束的更好方法。

扩展Thread类

**创建线程的第二种方式是创建一个扩展了Thread的新类,然后创建该类的实例。扩展类必须重写run()方法,run()方法是新线程的入口点。扩展类还必须调用start()方法以开始新线程的执行。**下面的程序对前面的程序进行了改写以扩展Thread类:

这个程序产生的输出和前面版本的相同。可以看出,子线程是通过实例化NewThread类创建的,NewThread类派生自Thread。
注意在NewThread类中对super()方法的调用,这会调用以下形式的Thread构造函数
其中,threadName指定了线程的名称。

选择一种创建方式

至此,你可能会好奇Java为什么提供两种创建子线程的方式,哪一种方式更好一些呢?这两个问题的答案涉及同一原因。Thread类定义了派生类可以重写的几个方法。在这些方法中,只有一个方法必须重写,即run()方法。当然,这也是实现Runnable接口时需要实现的方法许多Java程序员认为:只有当类正在以某种方式增强或修改时,才应当对类进行扩展。因此,如果不重写Thread类的其他方法,创建子线程的最好方式可能是简单地实现Runnable接口。此外,通过实现Runnable接口,你的线程类不需要继承Thread类,从而可以自由地继承其他类。最终,使用哪种方式取决于你自己。但是,在本章的剩余部分,将使用实现了Runnable接口的类创建线程。

创建多个线程

到目前为止,只使用了两个线程:主线程和一个子线程。但是,程序可以产生所需要的任意多个线程。例如,下面的程序创建了三个子线程:

可以看出,启动之后,所有三个子线程共享CPU。注意在main()方法中对sleep(10000)的调用,这会导致主线程休眠10秒钟,从而确保主线程在最后结束。

使用isAlive()和join()方法

前面提到过,通常希望主线程在最后结束。在前面的例子中,通过在main()方法中调用sleep()方法,并制定足够长的延迟时间来确保所有子线程在主线程之前终止。但是,这完全不是一个令人满意的方案,并且还会造成一个更大的问题:一个线程如何知道另一个线程何时结束?幸运的是,Thread类提供了一种能够解决这个问题的方法

有两种方法可以确定线程是否已经结束。首次,可以为线程调用isAlive()方法。这个方法是由Thread类定义的,它的一般形式如下所示:

如果线程仍然在运行,isAlive()方法就返回true,否则返回false
虽然isAlive()方法有时很有用,但是通常使用join()方法来等待线程结束,如下所示:
该方法会一直等待,直到调用线程终止。如此命名该方法的原因是:调用线程一直等待,直到指定的线程加入(join)其中为止。join()方法的另外一种形式,允许指定希望等待指定线程终止的最长时间

下面是前面例子的改进版本,该版本使用join()方法确保主线程在最后结束,另外还演示了isAlive()方法的使用:

线程优先级

线程调度程序根据线程优先级决定每个线程应当何时运行理论上,优先级更高的线程比优先级更低的线程会获得更多的CPU时间。实际上,线程得到的CPU时间除了依赖于优先级外,通常还依赖于其他几个因素(例如,操作系统实现多任务的方式可能会影响CPU时间的相对可用性)具有更高优先级的线程还可能取代更低优先级的线程。例如,当一个优先级的线程正在运行时,需要恢复一个更高优先级的线程(例如,从休眠或等待I/O中恢复)时,高优先级的线程将取代低优先级的线程

理论上,具有相同优先级的线程应当得到相等的CPU时间。但是,这需要谨慎对待。请记住,Java被设计为在范围广泛的环境中运行。有些环境实现多任务的方式与其他环境不同。为了安全起见,具有相同优先级的线程应当时不时释放控制权。这样可以确保所有线程在非抢占式操作系统中有机会运行。实际上,即使是在非抢占式环境中,大部分线程仍然有机会运行,因为大部分线程不可避免地会遇到一些阻塞情况,例如I/O等待。**当发生这种情况时,阻塞的线程被挂起,其他线程就可以运行。**但是,如果希望平滑多个线程的执行,最好不要依赖于这种情况。此外,某些类型的任务是CPU密集型的。这种线程会支配CPU。对于这类线程,你会希望经常地释放控制权,以使其他线程能够运行

为了设置线程的优先级,需要使用setPriority()方法,他是Thread类的成员。下面是该方法的一般形式:

其中**,level指定了为调用线程设置的新优先级。level的值必须在MIN_PRIORITY和MAX_PRIORITY之间选择。目前,这些值分别是1和10**。如果希望将线程设置为默认优先级,可以使用NORM_PRIORITY,目前的值是5。这些优先级是在Thread类中作为static final变量定义的。

可以通过调用Thread类的getPriority()方法获取当前设置的优先级,该方法如下所示:

不同的Java实现对于任务调度可能有很大的区别。如果线程依赖于抢占式行为,而不是协作性地放弃CPU,那么经常会引起不一致性。使用Java实现可预测、跨平台行为的最安全方法是使用自愿放弃CPU控制权的线程

同步

当两个或多个线程需要访问共享的资源时,它们需要以某种方式确保每次只有一个线程使用资源实现这一目的的过程称为同步。正如即将看到的,Java为同步提供了独特的、语言级的支持

同步的关键是监视器的概念,监视器是用作互斥锁的对象。在给定时刻,只有一个线程可以拥有监视器。当线程取得锁时,也就是进入了监视器。其他所有企图进入加锁监视器的线程都会被挂起,直到第一个线程退出监视器。也就是说,这些等待的其他线程在等待监视器。如果需要的话,拥有监视器的线程可以再次进入监视器。 可以使用两种方法同步代码。这两种方法都要用到synchronized关键字,下面分别介绍这两种方法。

使用同步方法

在Java中进行同步很容易,因为所有对象都有它们自身关联的隐式监视器为了进入对象的监视器,只需要调用使用synchronized关键字修饰过的方法。当某个线程进入同步方法中时,调用同一实例的该同步方法(或任何其他同步方法)的所有其他线程都必须等待为了退出监视器并将对象的控制权交给下一个等待线程,监视器的拥有者只需要简单地从同步方法返回

为了理解对同步的需求,下面介绍一个应当使用但是还没有使用同步的例子。下面的程序有3个简单的类。第1个类是Callme,其中只有一个方法call()。call()方法带有一个String类型的参数msg,这个方法尝试在方括号中输出msg字符串。需要注意的一件有趣的事情是:call()方法在输出括号和msg字符串之后调用Thread.sleep(1000),这会导致当前线程暂停1秒。

下一个类是Caller,其构造函数带有两个参数:对Callme实例的引用和String类型的字符串。这两个参数分别存储在成员变量target和msg中。构造函数还创建了一个新的调用对象run()方法的线程。线程会立即启动。Caller类的run()方法调用Callme类实例的target的call()方法,并传入msg字符串。最后,Synch类通过创建1个Callme类实现和3个Caller类实例来启动程序,每个Caller类实例带有唯一的消息字符串,但是为每个Caller类实例传递同一个Callme实例。

可以看出,通过调用sleep()方法,call()方法运行执行切换到另一个线程,这会导致混合输出3个消息字符串。在这个程序中,没有采取什么方法以阻止3个线程在相同的时间调用同一对象的同一个方法,这就是所谓的竞态条件(race condition),因为3个线程相互竞争以完成方法。这个例子使用了sleep()方法,使用效果可以重复并且十分明显。在大多数情况下,竞态条件会更加微妙并且更不可预测,因为不能确定何时会发生线程上下文切换。这会造成程序在某一次运行正确,而在下一次可能运行错误。 为了修复前面的程序,必须按顺序调用call()方法。也就是说,必须限制每次只能由一个线程调用call()方法。为此,只需简单地在call()方法定义的前面添加关键字synchronized,如下所示:
当一个线程使用call()方法时,这会阻止其他线程进入该方法。将synchronized关键字添加到call()方法中之后,程序的输出如下所示:

在多线程情况下,如果有一个或一组方法用来操作对象的内部状态,那么每次都应当使用synchronized关键字,以保证状态不会进入竞态条件。请记住,一旦线程进入一个实例的同步方法,所有其他线程就都不能再进入相同实例的任何同步方法。但是,仍然可以继续调用同一实例的非同步部分。

synchronized语句

虽然在类中创建同步方法是一种比较容易并且行之有效的实现同步的方式,但并不是在所有情况下都可以使用这种方法。为了理解其中的原因,我们分析下面的内容。**假设某个类没有针对多线程访问进行设计,即类没有使用同步方法,而又希望同步对类的方法。进一步讲,类不是由你创建的,而是由第三方创建的,并且你不能访问类的源代码。因此,不能为类中的合适方法添加synchronized修饰符。如何同步访问这种类的对象呢?**幸运的是,这个问题的解决方案很容易:可以简单地将这种类定义的方法的调用放到synchronized代码块中。 下面是synchroniced语句的一般形式:

其中,objRef是对被同步对象的引用。synchronized代码块确保对objRef对象的成员方法的调用,只会在当前线程成功进入objRef的监视器之后发生。

下面是前面例子的另一个版本,该版本在run()方法中使用synchronized代码块:

在此,没有使用synchronized修饰call()方法。反而,在Caller类的run()方法中使用了synchronized语句。这会使该版本的输出和前面版本的相同,它们都是正确的,因为每个线程在开始之前都要等待前面的线程先结束

线程间通信

前面的例子无条件地锁住其他线程对特定方法的异步访问。Java对象的隐式监视器的这种用途很强大,但是通过进程间通信可以实现更细微级别的控制。正如即将看到的,在Java中这很容易实现。 在前面讨论过,多线程通过将任务分割到独立的逻辑单元来替换事件循环编程线程还提供了第二个优点:消除了轮询检测。轮询检测通常是通过重复检查某些条件的循环实现的。一旦条件为true,就会发生恰当的动作,这会浪费CPU时间。例如,分析经典的队列问题,对于这种问题,一个线程产生一些数据,另外一个线程使用这些数据。为了使问题更有趣,假定生产者在产生更多数据之前,必须等待消费者结束。在轮询检测系统中,消费者在等待生产者生成时需要消耗许多的CPU时间。一旦生产者结束生产数据,就会开始轮询,在等待消费者结束的过程中,会浪费更多CPU时间。显然,这种情况不是你所期望的。

为了避免轮询检测,Java通过wait()、notify()以及notifyALL()方法,提供了一种巧妙的进程间通信机制,这些方法在Object中是作为final方法实现的,因此所有类都具有这些方法。所有这3个方法都只能在同步上下文中调用。尽管从计算机科学角度看,在概念上这些方法很高级,但是使用这些方法的规则实际上很简单:

wait()方法通知调用线程放弃监视器并进入休眠,直到其他一些线程进入同一监视器并调用notify()方法或者notifyAll()方法。
notify()方法唤醒调用相同对象的wait()方法的线程。 notifyAll()方法唤醒调用相同对象的wait()方法的所有线程,其中的一个线程将得到访问权限。

这些方法都是在Object类中定义的,如下所示:

wait()方法还有另外一种形式,允许指定等待的时间间隔。

在通过例子演示线程间通信之前,还有重要的一点需要指出。尽管在正常情况下,wait()方法会等待直到调用notify()或notifyAll()方法,但是还有一种几率很小却可能会发生的情况,等待线程由于假唤醒(spurious wakeup)而被唤醒。对于这种情况,等待线程也会被唤醒,然而却没有调用notify()或notifyAll()方法(本质上,线程在没有什么明显理由的情况下就被恢复了)。因为存在这种极小的可能,Oracle推荐应当在一个坚持线程等待条件的循环中调用wait()方法。下面的例子演示了这种技术。

现在通过一个使用wait()和notify()方法的例子演示线程间通信。首先分析下面的示例程序,该例以不正确的方式实现了一个简单形式的生成者/消费者问题。该例包含4个类:类Q是试图同步的队列;类Producer是生产队列条目的线程对象;类Consumer是使用队列条目的线程对象;类PC是一个小型类,用于创建类Q、Producer和Consumer的实例。

尽管类Q中的put()和get()方法是同步的,但是没有什么措施能够停止生产者过度运行,也没有什么措施能停止消费者两次消费相同的队列值。因此,得到的输出是错误的,如下所示(根据处理器的速度和加载的任务实际输出可能会不同):
可以看出,生产者在将1放入队列之后,消费者开始运行,并且连续5次获得相同的数值1。然后,生产者恢复执行,并产生数值2到7,而不让消费者有机会试用它们。

试用Java编写这个程序的正确方式是使用wait()和notify()方法在两个方向上发信号,如下所示:

在get()方法中调用wait()方法,这会导致get()方法的执行被挂起,直到生产者通知已经准备好一些数据。当发出通知时,恢复get()方法中的执行。在获得数据之后,get()方法调用notify()方法。该调用通知生产者可以在队列中放入更多数据。在put()方法中,wait()方法暂停执行直到消费者从队列中删除条目。当执行恢复时,下一个数据条目被放入到队列中,并调用notify()方法。这会通知消费者,现在应当删除该数据条目。

死锁

需要避免的与多任务处理明确相关的特殊类的错误是死锁(deadlock),当两个线程循环依赖一对同步对象时,会发生这种情况。例如,假设一个线程进入对象X的监视器,另一个线程进入对象Y的监视器。如果X中的线程试图调用对象Y的任何同步方法,那么会如你所期望的那样被阻塞。但是,如果对象Y中的线程也试图调用对象A的任何同步方法,那么会永远等待下去,因为为了进入X,必须释放对Y加的锁,这样第一个线程才能完成。死锁是一种很难调试的错误,原因有两点:

死锁通常很少发生,只有当两个线程恰好以这种方式获取CPU时钟周期时才会发生死锁。
死锁可能涉及更多的线程以及更多的同步对象(也就是说,死锁可能是通过复杂的事件序列发生的,而不是通过刚才描述的情况发生的)。

为了完全理解死锁,实际进行演示是有用的。下一个例子创建两个类——A和B,这两个类分别具有方法foo()和bar(),在调用对方类中的方法之前会暂停一会儿。主类DealLock创建A的一个实例和B的一个实例,然后开始第二个线程以设置死锁条件。方法foo()和bar()使用sleep()作为强制死锁条件发生的手段。

因为程序被死锁,所有你需要按下Ctrl+C组合键来结束程序。通过在PC上按下Ctrl+Break组合键,可以看到完整的线程和监视器缓存存储(monitor cache dump)。可以看出,当等待a的监视器时,RacingThread拥有b的监视器。同时,MainThread拥有a,并且在等待获取b。这个程序永远不会结束。正如该程序所演示的,如果多线程程序偶尔被锁住,那么首先应当检查是否是由于死锁造成的。

挂起、恢复与停止线程

有时,挂起线程的执行是有用的。例如,可以使用单独的线程显示一天的时间。如果用户不想要时钟,那么可以挂起时钟线程。无论是什么情况,挂起线程都是一件简单的事情。线程一旦挂起,重新启动线程也很简单。

Java早期版本(例如Java 1.0)和现代版本(从Java 2开始)提供的用来挂起、停止以及恢复线程的机制不同。在Java 2以前,程序使用Thread类定义的suspend()、resume()和stop()方法,暂停、重新启动和停止线程的执行

虽然这些方法对于管理线程执行看起来是一种合理并且方便的方式,但是在新的Java程序中不能使用它们。下面是其中的原因。在几年前,Java 2不推荐使用Thead类的suspend()方法,因为suspend()方法有时会导致严重的系统故障。假定线程为关键数据结构加锁,如果这时线程被挂起,那么这些锁将无法释放。其他可能等待这些资源的线程会被死锁

方法resume()也不推荐使用。虽然不会造成问题,但是如果不使用suspend()方法,就不能使用resume()方法,它们是配对使用的

对于Thread类的stop()方法,Java2也反对使用,因为有时这个方法也会造成严重的系统故障。假定线程正在向关键的重要数据结构中写入数据,并且只完成了部分发生变化的数据。如果这时停止线程,那么数据结构可能会处于损坏状态问题是:stop()会导致释放调用线程的所有锁。因此,另一个正在等待相同锁的线程可能会使用这些已损坏的数据

因为现在不能使用suspend()、resume()以及stop()方法控制线程,所有你可能会认为没有办法来暂停、重启以及终止线程。但幸运的是,这不是真的。反而,线程必须被设计为run()方法周期性地进行检查,以确定是否应当挂起、恢复或停止线程自身的执行。通常,这是通过建立用来标志线程执行状态的变量完成的。只要这个标志变量被设置为“运行”,run()方法就必须让线程继续执行。如果标志变量被设置为“挂起”,线程就必须暂停。如果标志变量被设置为“停止”,线程就必须终止。当然,编写这种代码的方式有很多,但是对于所有程序,中心主题是相同的。

下面的例子演示了如何使用继承自Object的wait()和notify()方法控制线程的执行。下面分析这个程序中的操作。NewThread类包含布尔型实例变量suspendFlag,该变量用于控制线程的执行,构造函数将该变量初始化为false。方法run()包含检查suspendFlag变量的synchronized代码块。如果该变量为true,就调用wait()方法,挂起线程的执行。mysuspend()方法将suspendFlag变量设置为true。myresume()方法将suspendFlag设置为false,并调用notify()方法以唤醒线程。最后,对main()方法进行修改以调用mysuspend()和myresume()方法。

运行这个程序时,会看到线程被挂起和恢复。在本书的后面,会看到更多使用现代线程控制机制的例子。尽管这种机制没有旧机制那么“清晰”,但是不关注怎样,这是确保不会发生运行错误所需要做的。对于所有新代码,必须使用这种方式。

获取线程的状态

在本章前面提到过,线程可以处于许多不同的状态。可以调用Thread类定义的getState()方法来获取线程的当前状态,该方法如下所示:

该方法返回Thread.State类型的值,指示在调用该方法时线程所处的状态。State是由Thread类定义的枚举类型(枚举是一系列具有名称的常亮,将在第12节详细讨论)。表11-2中列出了getState()可以返回的值

对于给定的Thread实例,可以使用getState()方法获取线程的状态。例如,下面的代码判断调用信息thre在调用getState()方法时是否处于RUNNABLE状态:

在调用getState()方法之后,线程的状态可能会发生变化,理解这一点很重要。因此,基于具体的环境,通过调用getState()方法获取的状态,可能无法反映之后一段较短的时间内线程的实际状态。由于该原因(以及其他原因),getState()方法的目标不是提供一种同步线程的方法,而主要用于调试或显示线程的运行时特征

使用多线程

有效利用Java多线程特性的关键是并发地而不是顺序地思考问题。例如**,当程序中有两个可以并发执行的子系统时,可以在单独的线程中执行它们。通过细心地使用多线程,可以创建非常高效的程序。但是需要注意:如果创建的线程太多,实际上可能会降低程序的性能,而不是增强性能。请记住,线程上下文切换需要一定的开销。如果创建的线程过多,花费在上下文切换上的CPU时间会比执行程序的实际时间更长。最后一点:为了创建能够自动伸缩以尽可能利用多核系统中可用处理器的计算密集型应用程序,可用考虑使用新的Fork/Join框架**。