第七章 取消与关闭
7.1 任务取消
如果外部代码能在某个操作正常之前将其置入“完成”状态,那么这个操作就可以称为可取消的(Cancellable)。
取消某个操作的原因有很多:
用户请求取消
用户点击图形界面程序中的“取消”按钮,或者通过管理结构来发出取消请求,例如JMX(Java Management Extensions)
有时间限制的操作
例如,某个应用程序需要在有限时间内搜索问题空间,并在这个时间内选择最佳的解决方法。当计数器超时时,需要取消所有正在搜索的任务。
应用程序事件
例如,应用程序对某个问题空间进行分解并搜索,从而使不捅的任务可以搜索问题空间中的不同区域。当其中一个任务找到了解决方案时,所有其他仍在搜索额任务都将被取消。
错误
网页爬虫程序搜索相关的页面,并将页面或摘要数据保存到硬盘。当一个爬虫任务发生错误时(例如,磁盘空间已满),那么所有搜索任务都会取消,此时可能会记录它们的当前状态,以便稍后重新启动。
关闭
当一个程序或服务关闭时,必须对正在处理和等待处理的工作执行某种操作。在平缓的关闭过程中,当前正在执行的任务将继续执行直到完成,而在立即关闭过程中,当前的任务则可能取消。
Java中没有一种安全的抢占式方法来停止线程,也就没有安全的抢占式方法来停止任务。只有一些协作式的机制,使请求取消的任务和代码都遵循一种协商好的协议。
其中一种协作机制能设置某个“已请求取消(Cancellation Requested)标志”,而任务将定期地查看这个标志。如果设置了这个标志,那么任务将提前结束。
下面程序就使用了这种技术,其中的PrimeGenerator持续地枚举素数,直到它被取消。
程序清单7-1
@ThreadSafe
public class PrimeGenerator implements Runnable {
private static ExecutorService exec = Executors.newCachedThreadPool();
@GuardedBy("this") private final List primes
= new ArrayList();
private volatile boolean cancelled;
public void run() {
BigInteger p = BigInteger.ONE;
while (!cancelled) {
p = p.nextProbablePrime();
synchronized (this) {
primes.add(p);
}
}
}
public void cancel() {
cancelled = true;
}
public synchronized List get() {
return new ArrayList(primes);
}
}
程序清单7-2给出了这个类的使用示例,即让素数生成器运行1秒后取消。素数生成器通常不会在刚好一秒后停止,因为在请求取消的时刻和run方法中循环执行下一次检查之间可能存在延迟。cancel方法由finally块调用,确保即使在调用sleep时被中断也能取消素数生成器的执行。如果cancel没有被调用,那么搜索素数的线程将永远运行下去,不断消耗CPU的时钟周期,并使得JVM不能正常退出。
程序清单7-2
static List aSecondOfPrimes() throws InterruptedException {
PrimeGenerator generator = new PrimeGenerator();
exec.execute(generator);
try {
SECONDS.sleep(1);
} finally {
generator.cancel();
}
return generator.get();
}
一个可取消的任务必须拥有取消策略(Cancellation Policy)。
PrimeGenerator使用了一种简单的取消策略:客户代码通过调用cancel来请求取消,PrimeGenerator在每次搜索素数之前都首先检查是否存在取消请求,如果存在则退出。
7.1.1 中断
rimeGenerator中的取消机制最终会使得搜索素数的任务退出,但在退出过程中需要花费一定的时间。然而, 如果使用这种方法的任务调用了一个阻塞方法,例如BlockingQueue.put,那么可能会产生一个更严重的问题——任务可能永远不会检查取消标志,因此永远不会结束。
下面的BrokenPrimeGenerator说明了这个问题。生产者线程生成素数,并将它们放入一个阻塞队列。如果生成者的速度超过了消费者的处理速度, 队列将被填满,put方法也会阻塞。当生产者在put方法中阻塞时,如果消费者希望取消生产者任务,它可以调用cancel方法来设置cancelled标志,但此时生产者却永远不能检查这个标志,因为它无法从阻塞的put方法中恢复过来(因为消费者此时已经停止从队列中取出素数,所以put方法将一直保持阻塞状态。)
程序清单7-3
class BrokenPrimeProducer extends Thread {
private final BlockingQueue queue;
private volatile boolean cancelled = false;
BrokenPrimeProducer(BlockingQueue queue) {
this.queue = queue;
}
public void run() {
try {
BigInteger p = BigInteger.ONE;
while (!cancelled)
queue.put(p = p.nextProbablePrime());
} catch (InterruptedException consumed) {
}
}
public void cancel() {
cancelled = true;
}
}
一种特殊的方法是支持中断。线程中断是一种协作机制,线程可以通过这个机制来通知另一个线程,告诉它在合适的或可能的情况下停止当前工作,并转而执行其他的工作。
如果在取消之外的其他操作中使用了中断,那么都是不合适的,并且很难支撑起更大应用。
每个线程都有一个boolean类型的中断状态。当中断线程时,这个线程的中断状态将被设置为true。
在Thread中包含了中断线程以及查询线程中断状态的方法,如下:
- interrupt方法能中断目标线程
- isInterrupt方法能返回目标线程的中断状态
- 静态的interrupted方法将清除当前线程的中断状态,并返回它之前的值,这也是清除中断状态的唯一方法。
程序清单7-4
public class Thread {
//interrupt方法能中断目标线程
public void interrupt() { ... }
//isInterrupt方法能返回目标线程的中断状态
public boolean isInterrupted() { ... }
//静态的interrupted方法将清除当前线程的中断状态,并返回它之前的值,这也是清除中断状态的唯一方法。
public static boolean interrupted() { ... }
...
}
阻塞库方法,例如Thread.sleep和Object.wait,都会检查线程何时中断,并且在发现中断时提前返回。它们在响应中断时的操作包括,清除中断状态,抛出InterruptedException,表示阻塞操作由于中断而提前结束。
当线程在非阻塞状态下中断时,它的中断状态将被设置,然后根据被取消的操作来检查中断状态以及发生了中断,通过这样的方法,中断操作将变得“有黏性”——如果不能触发InterruptedException,那么中断状态将一直保持,直到明确地清除中断状态。
调用Interrupt并不意味着立即停止目标线程正在进行的工作,而只是传递了请求中断的消息。
对中断操作的正确理解是:它并不会真正中断一个正在运行的线程,而只是发出中断骑牛,然后在线程下一个适合的时刻中断自己。(这个时刻被称为取消点)。
有些方法,如wait,sleep和join等,将严格地处理这种请求,当它们收到中断请求或者在开始执行时发现某个已被设置好的中断状态时,将抛出一个异常。
在使用静态的interrupted时应该小心,因为它会清除当前线程的中断状态。如果在调用interrupted时返回了true,那么除非你想屏蔽这个中断,否则你必须对它进行处理——可以抛出InterruptedException,或者通过再次调用interrupt来恢复中断状态。
通常,中断是实现取消的最合适方法。
在每次迭代循环中,有两个位置可以检测出中断:在阻塞的put方法中调用,以及在循环开始处查询中断状态。
由于调用了阻塞的put方法,因此这里不一定需要显式的检测,但执行检测会使PrimeProducer对中断具有更高的响应性,因为它是在启动寻找素数任务之前检测中断的,而不是在任务完成后。 如果可中断的阻塞方法的调用频率不高,不足以获得足够的响应性,那么显式地检测中断状态能起到一定的帮助作用。
程序清单7-5 通过中断来取消
public class PrimeProducer extends Thread {
private final BlockingQueue queue;
PrimeProducer(BlockingQueue queue) {
this.queue = queue;
}
public void run() {
try {
BigInteger p = BigInteger.ONE;
while (!Thread.currentThread().isInterrupted())
queue.put(p = p.nextProbablePrime());
} catch (InterruptedException consumed) {
/* Allow thread to exit */
}
}
public void cancel() {
interrupt();
}
}
7.1.2 中断策略
最合理的中断策略是某个形式的线程级(Thread-Level)取消操作或服务级(Service-Level)取消操作:尽快退出,在必要时进行清理,通知某个所有者该线程已经退出。
此外还可以奖励其他的中断策略,例如暂停服务或重新开始服务,但对于那些包含非标准中断策略的线程或线程池,只能用于能知道这些策略的任务中。
一个中断请求可以有一个或多个接受者——中断线程池中的某个工作者线程,同时意味者“取消当前任务”和“关闭工作者线程”。
任务不会在其自己拥有的线程中执行,而是在某个服务(例如线程池)拥有的线程中执行。
对于非线程所有者的代码来说(例如,对于线程池而言,任何在线程池实现以外的代码),应该小心地保存中断状态,这样拥有线程的代码才能对中断做出响应,即使“非所有者”代码也可以做出响应。
这就是为什么大多数可阻塞的库函数都只是抛出InterruptedException作为中断响应。它们永远不会在某个由自己拥有的线程中运行,因此它们为任务或库代码实现了最合理的取消策略:尽快退出流程,并把中断信息传递给调用者,从而使调用栈中的上层代码可以采取进一步的操作。
当检查到中断请求时,任务并不需要放弃所有的操作——它可以推迟处理中断请求,并直到某个更合适的时刻。因此需要记住中断请求,并在完成当前任务后抛出InterruptedException或表示已收到中断状态,这项技术能够确保在更新过程中发生中断时,数据结构不会被破坏。
任务不应该对执行该任务的线程的中断策略做出任何假设,除非该任务被专门设计为服务中运行,并且在这些服务中抱哈特定的中断策略。
无论任务将中断视为取消,还是其他某个中断响应操作,都应该小心地保存执行线程的中断状态。
如果除了将InterruptedException传递给调用者外需要执行其他操作,那么应该在捕获InterruptedException之后恢复中断状态:
Thread.currentThread.interrupt();
执行取消操作的代码也不应该对线程的中断策略做出假设,线程应该只能由所有者中断,所有者可以将线程的中断策略信息想封装到某个合适的取消机制中,例如shutdown方法。
由于每个线程拥有各自的中断策略,因此除非你知道中断对该线程的含义,否则就不应该中断这个线程。
7.1.3 响应中断
在5.4中,当调用可中断的阻塞函数时,例如Thread.sleep或Blocking.put等,有两种实用策略可用于处理InterruptedException:
- 传递异常(可能在某个特定于任务额清除操作之后),从而使你的方法也称为可中断的阻塞方法。
- 恢复中断状态,从而使调用栈上的上层代码能够对其进行处理。
传递InterruptedException如下:
程序清单7-6
BlockingQueue queue;
...
public Task getNextTask() throws InterruptedException {
return queue.take();
}
保存中断请求,一种标注你的方法就是通过再次调用interrupt来恢复中断状态。
只有实现了线程中断策略的代码才可以屏蔽中断请求,在常规的任务和库代码中都不应该屏蔽中断请求。
对于一些不支持取消但仍可以调用可中断阻塞方法的操作,它们必须在循环中调用这些方法,并在发现中断后重新尝试。在这种情况下,它们应该在本地保存中断状态,并在返回前恢复状态而不是在捕获InterruptedException时恢复状态,如程序清单7-7所示
程序清单7-7
public class NoncancelableTask {
public Task getNextTask(BlockingQueue queue) {
boolean interrupted = false;
try {
while (true) {
try {
return queue.take();
} catch (InterruptedException e) {
interrupted = true;
// fall through and retry
}
}
} finally {
if (interrupted)
Thread.currentThread().interrupt();
}
}
interface Task {
}
}
如果代码不会调用可中断的阻塞方法,那么仍然可以通过在任务代码中轮询当前线程的中断状态来响应中断。
例如,当一个由ThreadPoolExecutor拥有的工作者线程检测到中断时,它会检查线程池是否正在关闭,如果是,它会在结束之前执行一些线程池清理工作,否则它可能创建一个新线程将线程池恢复到合理的规模。
7.1.4 示例:计时运行
如果枚举所有的素数,可能永远也得不到答案,在这种情况下, 如果指定最多花10分钟进行枚举,那么就是可行的。
下面程序给定了在指定时间内运行一个任意的Runnable的示例。
程序清单7-8 在外部线程中安排中断(不要这么做)
public class TimedRun1 {
private static final ScheduledExecutorService cancelExec = Executors.newScheduledThreadPool(1);
public static void timedRun(Runnable r,
long timeout, TimeUnit unit) {
final Thread taskThread = Thread.currentThread();
cancelExec.schedule(new Runnable() {
public void run() {
taskThread.interrupt();
}
}, timeout, unit);
r.run();
}
}
这种做法非常简单,但是破坏了以下规则:在中断线程之前,应该了解它的中断策略。
而且,如果任务不响应中断,那么timedRun会在任务结束时才返回,此时可能已经超过了指定的时限(或者还没有超过时限)。
程序清单7-9解决了之前方案中的问题
程序清单7-9 在专门的线程中中断任务
public class TimedRun2 {
private static final ScheduledExecutorService cancelExec = newScheduledThreadPool(1);
public static void timedRun(final Runnable r,
long timeout, TimeUnit unit)
throws InterruptedException {
class RethrowableTask implements Runnable {
private volatile Throwable t;
public void run() {
try {
r.run();
} catch (Throwable t) {
this.t = t;
}
}
void rethrow() {
if (t != null)
throw launderThrowable(t);
}
}
RethrowableTask task = new RethrowableTask();
final Thread taskThread = new Thread(task);
taskThread.start();
cancelExec.schedule(new Runnable() {
public void run() {
taskThread.interrupt();
}
}, timeout, unit);
taskThread.join(unit.toMillis(timeout));
task.rethrow();
}
}
该例子的不足是,它依赖于一个限时的join,因此无法知道执行控制是因为线程正常退出而返回还是因为join超时而返回。
7.1.5 通过Future来实现取消
ExecutorSerivce.submit将返回一个Future来描述任务。Future拥有一个cancel方法,该方法带有一个boolean mayInterruputIfRunning参数,表示取消操作是否成功,它只表示任务是否能够接受中断,而不是表示任务是否能检测并处理中断。如果true并且任务当前正在某个线程中运行,那么这个线程能被中断,如果false,那么意味着若任务还没有启动,就不要运行它,这适用于不处理中断的任务中。
除非你清楚线程的中断策略,否则不要中断线程。那么在什么情况下调用cancel可以将参数指定为true? 执行任务的线程是由标准的Executor创建的,它实现了一种中断策略使得任务可以通过中断被取消 2任务在标准Executor中运行,并通过它们的Future来取消任务,而不是中断线程池
程序清单7-10
public class TimedRun {
private static final ExecutorService taskExec = Executors.newCachedThreadPool();
public static void timedRun(Runnable r,
long timeout, TimeUnit unit)
throws InterruptedException {
Future> task = taskExec.submit(r);
try {
task.get(timeout, unit);
} catch (TimeoutException e) {
// task will be cancelled below
} catch (ExecutionException e) {
// exception thrown in task; rethrow
throw launderThrowable(e.getCause());
} finally {
// Harmless if task already completed
task.cancel(true); // interrupt if running
}
}
}
当Future.get抛出InterruptedException或TimeoutException时,如果你知道不再需要结果,那么就可以调用Future.cancel来取消任务。
7.1.6 处理不可中断的阻塞
许多可阻塞方法都是提前返回或者抛出InterruptedException来响应中断请求,然而并非所有的可阻塞方法或者机制都是能响应中断,对于特殊的不可中断操作而被阻塞的线程,具体阻塞原因具体分析,可类似中断的手段来停止这些线程。
常见的不可中断而阻塞的情行:
- java.io包中同步Socket I/O:在最常见的阻塞I/O形式就是对套接字的读写,虽然InputStream和OutputStream的read和write方法都不会响应中断,但是可以通过关闭底层的套接字,可以使执行read或者write方法而阻塞的线程抛出一个SocketException。
- java.io包中同步 I/O:当中断一个正在InterruptibleChannel上等待的线程时,将抛出ClosedByInterruptException并关闭链路(这回使其他在这条链路上阻塞的线程同样抛出ClosedByInterruptException)。当关闭一个InterruptibleChannel时,将导致所有在此链路上阻塞的线程都抛出AsynchronousCloseException,大多数标准的Channel都实现了InterruptibleChannel。
- Selector的异步I/O:如果一个线程在调用Selector.select方法(在java.nio.channels中)时阻塞了,那么调用close或者wakeup方法会使线程抛出ClosedSelectorException并提前返回。
- 等待内置锁:若一个线程由于等待某个内置锁而阻塞,那么将无法响应中断,因为线程认为它肯定会获得锁,所以它不理会中断请求。
对于同步Socket阻塞情形,重新重写Thread的interrupt方法,即可关闭套接字从而中断socket阻塞,也可中断线程。
程序清单7-11
public class ReaderThread extends Thread {
private static final int BUFSZ = 512;
private final Socket socket;
private final InputStream in;
public ReaderThread(Socket socket) throws IOException {
this.socket = socket;
this.in = socket.getInputStream();
}
public void interrupt() {
try {
socket.close();
} catch (IOException ignored) {
} finally {
super.interrupt();
}
}
public void run() {
try {
byte[] buf = new byte[BUFSZ];
while (true) {
int count = in.read(buf);
if (count < 0)
break;
else if (count > 0)
processBuffer(buf, count);
}
} catch (IOException e) { /* Allow thread to exit */
}
}
public void processBuffer(byte[] buf, int count) {
}
}
7.1.7 采用newTaskFor来封装非标准的取消
我们可以通过newTaskFor方法来进一步优化ReaderThread中封装非标准取消的技术,这是Java6在ThreadPoolExecutor中的新增功能。
程序清单7-12
public abstract class SocketUsingTask implements CancellableTask {
@GuardedBy("this") private Socket socket;
protected synchronized void setSocket(Socket s) {
socket = s;
}
public synchronized void cancel() {
try {
if (socket != null)
socket.close();
} catch (IOException ignored) {
}
}
public RunnableFuture newTask() {
return new FutureTask(this) {
public boolean cancel(boolean mayInterruptIfRunning) {
try {
SocketUsingTask.this.cancel();
} finally {
return super.cancel(mayInterruptIfRunning);
}
}
};
}
}
interface CancellableTask extends Callable {
void cancel();
RunnableFuture newTask();
}
@ThreadSafe
class CancellingExecutor extends ThreadPoolExecutor {
public CancellingExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue) {
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
}
public CancellingExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, ThreadFactory threadFactory) {
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory);
}
public CancellingExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, RejectedExecutionHandler handler) {
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, handler);
}
public CancellingExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) {
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory, handler);
}
protected RunnableFuture newTaskFor(Callable callable) {
if (callable instanceof CancellableTask)
return ((CancellableTask) callable).newTask();
else
return super.newTaskFor(callable);
}
}
7.2 停止基于线程的服务
除非拥有某个线程,否则不能对该线程进行操控。线程有一个相应的所有者,即创建该线程的类。因此线程池是其工作线程的所有者,如果要中断这些线程,那么应该使用线程池。
对于持有线程的服务,只要服务的存在时间大于创建线程的方法的存在时间,那么就应该提供生命周期方法。
在ExecutorService中提供了shutdown和shutdownNow等方法。
7.2.1 示例:日志服务
在大多数服务器应用程序中都会用到日志,例如,在代码中插入println语句就是一种简单的日志。
在11.6节中,我们将看到这种内联日志功能会给一些高容量的(highvolume)应用程序带来一定的性能开销。另一种替代方法是通过调用log(日志)方法将日志消息放入某个队列中,并由其他线程来处理。
LogWriter给出了一个简单的日志服务示例,其中日志操作在单独的日志线程中执行。产生日志消息的线程并不会将消息直接写入输出流,而是由LogWriter通过BlockingQueue将消息提交给日志线程,由日志线程写入。这是一种多生产者但消费者(Multiple-Producer,Single-Consumer)的设计方式:每个调用log的操作都相当于一个生成者,而后台的日志线程则相当于消费者。如果消费者的处理速度低于生产者的生成速度,那么BlockingQueue将阻塞生产者,直到日志线程有能力处理新的日志服务。
程序清单7-13
public class LogWriter {
private final BlockingQueue queue;
private final LoggerThread logger;
private static final int CAPACITY = 1000;
public LogWriter(Writer writer) {
this.queue = new LinkedBlockingQueue(CAPACITY);
this.logger = new LoggerThread(writer);
}
public void start() {
logger.start();
}
public void log(String msg) throws InterruptedException {
queue.put(msg);
}
private class LoggerThread extends Thread {
private final PrintWriter writer;
public LoggerThread(Writer writer) {
this.writer = new PrintWriter(writer, true); // autoflush
}
public void run() {
try {
while (true)
writer.println(queue.take());
} catch (InterruptedException ignored) {
} finally {
writer.close();
}
}
}
}
为了使LoggerWriter这样的服务在软件产品中能发挥实际的作用,还需要实现一种终止日志线程的方法,从而避免使JVM无法正常关闭。
要停止日志线程是很容易的,因为它会反复调用take,而take能响应中断。如果将日志线程修改为当捕获到InterruptedException时退出,那么只需中断日志线程就能停止服务。然而,如果过只是时日志线程退出,还不是一种完备的关闭机制。这种直接关闭的做法会丢失那些正在等待被写入到日志的信息,不仅如此,其他线程将在调用log时被阻塞,因为日志消息队列是满的(日志线程停止了take),因此这些线程将无法解除阻塞状态。当取消一个生产者-消费者操作时,需要同时取消生产者和消费者,在中断日志线程过程时会处理消费者,但在这个示例中,由于生产者不是专门的线程,因此要取消它们将非常困难。
另一种关闭LogWriter的方法是:设置某个“已请求关闭”标志(与前面的已请求取消标志类似),避免进一步提交日志信息。
如7-14所示,在收到关闭请求后,消费者会把队列中的所有信息系写入日志,并解除所有在调用log时阻塞的生产者。然而,在这个方法中存在竞态条件问题,使得该方法并不可靠。log的实现是一种“先检查后执行”的代码序列:生产者发现服务还没有关闭,因此在关闭服务后仍然会把日志信息放入队列,这同样会使得生产者可能在调用log时阻塞并且无法解除阻塞状态(即检查的时候还未停止,而put的时候停止了,这个时候将会阻塞)。
程序清单7-14
public void log(String msg) throws InterruptedException {
if(!shutdownRequested)
queue.put(msg);
else
throw new IllegalStateException("logger is shut down");
}
可以通过一些技巧来降低这些情况发生的概率(例如,在宣布队列被情况之前,让消费这等待数秒),但仍没有解决问题的本质。
为LogWriter提供可靠关闭操作的方法是解决竞态条件问题,因而要使日志消息的提交操作成为原子操作。然而,我们不希望在消息加入队列时去持有一个锁,因为put方法本省就可以阻塞。我们采用的方法是,通过原子方式来检查关闭请求,并且有条件地递增一个计数器来“保持”提交信息的权利。
程序清单7-15
public class LogService {
private final BlockingQueue queue;
private final LoggerThread loggerThread;
private final PrintWriter writer;
@GuardedBy("this") private boolean isShutdown;
@GuardedBy("this") private int reservations;
public LogService(Writer writer) {
this.queue = new LinkedBlockingQueue();
this.loggerThread = new LoggerThread();
this.writer = new PrintWriter(writer);
}
public void start() {
loggerThread.start();
}
public void stop() {
synchronized (this) {
isShutdown = true;
}
loggerThread.interrupt();
}
public void log(String msg) throws InterruptedException {
synchronized (this) {
if (isShutdown)
throw new IllegalStateException(/*...*/);
++reservations;
}
queue.put(msg);
}
private class LoggerThread extends Thread {
public void run() {
try {
while (true) {
try {
synchronized (LogService.this) {
if (isShutdown && reservations == 0)
break;
}
String msg = queue.take();
synchronized (LogService.this) {
--reservations;
}
writer.println(msg);
} catch (InterruptedException e) { /* retry */
}
}
} finally {
writer.close();
}
}
}
}
7.2.2 关闭ExecutorService
(6.2.4节中)ExecutorService提供了两种关闭方法:使用shutdown正常关闭,以及使用shutdownNow强行关闭。在进行强行关闭时,shutdownNow首先关闭当前正在执行的任务,然后返回所有尚未启动的任务清单。
这两种方法的差别性在于各自的安全性和响应性:强行关闭的速度更快,但风险也更大,因为任务很可能在执行到一半的时候被借宿;而正常关闭虽然速度慢,但却更安全,因为ExecutorService会一直等待队列中的所有任务都执行完成后才关闭。
简单的程序可以直接在main函数中启动和关闭全局的ExecutorService。而在复杂程序中,通常会将ExecutorService封装在某个更高级的服务中,并且该服务能提供自己的生命周期方法,例如下面LogService的一种,它将管理线程的工作委托给一个ExecutorService,而不是自行管理。通过封装ExecutorService,可以将所有权链(Ownership Chain)从应用程序扩展到服务以及线程,所有权链上的各个成员都将管理它所拥有的服务或线程的生命周期。
程序清单 7-16
public class LogService {
//将管理线程的工作委托给一个ExecutorService,而不是自行管理
private final ExecutorService exec=newSingleThreadExecutor();
private final PrintWriter writer;
//...
public void start(){
}
public void stop()throws InterruptedException{
try{
exec.shutdown();
//再接受到shutdown请求后会等待任务完成或超时, 或者并发线程被中断
exec.awaitTermination(TIMEOUT, UNIT);
}finally {
writer.close();
}
}
public void log(String msg){
try{
exec.execute(new WriteTask(msg));
}catch (RejectedExecutionException ignored) {
// TODO: handle exception
}
}
}
7.2.3 "毒丸"对象
另一种关闭生产者-消费者服务的方式就是使用“毒丸(Poison Pills)对象”。
“毒丸”是指一个放在队列上的对象,其含义是:“当得到这个对象时,立即停止”。
在FIFO队列中,“毒丸”对象将确保消费者在关闭之前首先完成队列中的所有工作,在提交“毒丸”对象之前提交的所有工作都会被处理,而生产者提交了“毒丸”对象后,将不会再提价任何工作。(这一点和ExecutorService的shutdown方法类似)
在这个实例中使用“毒丸”对象来关闭服务
程序清单7-17 通过“毒丸”对象来关闭服务
public class IndexingService {
private static final int CAPACITY = 1000;
private static final File POISON = new File("");
private final IndexerThread consumer = new IndexerThread();
private final CrawlerThread producer = new CrawlerThread();
private final BlockingQueue queue;
private final FileFilter fileFilter;
private final File root;
public IndexingService(File root, final FileFilter fileFilter) {
this.root = root;
this.queue = new LinkedBlockingQueue(CAPACITY);
this.fileFilter = new FileFilter() {
public boolean accept(File f) {
return f.isDirectory() || fileFilter.accept(f);
}
};
}
private boolean alreadyIndexed(File f) {
return false;
}
class CrawlerThread extends Thread {
public void run() {
try {
crawl(root);
} catch (InterruptedException e) { /* fall through */
} finally {
while (true) {
try {
queue.put(POISON);
break;
} catch (InterruptedException e1) { /* retry */
}
}
}
}
private void crawl(File root) throws InterruptedException {
File[] entries = root.listFiles(fileFilter);
if (entries != null) {
for (File entry : entries) {
if (entry.isDirectory())
crawl(entry);
else if (!alreadyIndexed(entry))
queue.put(entry);
}
}
}
}
class IndexerThread extends Thread {
public void run() {
try {
while (true) {
File file = queue.take();
if (file == POISON)
break;
else
indexFile(file);
}
} catch (InterruptedException consumed) {
}
}
public void indexFile(File file) {
/*...*/
};
}
public void start() {
producer.start();
consumer.start();
}
public void stop() {
producer.interrupt();
}
public void awaitTermination() throws InterruptedException {
consumer.join();
}
}
程序清单7-18 IndexingService的生产者线程
public class IndexingService {
private static final int CAPACITY = 1000;
private static final File POISON = new File("");
private final IndexerThread consumer = new IndexerThread();
private final CrawlerThread producer = new CrawlerThread();
private final BlockingQueue queue;
private final FileFilter fileFilter;
private final File root;
public IndexingService(File root, final FileFilter fileFilter) {
this.root = root;
this.queue = new LinkedBlockingQueue(CAPACITY);
this.fileFilter = new FileFilter() {
public boolean accept(File f) {
return f.isDirectory() || fileFilter.accept(f);
}
};
}
private boolean alreadyIndexed(File f) {
return false;
}
class CrawlerThread extends Thread {
public void run() {
try {
crawl(root);
} catch (InterruptedException e) { /* fall through */
} finally {
while (true) {
try {
queue.put(POISON);
break;
} catch (InterruptedException e1) { /* retry */
}
}
}
}
private void crawl(File root) throws InterruptedException {
File[] entries = root.listFiles(fileFilter);
if (entries != null) {
for (File entry : entries) {
if (entry.isDirectory())
crawl(entry);
else if (!alreadyIndexed(entry))
queue.put(entry);
}
}
}
}
class IndexerThread extends Thread {
public void run() {
try {
while (true) {
File file = queue.take();
if (file == POISON)
break;
else
indexFile(file);
}
} catch (InterruptedException consumed) {
}
}
public void indexFile(File file) {
/*...*/
};
}
public void start() {
producer.start();
consumer.start();
}
public void stop() {
producer.interrupt();
}
public void awaitTermination() throws InterruptedException {
consumer.join();
}
}
程序清单7-19 IndexingService的消费者线程
public class IndexingService {
private static final int CAPACITY = 1000;
private static final File POISON = new File("");
private final IndexerThread consumer = new IndexerThread();
private final CrawlerThread producer = new CrawlerThread();
private final BlockingQueue queue;
private final FileFilter fileFilter;
private final File root;
public IndexingService(File root, final FileFilter fileFilter) {
this.root = root;
this.queue = new LinkedBlockingQueue(CAPACITY);
this.fileFilter = new FileFilter() {
public boolean accept(File f) {
return f.isDirectory() || fileFilter.accept(f);
}
};
}
private boolean alreadyIndexed(File f) {
return false;
}
class CrawlerThread extends Thread {
public void run() {
try {
crawl(root);
} catch (InterruptedException e) { /* fall through */
} finally {
while (true) {
try {
queue.put(POISON);
break;
} catch (InterruptedException e1) { /* retry */
}
}
}
}
private void crawl(File root) throws InterruptedException {
File[] entries = root.listFiles(fileFilter);
if (entries != null) {
for (File entry : entries) {
if (entry.isDirectory())
crawl(entry);
else if (!alreadyIndexed(entry))
queue.put(entry);
}
}
}
}
class IndexerThread extends Thread {
public void run() {
try {
while (true) {
File file = queue.take();
if (file == POISON)
break;
else
indexFile(file);
}
} catch (InterruptedException consumed) {
}
}
public void indexFile(File file) {
/*...*/
};
}
public void start() {
producer.start();
consumer.start();
}
public void stop() {
producer.interrupt();
}
public void awaitTermination() throws InterruptedException {
consumer.join();
}
}
7.2.4 示例:只执行一次的服务
如果某个方法需要处理一批任务,并且当所有任务都处理完成后才返回,那么可以通过一个私有的Executor来简化服务的生命周期管理,其中该Executor的生命周期是由这个方法来控制的。(这种情况下,invokeAll和invokeAny作用较大)
checkMail方法能在多台主机上并行地检查新邮件。它创建一个私有的Executor,并向每台主机提交一个任务。然后,当所有邮件检查任务都执行完毕后,关闭Executor并等待结束。
程序清单7-20
public class CheckForMail {
public boolean checkMail(Set hosts, long timeout, TimeUnit unit)
throws InterruptedException {
ExecutorService exec = Executors.newCachedThreadPool();
final AtomicBoolean hasNewMail = new AtomicBoolean(false);
try {
for (final String host : hosts)
exec.execute(new Runnable() {
public void run() {
if (checkMail(host))
hasNewMail.set(true);
}
});
} finally {
exec.shutdown();
exec.awaitTermination(timeout, unit);
}
return hasNewMail.get();
}
private boolean checkMail(String host) {
// Check for mail
return false;
}
}
7.2.5 shutdownNow的局限性
当通过shutdownNow来强行关闭ExecutorService时,它会尝试取消正在执行的任务,并返回所有已提交但尚未开始的任务,从而将这些任务写入日志或者保存起来以便之后进行处理。
然而,我们无法在关闭中知道正在执行的任务的状态,除非任务本身会执行某种检查。
在TrackingExecutor中给出了如何在关闭过程中判断正在执行的任务。通过封装ExecutorService并使得execute(类似地还有submit)记录那些任务是关闭后取消的,TrackingExecutor可以找出那些任务已经开始但还没有正常完成。在Executor结束后,getCancelledTasks返回被取消的任务清单。
程序清单7-21
public class TrackingExecutor extends AbstractExecutorService {
private final ExecutorService exec;
private final Set tasksCancelledAtShutdown =
Collections.synchronizedSet(new HashSet());
public TrackingExecutor(ExecutorService exec) {
this.exec = exec;
}
public void shutdown() {
exec.shutdown();
}
public List shutdownNow() {
return exec.shutdownNow();
}
public boolean isShutdown() {
return exec.isShutdown();
}
public boolean isTerminated() {
return exec.isTerminated();
}
public boolean awaitTermination(long timeout, TimeUnit unit)
throws InterruptedException {
return exec.awaitTermination(timeout, unit);
}
public List getCancelledTasks() {
if (!exec.isTerminated())
throw new IllegalStateException(/*...*/);
return new ArrayList(tasksCancelledAtShutdown);
}
public void execute(final Runnable runnable) {
exec.execute(new Runnable() {
public void run() {
try {
runnable.run();
} finally {
if (isShutdown()
&& Thread.currentThread().isInterrupted())
tasksCancelledAtShutdown.add(runnable);
}
}
});
}
}
在WebCrawler中给出了TrackingExecutor的用法。网页爬虫程序的工作通常是无穷尽的,因此当爬虫程序必须关闭时,我们通常希望保存它的状态,以便稍后重新启动。CrawklTask提供了一个getPage方法,该方法能找出正在处理的页面,当爬虫程序关闭时,无论是还没有开始的任务,还是那些被取消的任务,都将记录它们的URL,因此当爬虫程序重新启动时,可以将这些URL的页面抓取任务加入到任务队列中。
程序清单7-22
public abstract class WebCrawler {
private volatile TrackingExecutor exec;
@GuardedBy("this") private final Set urlsToCrawl = new HashSet();
private final ConcurrentMap seen = new ConcurrentHashMap();
private static final long TIMEOUT = 500;
private static final TimeUnit UNIT = MILLISECONDS;
public WebCrawler(URL startUrl) {
urlsToCrawl.add(startUrl);
}
public synchronized void start() {
exec = new TrackingExecutor(Executors.newCachedThreadPool());
for (URL url : urlsToCrawl) submitCrawlTask(url);
urlsToCrawl.clear();
}
public synchronized void stop() throws InterruptedException {
try {
saveUncrawled(exec.shutdownNow());
if (exec.awaitTermination(TIMEOUT, UNIT))
saveUncrawled(exec.getCancelledTasks());
} finally {
exec = null;
}
}
protected abstract List processPage(URL url);
private void saveUncrawled(List uncrawled) {
for (Runnable task : uncrawled)
urlsToCrawl.add(((CrawlTask) task).getPage());
}
private void submitCrawlTask(URL u) {
exec.execute(new CrawlTask(u));
}
private class CrawlTask implements Runnable {
private final URL url;
CrawlTask(URL url) {
this.url = url;
}
private int count = 1;
boolean alreadyCrawled() {
return seen.putIfAbsent(url, true) != null;
}
void markUncrawled() {
seen.remove(url);
System.out.printf("marking %s uncrawled%n", url);
}
public void run() {
for (URL link : processPage(url)) {
if (Thread.currentThread().isInterrupted())
return;
submitCrawlTask(link);
}
}
public URL getPage() {
return url;
}
}
}
在TrackingExecutor中存在一个不可避免的竞态条件,从而产生“误报”问题:一些被任务已经取消的任务实际上已经完成。
这个问题的原因在于,在任务执行最后一条指令以及线程池将任务记录为“结束”的这两个时刻之间,线程池可能被关闭。如果任务是幂等的(Idempotent,即将任务执行两次与一次会得到同样的结果),那么不存在问题,网页爬虫程序中就是这种情况。否则必须考虑这种风险,并对“误报”问题做好准备。
7.3 处理非正常的线程终止
当单线程的控制台程序由于发生了一个未捕获的异常而终止时,程序将停止运行,并产生与程序正常输出非常不同的栈追踪信息。
然而,如果并发程序中的某个线程发生故障,那么通过不会那么明显。在控制台中可能会输出栈追踪信息,但没有人会观察控制台。此外,当线程发生故障时,应用程序可能看起来仍在工作,所以这个失败很可能被忽略。幸运的是,我们有可以监测并防止在程序中“遗漏”线程的方法。
导致线程提前死亡的最主要原因就是RuntimeException。由于这些异常表现除了某种编程错误或者其他不可修复的错误,因此它们通常不被捕获。它们不会在调用栈中逐层传递,而是默认地在控制台中输出栈追踪信息,并终止线程。
线程非正常退出的后果可能是良性的,也可能是恶心的,这取决与线程在应用程序中的作用。包含50个线程的线程池丢失一个线程,程序也能运行良好。
然而,如果在GUI程序中丢失了事件分派线程,那么造成的影响将非常显著——应用程序将停止处理事件并且GUI会因此失去响应。(第6章的OutOfTime给出了由于遗漏线程而造成的严重后果:Timer表示的服务将永远无法使用)。
在任务处理线程(例如线程池中的工作者线程或者Swing的事件派发线程等)的生命周期中,将通过某种抽象机制(例如Runnable)来调用许多未知的代码,应该对在这些线程中的代码能否表现出正常的行为保持怀疑。
像Swing事件线程这样的服务可能应因为某个编写不当的事件处理器抛出NullPointerException而失败,这是糟糕的。因此,这些线程应该在try-catch代码块中调用这些任务,这样就能捕获那些未检查的异常了,或者也可以使用try-finally代码块来确保框架能够知道线程非正常退出的情况,并做出正确的响应。
在7-23中给出了如何在线程池内部构建一个工作者线程。
如果任务抛出了一个为检查异常,那么它将使线程终结。但会首先通知框架该线程已经终结。然后,框架可能会用新的线程来代替这个工作线程,也可能不会,因为线程池正在关闭,或者当前已有足够多的线程能满足需要。
ThreadPoolExecutor和Swing都通过这项技术来确保行为糟糕的任务不会影响到后续任务的执行。
当编写一个向线程池提交任务的工作者线程类时,或者调用不可信的外部代码时(例如动态加载的插件),使用这些方法中的某一种可以避免某个编写得很糟糕的任务或插件不会影响调用它的整个线程。
程序清单7-23
public void run() {
Throwable thrown = null;
try {
while (!isInterrupted()) //未中断
runTask(getTaskFromWorkQueue());
} catch (Throwable e) { //检查到异常
thrown = e;
} finally { //线程终结
threadExited(this, thrown);
}
}
7.3.1 未捕获异常的处理
上节介绍了一种主动方法来解决未检查异常。在Thread API 中同样提供了UncaughtExceptionHandler,它能检测出某个线程由于未捕获的异常而终结的情况。这两种方法是互补的,通过将两者结合在一起,就能有效地防止线程泄漏问题。
当线程由于未捕获异常而退出时,JVM会吧这个事件报告给应用程序提的UncaughtExceptionHandler异常处理器。如果没有提供任何异常处理器,那么默认的行为是将栈追踪信息输出到System.err。
程序清单7-24
public interface UncaughtExceptionHandler {
void uncaughtException(Thread t, Throwable e);
}
异常处理器如何处理未捕获异常,取决于对服务质量需求。最常见的响应方式是将一个错误信息以及相应的栈追踪信息写入应用程序日志中,如7-25。异常处理器还可以采取更直接的响应。例如尝试重新启动线程,关闭应用程序,或者执行其他修复或者诊断等操作。
程序清单7-25
public class UEHLogger implements Thread.UncaughtExceptionHandler {
public void uncaughtException(Thread t, Throwable e) {
Logger logger = Logger.getAnonymousLogger();
logger.log(Level.SEVERE, "Thread terminated with exception: " + t.getName(), e);
}
}
在运行时间较长的应用程序中,通过会为所有线程的未捕获异常指定同一个异常处理器,并且该处理器至少会将异常信息记录到日志中。
要为线程池中的所有线程设置一个UncaughtExceptionHandler,需要为ThreadPoolExecutor的构造函数提供一个ThreadFactory。(与所有的线程操作一样,只有线程的所有者能够改变线程的UncaughtExceptionHandler)
标准线程池允许当发生未捕获异常结束时结束线程,但由于使用了一个try-finally代码块来接收通知,因此当线程结束时,将有新的线程来代替它。如果没有提供捕获异常处理器或则其他的故障通知机制,那么任务会悄悄失败,从而导致极大的混乱。如果你希望在任务由于发生异常而失败时获得通知,并且执行一些特定于任务的恢复操作,那么可以将任务封装在能捕获异常的Runnable或Callable中,或者改写ThreadPoolExecutor中的afterExecutor方法。
只有通过execute提交的任务,才能将它抛出的异常交给未捕获异常处理器,而通过submit提交的任务,无论是抛出的未检查异常还是已检查异常,都被认为是任务返回状态的一部分。如果一个由submit提交的任务由于抛出了异常而结束,那么这个异常将被Future.get封装在ExecutionException中重新抛出。
7.4 JVM关闭
JVM可以正常关闭,也可以强行关闭。正常关闭的触发方式有多种:当最后一个“正常(非守护 nondaemon)”线程结束时,或者调用了Systen.exit时,或者通过其他特定于平台的方法关闭时(例如发送额SIGINT幸好或键入Ctrl-C).虽然可以通过这些标准方法来正常关闭JVM,也可以通过调用Runtime.halt(停止)或者在操作系统中中“杀死”JVM进程(例如发送SIGKILL)来强行关闭JVM。
7.4.1 关闭钩子
在正常关闭中,JVM会首先调用所有已注册的关闭钩子(Shutdown Hooks)。
关闭钩子(Shutdown Hooks)是指通过Runtime.addShutdownHook注册的但尚未开始的线程。
关闭钩子本质上是一个线程(也称为Hook线程),用来监听JVM的关闭。通过使用Runtime的addShutdownHook(Thread hook)可以向JVM注册一个关闭钩子。Hook线程在JVM 正常关闭才会执行,在强制关闭时不会执行。
JVM不能保证关闭钩子的调用顺序。若关闭应用程序时,如果有(守护或非守护 daemon
or nondaemon)线程仍然在运行,那么这些线程接下来将与关闭进程并发执行。当所有的关闭钩子都执行结束时,如果runFinalizersOnExit为true,那么JVM将运行终结器。然后再停止。JVM不会停止或中断任何关闭时仍然运行的应用程序线程。当JVM最终结束时,这些线程将被强行结束。如果关闭钩子或终结期没有执行完成,那么正常关闭线程“挂起”并且JVM必须被强行关闭。当被强行关闭时,只是关闭JVM,而不会运行关闭钩子
关闭钩子应该是线程安全的:它们在访问共享数据时必须使用同步机制,并且小心地避免发生死锁,这与其他并发代码的要求相同。而且,关闭钩子不应该对应用程序的状态(例如,其他服务是否已经关闭,或者所有的正常线程是否已经执行完成)或JVM的关闭原因做出任何假设,因此在编写关闭钩子的代码时必须考虑周全。最后,关闭钩子必须尽快退出,因为它们会延迟JVM的结束时间,而用户希望JVM能尽快停止。
关闭钩子可以用于实现服务或者应用程序的清理工作,例如删除临时文件,或者清除无法由操作系统自动清除的资源。在7-26中给出了如何使程序清单7-16中的LogService在其start方法中注册一个关闭钩子,从而确保在退出时关闭日志文件。
程序清单7-26
public void start() {
Runtime.getRuntime().addShutdownHook(new Thread() {//注册一个关闭钩子
public void run() {
try { LogService.this.stop(); } //确保在退出时关闭日志文件。
catch (InterruptedException ignored) {}
}
});
}
7.4.2 守护线程
有时候,你希望创建一个线程来执行一些辅助工作,但又不希望这个线程阻碍JVM的关闭。在这种情况下就需要使用守护线程(Daemon Thread)
线程可以分为两张:普通线程和守护线程。在JVM启动时创建的所有线程中,除了主线程以外,其他的线程都是守护线程(例如垃圾回收器以及其他执行辅助工作的线程)。当创建一个新线程时,新线程将继承创建它的线程的守护状态,因此在默认情况下,主线程创建的所有线程都是普通线程。
普通线程与守护线程之间的差异仅在于当线程退出时发生的操作。当一个线程退出时,JVM会检查其他正在运行的线程,如果这些线程都是守护线程,那么JVM会正常退出操作。当JVM停止时,所有仍然存在的守护线程都将被抛弃——既不会执行finally代码块,也不会执行回卷栈,而JVM只是直接退出。
我们应尽可能少使用守护线程——很少有操作能够在不进行清理的情况下被安全地抛弃。
守护线程通常不能用来代替应用程序管理程序中各个服务的生命周期。
7.4.3 终结器
(句柄就是个数字,一般和当前系统下的整数的位数一样,比如32bit系统下就是4个字节。这个数字是一个对象的唯一标示,和对象一一对应。这个对象可以是一个块内存,一个资源,或者一个服务的context(如 socket,thread)等等。)
当不再需要内存资源时,可以通过垃圾回收器来回收它们,但对于其他一些资源,例如文件句柄或套接字句柄(handles),当不再需要它们时,必须显示地交换给操作系统。为了实现这个功能,垃圾回收器对那些定义了finalizer方法的对象会进行特殊处理:在回收期释放它们后,调用它们的finalizer方法,从而保证一些持久化资源被释放。
避免使用终结器。