一、Java环境下的多线程技术 构建线程化的应用程序往往会对程序带来重要的性能影响。例如,请考虑这样一个程序,它从磁盘读取大量数据并且在把它们写到屏幕之前处理这些数据(例如一个DVD播放器)。在一个传统的单线程程序(今天所使用的大多数客户端程序)上,一次只有一个任务执行,每一个这些活动分别作为一个序列的不同阶段发生。只有在一块已定义大小的数据读取完成时才能进行数据处理。因此,能处理数据的程序逻辑直到磁盘读操作完成后才得到执行。这将导致非常差的性能问题。 在一个多线程程序中,可以分配一个线程来读取数据,让另一个线程来处理数据,而让第三个线程把数据输送到图形卡上去。这三个线程可以并行运行;这样以来,在磁盘读取数据的同时仍然可以处理数据,从而提高了整体程序的性能。许多大量的示例程序都可以被设计来同时做两件事情以进一步提高性能。Java虚拟机(JVM)本身就是基于此原因广泛使用了多线程技术。 本文将讨论创建多线程Java代码以及一些进行并行程序设计的最好练习;另外还介绍了对开发者极为有用的一些工具和资源。篇幅所限,不可能全面论述这些问题,所以我想只是重点提一下极重要的地方并提供给你相应的参考信息。 二、线程化Java代码 所有的程序都至少使用一个线程。在C/C++和Java中,这是指用对main()的调用而启动的那个线程。另外线程的创建需要若干步骤:创建一个新线程,然后指定给它某种工作。一旦工作做完,该线程将自动被JVM所杀死。 Java提供两个方法来创建线程并且指定给它们工作。第一种方法是子类化Java的Thread类(在java.lang包中),然后用该线程的工作函数重载run()方法。下面是这种方法的一个示例:
这个类子类化Thread并且提供它自己的run()方法。上面代码中的函数运行一个循环来打印传送过来的字符串到屏幕上,然后等待一个随机的时间数目。在循环十次后,该函数打印"DONE!",然后退出-并由它杀死这个线程。下面是创建线程的主函数:
注意该代码极为简单:函数开始,给定一个名字(它是该线程将要打印输出的字符串)并且调用start()。然后,start()将调用run()方法。程序的结果如下所示:
正如你所看到的,这两个线程的输出结果纠合到一起。在一个单线程程序中,所有的"Do it!"命令将一起打印,后面跟着输出"Definitely not!"。 这个程序的不同运行将产生不同的结果。这种不确定性来源于两个方面:在循环中有一个随机的暂停;更为重要的是,因为线程执行时间没法保证。这是一个关键的原则。JVM将根据它自己的时间表运行这些进程(虚拟机一般支持尽可能快地运行这些线程,但是没法保证何时运行一个给定线程)。对于每个线程可以使一个优先级与之相关联以确保关键线程被JVM处理在次要的线程之前。 启动一个线程的第二种方法是使用一个实现Runnable接口的类-这个接口也定义在java.lang中。这个Runnable接口指定一个run()方法-然后该方法成为线程的主函数,类似于前面的代码。 现在,Java程序的一般风格是支持继承的接口。通过使用接口,一个类在后面仍然能够继承(子类化)-如果必要的话(例如,如果该类要在后面作为一个applet使用的话,就会发生这种情况)。 三、线程的含义 在采用多线程技术增强性能的同时,它也增加了程序内部运行的复杂性。这种复杂性主要是由线程之间的交互引起的。熟悉这些问题是很重要的,因为随着越来越多的核心芯片加入到Intel处理器中,要使用的线程数目也将相应地增长。如果在创建多线程程序时不能很好地理解这些问题,那么是调试时将很难发现错误。因此,让我们先看一下这些问题及其解决办法。 等待另一个线程完成:假定我们有一个整型数组要进行处理。我们可以遍历这个数组,每次一个整数并执行相应的操作。或,更高效地,我们可以建立多个线程,这样以来让每个线程处理数组的一部分。假定我们在开始下一步之前必须等待所有的线程结束。为了暂时同步线程之间的活动,这些线程使用了join()方法-它使得一个线程等待另一个线程的完成。加入的线程(线程B)等待被加入的线程(线程A)的完成。在join()中的一个可选的超时值使得线程B可以继续处理其它工作-如果线程A在给定的时间帧内还没有终止的话。这个问题将触及到线程的核心复杂性-等待线程的问题。下面我们将讨论这个问题。 在锁定对象上等待:假定我们编写一个航空公司座位分配系统。在开发这种大型的程序时,为每个连接到该软件的用户分配一个线程是很经常的,如一个线程对应一个机票销售员(在很大的系统中,情况并非总是如此)。如果有两个用户同时想分配同一个座位,就会出现问题。除非采取特殊的措施,否则一个线程将分配该座位而另一个线程将会在做相同的事情。两个用户都会认为他们在这趟航班上拥有一个分配的位子。 为了避免两个线程同时修改一样的数据项,我们让一个线程在修改数据前锁定数据项。用这种方法,当第二个线程开始作修改时,它将等待到第一个线程释放锁为止。当这种发生时,线程将会看到座位已被分配,而对于座位分配的请求就会失败。两个线程竞争分配座位的问题也就是著名的竞争条件问题,而当竞争发生时有可能导致系统的泄漏。为此,最好的办法就是锁定任何代码-该代码存取一个可由多个线程共同存取的变量。 在Java中存在好几种锁选择。其中最为常用的是使用同步机制。当一个方法的签名包含同步时,在任何给定时间只有一个线程能够执行这个方法。然后,当该方法完成执行时,对该方法的锁定即被解除。例如,
就是一个方法-在这种方法中每次只运行一个线程。这种锁机制就打破了上面所描述的竞争条件。 使用同步是处理线程间交互的几种方法中的一种。J2SE 5.0中添加了若干方便的方法来锁定对象。大多数这些方法可以在包java.util.concurrent.locks中找到-一旦你熟悉了Java线程,就应该对它进行详细的研究。 在锁机制解决了竞争条件的同时,它们也带来了新的复杂性。在这种情况下,最困难的问题就是死锁。假定线程A在等待线程B,并且线程B在等待线程A,那么这两个线程将永远被锁定-这正是术语死锁的意义。死锁问题可能很难判定,并且必须相当小心以确保在线程之间没有这种依赖性。 四、使用线程池 如前所提及,在线程完成执行时,它们将被JVM杀死而分配给它们的内存将被垃圾回收机制所回收。不断地创建和毁灭线程所带来的麻烦是它浪费了时钟周期,因为创建线程确实耗费额外的时间。一个通用的且最好的实现是在程序运行的早期就分配一组线程(称为一个线程池),然后在这些线程可用时再使用它们。通过使用这种方案,在创建时分配给一个线程指定的功能就是呆在线程池中并且等待分配一项工作。然后,当分配的工作完成时,该线程被返回到线程池。 J2SE 5.0引入了java.util.concurrent包-它包括了一个预先构建的线程池框架-这大大便利了上述方法的实现。有关Java线程池的更多信息及一部教程,请参见http://java.sun.com/developer/JDCTechTips/2004/tt1116.html#2。 在设计线程程序和线程池时,自然出现关于应该创建多少线程的问题。答案看你怎样计划使用这些线程。如果你基于分离的任务来用线程划分工作,那么线程的数目等于任务的数目。例如,一个字处理器可能使用一个线程用于显示(在几乎所有系统中的主程序线程负责更新用户接口),一个用于标记文档,第三个用于拼写检查,而第四个用于其它后台操作。在这种情况中,创建四个线程是理想的并且它们提供了编写该类软件的一个很自然的方法。 然而,如果程序-象早些时候所讨论的那个一样-使用多个线程来做类似的工作,那么线程的最佳数目将是系统资源的反映,特别是处理器上可执行管道的数目和处理器的数目的反映。在采用英特尔处理器超线程技术(HT技术)的系统上,当前在每个处理器核心上有两个执行管道。最新的多核心处理器在每个芯片上有两个处理器核心。英特尔指出将来的芯片有可能具有多个核心,大部分是因为额外的核心会带来更高的性能而不会从根本上增加热量或电量的消耗。因此,管道数将会越来越多。 照上面这些体系结构所作的算术建议,在一个双核心Pentium 4处理器系统上,可以使用四条执行管道并因此可以使用四个线程将会提供理想的性能。在一个双处理器英特尔Xeon?处理器的工作站上,理想的线程数目是4,因为目前Xeon芯片提供HT技术但是没提供多核心模型。你可以参考下面文档来了解这些新型处理器上的执行管道的数目(http://www.intel.com/cd/ids/developer/asmo-na/eng/196716.htm)。 五、小结 你当在平台上运行线程化的Java程序时,你将可能想要监控在处理器上的加载过程与线程的执行。最好的获得这些数据与管理JVM怎样处理并行处理的JVM之一是BEA的WebLogic JRockit。JRockit还有其它一些由来自于BEA和Intel公司的工程师专门为Intel平台设计和优化的优点。 不考虑你使用哪一种JVM,Intel的VTune Performance Analyzer将会给你一个关于JVM怎样执行你的代码的很深入的视图-这包括每个线程的性能瓶颈等。另外,Intel还提供了关于如何在Java环境下使用VTune Performance Analyzer的白皮书[PDF 2MB]。 总之,本文提供了线程在Java平台工作机理的分析。由于Intel还将继续生产HT技术的处理器并且发行更多的多核心芯片,所以想从这些多管道中得到性能效益的压力也会增加。并且,由于核心芯片数目的增加,管道的数目也将相应地增加。唯一的利用它们的优点的办法就是使用多线程技术,如在本文中所讨论的。并且Java多线程程序的优势也越来越明显。 |