Java 理论与实践:并发在一定程度上使一切变得简单

类别:Java 点击:0 评论:0 推荐:
 Brian Goetz([email protected]
首席顾问,Quiotix Corp
2003 年 4 月

对于每个项目,象许多其它应用程序基础结构服务一样,通常无需从头重新编写并发实用程序类(如工作队列和线程池)。这个月,Brian Goetz 将介绍 Doug Lea 的 util.concurrent 包,这是一个高质量的、广泛使用的、并发实用程序的开放源码包。可以通过本文的论坛提出您对本文的想法,以飨笔者和其他读者。(您也可以单击本文顶部或底部的“讨论”参加论坛。)
当项目中需要 XML 解析器、文本索引程序和搜索引擎、正则表达式编译器、XSL 处理器或 PDF 生成器时,我们中大多数人从不会考虑自己去编写这些实用程序。每当需要这些设施时,我们会使用商业实现或开放源码实现来执行这些任务原因很简单 — 现有实现工作得很好,而且易于使用,自己编写这些实用程序会事倍功半,或者甚至得不到结果。作为软件工程师,我们更愿意遵循艾萨克·牛顿的信念 — 站在巨人的肩膀之上,有时这是可取的,但并不总是这样。(在 Richard Hamming 的 Turing Award 讲座中,他认为计算机科学家的“自立”要更可取。)

探究重复发明“车轮”之原因
对于一些几乎每个服务器应用程序都需要的低级应用程序框架服务(如日志记录、数据库连接合用、高速缓存和任务调度等),我们看到这些基本的基础结构服务被一遍又一遍地重写。为什么会发生这种情况?因为现有的选择不够充分,或者因为定制版本要更好些或更适合手边的应用程序,但我认为这是不必要的。事实上,专为某个应用程序开发的定制版本常常并不比广泛可用的、通用的实现更适合于该应用程序,也许会更差。例如,尽管您不喜欢 log4j,但它可以完成任务。尽管自己开发的日志记录系统也许有一些 log4j 所缺乏的特定特性,但对于大多数应用程序,您很难证明,一个完善的定制日志记录包值得付出从头编写的代价,而不使用现有的、通用的实现。可是,许多项目团队最终还是自己一遍又一遍地编写日志记录、连接合用或线程调度包。

表面上看起来简单
我们不考虑自己去编写 XSL 处理器的原因之一是,这将花费大量的工作。但这些低级的框架服务表面上看起来简单,所以自己编写它们似乎并不困难。然而,它们很难正常工作,并不象开始看起来那样。这些特殊的“轮子”一直处在重复发明之中的主要原因是,在给定的应用程序中,往往一开始对这些工具的需求非常小,但当您遇到了无数其它项目中也存在的同样问题时,这种需求会逐渐变大。理由通常象这样:“我们不需要完善的日志记录/调度/高速缓存包,只需要一些简单的包,所以只编写一些能达到我们目的的包,我们将针对自己特定的需求来调整它”。但情况往往是,您很快扩展了所编写的这个简单工具,并试图添加再添加更多的特性,直到编写出一个完善的基础结构服务。至此,您通常会执著于自己所编写的程序,无论它是好是坏。您已经为构建自己的程序付出了全部的代价,所以除了转至通用的实现所实际投入的迁移成本之外,还必须克服这种“已支付成本”的障碍。

并发构件的价值所在
编写调度和并发基础结构类的确要比看上去难。Java 语言提供了一组有用的低级同步原语:wait()、 notify() 和 synchronized,但具体使用这些原语需要一些技巧,需要考虑性能、死锁、公平性、资源管理以及如何避免线程安全性方面带来的危害等诸多因素。并发代码难以编写,更难以测试 — 即使专家有时在第一次时也会出现错误。Concurrent Programming in Java(请参阅参考资料)的作者 Doug Lea 编写了一个极其优秀的、免费的并发实用程序包,它包括并发应用程序的锁、互斥、队列、线程池、轻量级任务、有效的并发集合、原子的算术操作和其它基本构件。人们一般称这个包为 util.concurrent(因为它实际的包名很长),该包将形成 Java Community Process JSR 166 正在标准化的 JDK 1.5 中 java.util.concurrent 包的基础。同时,util.concurrent 经过了良好的测试,许多服务器应用程序(包括 JBoss J2EE 应用程序服务器)都使用这个包。

填补空白
核心 Java 类库中略去了一组有用的高级同步工具(譬如互斥、信号和阻塞、线程安全集合类)。Java 语言的并发原语 — synchronization、wait() 和 notify() — 对于大多数服务器应用程序的需求而言过于低级。如果要试图获取锁,但如果在给定的时间段内超时了还没有获得它,会发生什么情况?如果线程中断了,则放弃获取锁的尝试?创建一个至多可有 N 个线程持有的锁?支持多种方式的锁定(譬如带互斥写的并发读)?或者以一种方式来获取锁,但以另一种方式释放它?内置的锁定机制不直接支持上述这些情形,但可以在 Java 语言所提供的基本并发原语上构建它们。但是这样做需要一些技巧,而且容易出错。

服务器应用程序开发人员需要简单的设施来执行互斥、同步事件响应、跨活动的数据通信以及异步地调度任务。对于这些任务,Java 语言所提供的低级原语很难用,而且容易出错。util.concurrent 包的目的在于通过提供一组用于锁定、阻塞队列和任务调度的类来填补这项空白,从而能够处理一些常见的错误情况或者限制任务队列和运行中的任务所消耗的资源。

调度异步任务
util.concurrent 中使用最广泛的类是那些处理异步事件调度的类。在本专栏七月份的文章中,我们研究了 thread pools and work queues,以及许多 Java 应用程序是如何使用“Runnable 队列”模式调度小工作单元。

可以通过简单地为某个任务创建一个新线程来派生执行该任务的后端线程,这种做法很吸引人:

new Thread(new Runnable() { ... } ).start();



虽然这种做法很好,而且很简洁,但有两个重大缺陷。首先,创建新的线程需要耗费一定资源,因此产生出许许多多线程,每个将执行一个简短的任务,然后退出,这意味着 JVM 也许要做更多的工作,创建和销毁线程而消耗的资源比实际做有用工作所消耗的资源要多。即使创建和销毁线程的开销为零,这种执行模式仍然有第二个更难以解决的缺陷 — 在执行某类任务时,如何限制所使用的资源?如果突然到来大量的请求,如何防止同时会产生大量的线程?现实世界中的服务器应用程序需要比这更小心地管理资源。您需要限制同时执行异步任务的数目。

线程池解决了以上两个问题 — 线程池具有可以同时提高调度效率和限制资源使用的好处。虽然人们可以方便地编写工作队列和用池线程执行 Runnable 的线程池(七月份那篇专栏文章中的示例代码正是用于此目的),但编写有效的任务调度程序需要做比简单地同步对共享队列的访问更多的工作。现实世界中的任务调度程序应该可以处理死线程,杀死超量的池线程,使它们不消耗不必要的资源,根据负载动态地管理池的大小,以及限制排队任务的数目。为了防止服务器应用程序在过载时由于内存不足错误而造成崩溃,最后一项(即限制排队的任务数目)是很重要的。

限制任务队列需要做决策 — 如果工作队列溢出,则如何处理这种溢出?抛弃最新的任务?抛弃最老的任务?阻塞正在提交的线程直到队列有可用的空间?在正在提交的线程内执行新的任务?存在着各种切实可行的溢出管理策略,每种策略都会在某些情形下适合,而在另一些情形下不适合。

Executor
Util.concurrent 定义一个 Executor 接口,以异步地执行 Runnable,另外还定义了 Executor 的几个实现,它们具有不同的调度特征。将一个任务排入 executor 的队列非常简单:

Executor executor = new QueuedExecutor();
...
Runnable runnable = ... ;
executor.execute(runnable);



最简单的实现 ThreadedExecutor 为每个 Runnable 创建了一个新线程,这里没有提供资源管理 — 很象 new Thread(new Runnable() {}).start() 这个常用的方法。但 ThreadedExecutor 有一个重要的好处:通过只改变 executor 结构,就可以转移到其它执行模型,而不必缓慢地在整个应用程序源码内查找所有创建新线程的地方。QueuedExecutor 使用一个后端线程来处理所有任务,这非常类似于 AWT 和 Swing 中的事件线程。QueuedExecutor 具有一个很好的特性:任务按照排队的顺序来执行,因为是在一个线程内来执行所有的任务,任务无需同步对共享数据的所有访问。

PooledExecutor 是一个复杂的线程池实现,它不但提供工作线程(worker thread)池中任务的调度,而且还可灵活地调整池的大小,同时还提供了线程生命周期管理,这个实现可以限制工作队列中任务的数目,以防止队列中的任务耗尽所有可用内存,另外还提供了多种可用的关闭和饱和度策略(阻塞、废弃、抛出、废弃最老的、在调用者中运行等)。所有的 Executor 实现为您管理线程的创建和销毁,包括当关闭 executor 时,关闭所有线程,另外还为线程创建过程提供了 hook,以便应用程序可以管理它希望管理的线程实例化。例如,这使您可以将所有工作线程放在特定的 ThreadGroup 中,或者赋予它们描述性名称。

FutureResult
有时您希望异步地启动一个进程,同时希望在以后需要这个进程时,可以使用该进程的结果。FutureResult 实用程序类使这变得很容易。FutureResult 表示可能要花一段时间执行的任务,并且可以在另一个线程中执行此任务,FutureResult 对象可用作执行进程的句柄。通过它,您可以查明该任务是否已经完成,可以等待任务完成,并检索其结果。可以将 FutureResult 与 Executor 组合起来;可以创建一个 FutureResult 并将其排入 executor 的队列,同时保留对 FutureResult 的引用。清单 1 显示了一个一同使用 FutureResult 和 Executor 的简单示例,它异步地启动图像着色,并继续进行其它处理:

清单 1. 运作中的 FutureResult 和 Executor Executor executor = ...
ImageRenderer renderer = ...

FutureResult futureImage = new FutureResult();
Runnable command = futureImage.setter(new Callable() {
public Object call() { return renderer.render(rawImage); }
});

// start the rendering process
executor.execute(command);

// do other things while executing
drawBorders();
drawCaption();

// retrieve the future result, blocking if necessary
drawImage((Image)(futureImage.get())); // use future



FutureResult 和高速缓存
还可以使用 FutureResult 来提高按需装入高速缓存的并发性。通过将 FutureResult 放置在高速缓存内,而不是放置计算本身的结果,可以减少持有高速缓存上写锁的时间。虽然这种做法不能加快第一个线程把某一项放入高速缓存,但它将减少第一个线程阻塞其它线程访问高速缓存的时间。它还使其它线程更早地使用结果,因为它们可以从高速缓存中检索 FutureTask。清单 2 显示了使用用于高速缓存的 FutureResult 示例:

清单 2. 使用 FutureResult 来改善高速缓存 public class FileCache {
private Map cache = new HashMap();
private Executor executor = new PooledExecutor();

public void get(final String name) {
FutureResult result;

synchronized(cache) {
result = cache.get(name);
if (result == null) {
result = new FutureResult();
executor.execute(result.setter(new Callable() {
public Object call() { return loadFile(name); }
}));
cache.put(result);
}
}
return result.get();
}
}

这种方法使第一个线程快速地进入和退出同步块,使其它线程与第一个线程一样快地得到第一个线程计算的结果,不可能出现两个线程都试图计算同一个对象。

结束语
util.concurrent 包包含许多有用的类,您可能认为其中一些类与您已编写的类一样好,也许甚至比从前还要好。它们是许多多线程应用程序的基本构件的高性能实现,并经历了大量测试。util.concurrent 是 JSR 166 的切入点,它将带来一组并发性的实用程序,这些实用程序将成为 JDK 1.5 中的 java.util.concurrent 包,但您不必等到那时侯才能使用它。在以后的文章中,我将讨论 util.concurrent 中一些定制的同步类,并研究 util.concurrent 和 java.util.concurrent API 中的不同之处。

本文地址:http://com.8s8s.com/it/it12801.htm