Java* 环境中的多线程

作者:Allan McNaughton


Java* 环境中的多线程

Java* 线程能够为几乎所有的应用提供显著提升的性能。了解如何通过利用含超线程(HT)技术和全新的多核英特尔® 处理器,使这些线程成为 Java* 编程的标准部分。

通过线程执行来构建程序,即利用具有特定序列的指令来构建程序,从而带来巨大的性能优势。例如,某项程序从硬盘读取大量的数据,并在将数据写入屏幕(如 DVD 播放器)前对其进行处理。由于传统的单线程程序(如今大多数客户端程序都在使用的一类程序)一次只能执行一项任务,因此这些活动都将作为一个序列的一部分,按照不同阶段相继执行。除非某一特定大小的数据已经被读取,否则任何数据都不会得到处理。因此,只有完成磁盘读取之后,才能执行用于处理数据的程序逻辑。这样一来便影响了性能水平。

对于一个线程化的程序而言,一条线程可用于读取数据,另一条用于处理数据,而第三条则用于将数据写入显卡。这三条线程可以并行运行,也就是说,您可以一边读取硬盘,一边处理数据。这样总体性能就得到了优化。我们还能列举很多有关同时处理两种事物的能力将提升性能的实例。正是基于这点原因,我们才对 Java 虚拟机*(JVM*)本身进行了高度线程化。

本文将向您介绍创建多线程化 Java* 代码的方法、几个设计并行程序的最佳实践,并为您提供一些面向开发人员的工具和资源。要在一篇文章中论及如此之多的内容并非易事,因此我将仅就几个要点进行阐述,然后引导您获得更多的信息资源。


线程化 Java 代码

所有程序至少使用一条线程。在 C/C++ 和 Java 中,该线程就是以调用 main() 开始的线程。创建其它线程需要执行几个步骤,即创建一条新的线程,然后为其分派工作。一旦完成工作,JVM 将自动消除此线程。

Java 可提供两种创建线程并为其分派工作的方法。第一种方法是细分 Java 的线程类(Thread class)等级(在 java.lang 包中),然后使用线程的工作函数覆盖 run() 方法。举例如下:

public class SimpleThread extends Thread {

public SimpleThread(String str) {

super(str);

}

public void run() {

for (int i = 0; i < 10; i++) {

System.out.println(i + " " + getName());

try {

sleep((long)(Math.random() * 1000));

} catch (InterruptedException e) {}

}

System.out.println("DONE! " + getName());

}

}

 

该类可对线程进行细分,并提供自己的 run() 方法。此时,这一函数将运行一个循环,并把一个传递字符串打印到屏幕上,然后等待一段时间(时间随机)。循环经过十次迭代后,函数将打印“DONE!(完成!),然后退出,同时消除该线程。”创建线程的主函数如下:

public class TwoThreadsDemo {

public static void main (String[] args) {

new SimpleThread("Do it!").start();

new SimpleThread("Definitely not!").start();

}

}

 

请注意该代码的简易性:启动函数,给定一个名称(即线程将要打印的字符串)后,start() 就会被调用。start() 将调用 run() 方法。该程序的结果如预期所料:

0 Do it!

0 Definitely not!

1 Definitely not!

2 Definitely not!

1 Do it!

2 Do it!

3 Do it!

3 Definitely not!

4 Do it!

4 Definitely not!

5 Do it!

5 Definitely not!

6 Do it!

7 Do it!

6 Definitely not!

8 Do it!

7 Definitely not!

8 Definitely not!

9 Do it!

DONE! Do it!

9 Definitely not!

DONE! Definitely not!

 

如上所示,两条线程的输出结果是混合在一起的。在单线程程序中,所有的“Do it!”命令都会一起打印,之后是所有的“Definitely not!写入。”

不同的程序运行方式将产生不同的结果。原因主要有两方面:第一,循环的迭代之间存在着随机停顿;第二,更为重要的是,线程执行的时间无法保证。这是一个关键原则。JVM 会根据自身的进度运行线程(虽然一般情况下这会尽可能快地运行线程,却不能保证何时运行给定的线程)。因此,我们可将优先权赋予单条线程,以确保 JVM 在处理非关键线程之前先处理关键线程。

第二种启动线程的方法是使用具有 Runnable 接口(在 java.lang 中也有定义)的类。同前面的代码类似,此 Runnable 接口可指定一个 run() 方法,该方法随后将成为线程的主函数。

如今 Java 的常见风格更倾向于接口选项(interface),而非继承选项(inheritance)。这一首要法则也适用于本选项选择。通过采用接口选项,如果必要的话,某个类仍然可以在以后进行继承(子类)。(例如,当该类稍后被用作 applet 时。)


线程化的含义

线程化可以实现性能提升,但也会导致程序的内部操作更为复杂。这种复杂性主要源于线程之间的交互。了解并熟悉这些问题非常重要,因为随着英特尔处理器中内核的数量不断增加,线程的使用数量也相应地增涨。如果您在进行多线程编程时不了解这些问题,那么您无疑很难找到缺陷的所在。因此,现在就让我们来了解一下这些问题及其解决方案。

等待另一条线程完成: 假设我们有一个需要处理的整数阵列。我们可以采用一次处理一个整数的方法,来执行整个阵列的操作。我们也可以采用更加高效的方法,即分派多条线程,每条线程处理阵列的一部分。假设我们需要等所有线程均完成任务,才能进行下一步工作。为了暂时同步化线程间的活动,线程采用了 join() 方法,使每条线程等待另一条线程的完成,即连接线程(线程 B)会等待被连接的线程(线程 A)完成工作。join() 中的可选时间间隔可以支持线程 B 在线程 A 没有在给定时间范围内终止的情况下,继续执行其它工作。这就涉及到了线程问题复杂性的核心:线程等待问题。接下来我们就来讨论这个问题。

等待锁定对象: 假定我们编写了一个航线座位分配系统。这种大型程序往往会为每位连接到软件的用户指派一条线程,例如为每位售票员指派一条线程。(但是在超大型的系统中这一点往往无法实现。)如果两位用户同时尝试订购同一座位,就会出现问题。除非采取特殊措施,否则一条线程分配座位的同时,另一条线程可能也在执行同样的操作。这样,两位用户就都会以为自己订到了机票。

为了避免两条线程同时修改相同数据项目的情况,我们让一条线程在进行修改前先锁定该数据项目。这样一来,如果第二条线程也进行同样的修改,它必须要等第一条线程释放锁定后才能执行。这样的话,第二条线程就会发现座位已经分配,其订购座位的请求宣告失败。两条线程竞相争夺座位分配的问题被称为“竞争条件(race condition)”,它会引发严重混乱。若想解决这个问题,最好的办法就是锁定任何访问变量(可被一条以上线程访问)的代码。

Java 中有多个锁定选项。最常用的选项便是同步关键字的使用。如果某一方法的签名包含同步,那么在任何给定时间内只有一条线程能够执行该方法。一旦方法完成执行,该方法上的锁定就会立即被解除。例如,

protected synchronized int reserveSeat ( Seat seat_number )

{

if ( seat_number.getReserved() == false )

{

seat_number.setReserved();

return ( 0 );

}

else

return ( -1 );

}

 

以上方法一次只能由一条线程运行。这种锁定可打破上文所述的竞争条件。

采用同步化是处理线程间交互的方法之一。Java J2SE* 5.0 版中添加了多个锁定对象的简便方法。大多数方法都可以在 java.util.concurrent.locks 包中找到,如果您习惯了使用 Java 线程,请务必仔细检查该软件包。

尽管锁定机制可以解决竞争条件摂,但却带来了新的复杂问题。其中最为棘手的状况就是死锁。假设线程 A 正在等待线程 B,而线程 B 也等待着线程 A,那么这样一来两条线程将永远受阻,从而产生所谓的“死锁”。死锁很难识别(因为它们很难复制),因此您必须非常谨慎,以确保线程之间不存在此类依存关系。


使用线程池

如上文所述,当线程完成执行时,JVM 将取消它们,并最终回收分配给它们的内存。由于创建线程会带来巨大的相关开销,因此不断创建和销毁线程的过程将造成时钟周期浪费的问题。一项常见的解决方案,也是最佳的操作,便是提早分派程序中的线程组(名为“线程池”),并在线程可用时重新利用它们。在此模式下,在创建之初指派给线程的函数将存入池中,等待工作分配。完成分配的工作后,线程将被返回到线程池中。

J2SE 5.0 中添加了 java.util.concurrent 程序包(其中包括一个预建的线程池框架),为这一方法提供了极大的便捷性。如欲了解关于 Java 线程池的更多信息(包括教程在内),请访问:http://www.java-tips.org/java-se-tips/java.util.concurrent/pooling-threads-to-execute-short-2.html*

在设计线程化程序和线程池时,人们很自然会提出应创建多少条线程的问题。答案取决于您计划将线程用于何种用途。如果您希望根据任务数量在线程之间分配工作,那么线程的数量应该等于任务的数量。例如,某款文字处理软件可能需要将一条线程用来显示(几乎所有系统中的主要程序线程都有权升级用户界面),一条线程用来标记文件的页数,第三条线程用来检查拼写,第四条线程用于各项后台操作。因此,理想的方法是采用四条线程,这将为编写软件提供非常自然的方式。

但是,如果程序(如上所述)利用多条线程执行相似的工作,那么最佳线程数量往往反映了系统的资源情况,尤其是处理器上执行管线的数量以及处理器数量。在运行含超线程(HT)技术的英特尔® 处理器的系统上,每个处理器内核当前都具备两条执行管线。每枚全新的多核芯片拥有两个处理器内核。英特尔已经表示,未来芯片将趋于多核化,这是因为更多的内核等同于更高的性能,并且不会显著增加散热或功耗。因此,管线也会越来越多。

在基于双核英特尔® 奔腾® 4 处理器的系统上采用这些架构推荐的算法,就将有 4 条执行管线可用,这四条线程将为您带来理想的性能。对于基于双核英特尔® 至强® 处理器的工作站而言,理想的线程数量是四条,因为虽然该款至强处理器目前可提供超线程(HT)技术,但却没有多核模式。


最后思考

在英特尔® 平台上运行线程化 Java 程序时,您可能希望能够监控处理器上的负载和线程的执行情况。可以获取这些数据并管理 JVM 处理并行处理方式的最佳 JVM 之一就是 BEA 公司的 WebLogic* JRockit*。JRockit 具有众多额外优势,如专为英特尔® 平台构建,以及经过 BEA 和英特尔工程师的优化等。

无论您使用哪种 JVM,英特尔® VTune™ 性能分析器都能帮您深入分析 JVM 执行代码的方式,包括每线程性能的瓶颈问题。如欲阅读利用英特尔® Vtune™ 性能分析器进行 Java 编程的白皮书,请点击此处。 使用 VTune 性能分析器进行的 Java* 性能评测 [PDF 2MB].

本文大致介绍了线程在 Java 平台上的工作情况。随着英特尔持续发运含超线程(HT)技术的处理器并不断推出多核芯片,从这些众多的产品线中获取性能优势的压力也将日益增大。此外,随着内核数量的增加,管线的数量也会相应地激增。充分发挥其优势的唯一方式就是使用线程,如本文所介绍的示例线程。Java 中的线程化应用优势将越来越趋于明显。下文将为您提供更多资源,指导您在线程道路上走得更远。


更多参考资料和资源

 


作者简介

Allan McNaughton 是一位持有专利的技术专家和资深撰稿人,拥有超过十五年的丰富行业经验。他现任Technical Insight LLC*公司总裁,该公司是一家高科技白皮书的专业撰稿公司。此外,McNaughton 先生还经常在领先技术出版物上发表文章。

 


For more complete information about compiler optimizations, see our Optimization Notice.