首先我们很少希望某个任务、线程活服务立刻停止,这样会造成数据不一致的状态。通常我们希望会清除当前正在执行的工作,然后在结束。
可惜的是java中并没有一种安全的抢占方法来停止线程。但是它提供了中断(Interruption),这是一种协作机制,能够使一个线程终止另一个线程。这样提供了更好的灵活性,因为任务本身的代码比发出请求的代码更清楚如何执行清楚工作。
7.1 任务取消
如果外部代码能在某个操作正常完成之前将其置入 “完成” 状态,那么这个操作就是取消(Cancellable)。
取消某个操作可能有很多:
①用户请求取消。
②有时间限制的操作。
③应用程序的事件(例如文件唯一的情况下,同时开几个任务,分区去搜索文件,找到文件并返回)。
④错误(例如爬数据,磁盘满了,所有的任务都要取消,并记录他们当前的状态,以便于之后继续执行)。
④关闭(例如关闭程序活服务,必须要对正在处理或等待处理的工作执行某种操作)。
在java协作机制中能设置某个“已请求取消(Cancellation Requested)标志”,而任务将定期地查看这个标志。如果设置了这个标志,那么任务将提前结束。
下面的例子使用了这种机制,其中的PrimeGenerator持续地枚举素数(Prime),直到它被取消。cancel方法将设置cancelled标志,并且每次搜索下一个素数之前都会检查这个标志。
//7-1使用Volatile类型的域来保存取消状态
public class PrimeGenerator implements Runnable{
private final List<BigInteger> primes = new ArrayList<BigInteger>();
//volatile变量能确保可见性 禁止指令重排序
private volatile boolean cancelled;
public void run(){
BigInteger p=BigInteger.ONE;//常量
//任务将定期地查看这个标志。如果设置了这个标志,那么任务将提前结束。(即如果cancelled不为true则一直运行)
while(!cancelled){
p=p.nextProbablePrime();//返回一个比当前大的
synchronized (this) { //确保不会被添加多次
primes.add(p);
}
}
}
public void cancel(){
cancelled=true; //将“已请求取消标志”取为true
}
public synchronized List<BigInteger> get(){
return new ArrayList<BigInteger>(primes);
}
}
下面的例子让素数生成器运行1秒钟后取消。素数生成器通常不会在刚好一秒后停止,因为在请求取消的时刻和run方法中循环执行下一次检查之间可能存在延迟。cancel方法由finally块调用,确保即使在调用sleep时被中断也能取消素数生成器的执行。如果cancel没有被调用,那么搜索素数的线程将永远运行下去,不断消耗CPU的时钟周期,并使得JVM不能正常退出。
//7-2 一个仅运行一秒的素数生成器
static List<BigInteger> aSecondOfPrimes() throws InterruptedException{
PrimeGenerator generator=new PrimeGenerator();
new Thread(generator).start();
try{
SECONDS.sleep(1); //延迟一秒才执行cancel
}finally{
generator.cancel();
}
return generator.get();
}
一个可取消的任务必须拥有取消策略(Cancellation Policy)。 这个策略中详细的定义取消操作的“how”、“when”、“what”,即其他代码如何(how)请求取消该任务,任务在何时(when)检查是否已经请求了取消,已经在请求取消应该执行那些(what)操作。
7.1.1中断
PrimeGenerator中的取消机制最终会使得搜索素数的任务退出,但在退出过程中需要花费一定的时间。然而, 如果使用这种方法的任务调用了一个阻塞方法,例如BlockingQueue.put,那么可能会产生一个更严重的问题——任务可能永远不会检查取消标志,因此永远不会结束。
下面的例子中BrokenPrimeGenerator说明了这个问题。生产者线程生成素数,并将它们放入一个阻塞队列。如果生成者的速度超过了消费者的处理速度, 队列将被填满,put方法也会阻塞。当生产者在put方法中阻塞时,如果消费者希望取消生产者任务,它可以调用cancel方法来设置cancelled标志,但此时生产者却永远不能检查这个标志,因为它无法从阻塞的put方法中恢复过来(因为消费者此时已经停止从队列中取出素数,所以put方法将一直保持阻塞状态。)
//7-3 不可靠的取消操作吧生成者置于阻塞的操作中(不要这么做)
public class BrokenPrimeProducer extends Thread {
private final BlockingQueue<BigInteger> queue;
private volatile boolean cancelled=false;
public BrokenPrimeProducer(BlockingQueue<BigInteger> queue) {
this.queue=queue;
}
public void run(){
try{
BigInteger p=BigInteger.ONE;
while(!cancelled){
queue.put(p=p.nextProbablePrime());
}
}catch (InterruptedException consumed) {
}
}
}
void consumePrimes() throws InterruptedException{
BlockingQueue<BigInteger> primes=new ArrayBlockingQueue<>(MAX_PRIORITY);
BrokenPrimeProducer producer=new BrokenPrimeProducer(primes);
producer.start();
try{
while(needMorePrimes()) //如果需要继续消费
consume(primes.take());
}finally{
//它可以调用cancel方法来设置cancelled标志,但此时生产者却永远不能检查这个标志,因为它无法从阻塞的put方法中恢复过来(因为消费者此时已经停止从队列中取出素数,所以put方法将一直保持阻塞状态。)
producer.cancel();
}
}
每个线程都有一个boolean类型的中断状态。当中断线程时,这个线程的中断状态将被设置为true。
在Thread中包含了中断线程以及查询线程中断状态的方法,如下:
①interrupt方法能中断目标线程
②isInterrupt方法能返回目标线程的中断状态
③静态的interrupted方法将清除当前线程的中断状态,并返回它之前的值,这也是清除中断状态的唯一方法。
//7-4 Thread中的中断方法
public class Thread {
//interrupt方法能中断目标线程
public void interrupt() { ... }
//isInterrupt方法能返回目标线程的中断状态
public boolean isInterrupted() { ... }
//静态的interrupted方法将清除当前线程的中断状态,并返回它之前的值,这也是清除中断状态的唯一方法。
public static boolean interrupted() { ... }
...
}
阻塞库方法,例如Thread.sleep和Object.wait,都会检查线程何时中断,并且在发现中断时提前返回。它们在响应中断时的操作包括,清除中断状态,抛出InterruptedException,表示阻塞操作由于中断而提前结束。
当线程在非阻塞状态下中断时,它的中断状态将被设置,然后根据被取消的操作来检查中断状态以及发生了中断,通过这样的方法,中断操作将变得“有黏性”——如果不能触发InterruptedException,那么中断状态将一直保持,直到明确地清除中断状态。
调用Interrupt并不意味着立即停止目标线程正在进行的工作,而只是传递了请求中断的消息。
对中断操作的正确理解是:它并不会真正中断一个正在运行的线程,而只是发出中断骑牛,然后在线程下一个适合的时刻中断自己。
在使用静态的interrupted时应该小心,因为它会清除当前线程的中断状态。如果在调用interrupted时返回了true,那么除非你想屏蔽这个中断,否则你必须对它进行处理——可以抛出InterruptedException,或者通过再次调用interrupt来恢复中断状态。
通常,中断是实现取消的最合适方法。
BrokenPrimeGenerator中的问题很容易解决:使用中断而不是boolean标志来请求取消。
在每次迭代循环中,有两个位置可以检测出中断:在阻塞的put方法中调用,以及在循环开始处查询中断状态。
由于调用了阻塞的put方法,因此这里不一定需要显式的检测,但执行检测会使PrimeProducer对中断具有更高的响应性,因为它是在启动寻找素数任务之前检测中断的,而不是在任务完成后。
如果可中断的阻塞方法的调用频率不高,不足以获得足够的响应性,那么显式地检测中断状态能起到一定的帮助作用。
//7-5 通过中断来取消
public class PrimeProducer extends Thread {
private final BlockingQueue<BigInteger> queue;
PrimeProducer(BlockingQueue<BigInteger> queue) {
this.queue = queue;
}
public void run() {
try {
BigInteger p = BigInteger.ONE;
//在每次迭代循环中,有两个位置可以检测出中断:在阻塞的put方法中调用,以及在循环开始处查询中断状态。
while (!Thread.currentThread().isInterrupted())
queue.put(p = p.nextProbablePrime());
} catch (InterruptedException consumed) {
/* 允许线程退出 */
}
}
public void cancel() {
interrupt();
}
}
7.1.2 中断策略
正如任务中应该有取消策略一样,线程中同意应包含中断策略。
最合理的中断策略是某个形式的线程级(Thread-Level)取消操作或服务级(Service-Level)取消操作:尽快退出,在必要时进行清理,通知某个所有者该线程已经退出。
此外还可以建立其他的中断策略,例如暂停服务或重新开始服务,但对于那些包含非标准中断策略的线程或线程池,只能用于能知道这些策略的任务中。
区分任务和线程对中断的反映是很重要的。一个中断请求可以有一个或多个接收者—中断线程池中的某个工作者线程,同时意味着 “取消当前任务” 和 “关闭工作者线程”。
任务不会在其自己拥有的线程中执行,而是在某个服务(例如线程池)拥有的线程中执行。
对于非线程所有者的代码来说(例如,对于线程池而言,任何在线程池实现以外的代码),应该小心地保存中断状态,这样拥有线程的代码才能对中断做出响应,即使“非所有者”代码也可以做出响应。
这就是为什么大多数可阻塞的库函数都只是抛出InterruptedException作为中断响应。它们永远不会在某个由自己拥有的线程中运行,因此它们为任务或库代码实现了最合理的取消策略:尽快退出流程,并把中断信息传递给调用者,从而使调用栈中的上层代码可以采取进一步的操作。
当检查到中断请求时,任务并不需要放弃所有的操作——它可以推迟处理中断请求,并直到某个更合适的时刻。因此需要记住中断请求,并在完成当前任务后抛出InterruptedException或表示已收到中断状态,这项技术能够确保在更新过程中发生中断时,数据结构不会被破坏。
任务不应该对执行该任务的线程的中断策略做出任何假设,除非该任务被专门设计为服务中运行,并且在这些服务中包含特定的中断策略。
无论任务将中断视为取消,还是其他某个中断响应操作,都应该小心地保存执行线程的中断状态。
如果除了将InterruptedException传递给调用者外需要执行其他操作,那么应该在捕获InterruptedException之后恢复中断状态: Thread.currentThread.interrupt();
执行取消操作的代码也不应该对线程的中断策略做出假设,线程应该只能由所有者中断,所有者可以将线程的中断策略信息想封装到某个合适的取消机制中,例如shutdown方法。
7.1.3 响应中断
- 传递异常,使你的方法也可以成为可中断的阻塞方法。
- 恢复中断状态,使你的调用栈中的上层代码能够对其进行处理。
传递InterruptedException如下:
//7-6 将InterruptedException传递给调用者
BlockingQueue<Task> queue;
...
public Task getNextTask() throws InterruptedException {
return queue.take();
}
保存中断请求,一种标注你的方法就是通过再次调用interrupt来恢复中断状态。
只有实现了线程中断策略的代码才可以屏蔽中断请求,在常规的任务和库代码中都不应该屏蔽中断请求。
对于一些不支持取消但仍可以调用可中断阻塞方法的操作,它们必须在循环中调用这些方法,并在发现中断后重新尝试。在这种情况下,它们应该在本地保存中断状态,并在返回前恢复状态而不是在捕获InterruptedException时恢复状态,如
// 7-7 不可取消的任务在退出前恢复中断
public Task getNextTask(BlockingQueue<Taskgt; queue) {
boolean interrupted = false;
try {
while (true) {
try {
return queue.take();
} catch (InterruptedException e) {
interrupted = true;
// 重新尝试
}
}
} finally {
if (interrupted)
Thread.currentThread().interrupt();
}
}
如果代码不会调用可中断的阻塞方法,那么仍然可以通过任务代码中轮询当前线程的中断状态爱响应中断。
在取消过程中可能涉及除了中断状态之外的其他状态。中断可以用来获得线程的注意,并且由中断线程保存的信息,可以为中断的线程提供进一步的指示(当访问这些信息时,要确保使用同步)。
例如,当一个由ThreadPoolExecutor拥有的工作者线程检测到中断时,它会检测线程池是否正在关闭。如果是,它会在结束之前执行一些线程池清理工作,否则它可能创建一个新线程将线程池恢复到合理的规模。
7.1.4 计时运算
执行任务的另一个方面是,你希望知道在任务执行过程中是否会抛出异常。如果上面的PrimeGenerator例子在指定时限内抛出了一个未检查的异常,那么这个异常可能被忽略,因为素数生成器在另一个独立的线程总运行,而这个线程不会显式处理异常。
下面的例子在指定时限内运行任意的Runnable。它在调用线程中运行任务,并安排了一个取消任务,在运行指定的时间间隔后中断它。这解决了从任务中抛出未检查异常的问题,因为该异常会被timeRun的调用者捕获。
//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();
}
}
上面的例子很简单,但却破坏了以下规则:在中断线程之前,应该了解它的中断策略。由于timeRun1可以从任意一个线程中调用,因此它无法知道这个调用线程的中断策略。如果任务在超时之前可以完成,那么中断timeRun1所在线程的取消任务将在timeRun1返回到调用者之后启动。我们不知道在这种情况下将会运行什么代码,但结果一定是不好的。
而且,如果任务不响应中断,那么timedRun会在任务结束时才返回,此时可能已经超过了指定的时限。
下面的例子解决了aSecondOfPrimes的异常处理问题以及之前解决方案中的问题。
执行任务的线程拥有自己的执行策略,即使任务不响应中断,限时运行的方法仍然能返回到它的调用者。在启动任务线程之后,timeRun将执行一个限时的join方法。在join返回后,它将检查任务中是否有异常抛出,如果有的话,则会在调用timedRun2的线程中再次抛出应该异常。
// 7-9 在专门的线程中中断任务
public class timeRun2 {
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; //所有error和exception的超类
public void run() {
try {
r.run();
} catch (Throwable t) {
this.t = t;
}
}
void rethrow() {
if (t != null)
throw LaunderThrowable.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));//等待任务线程的消亡,即等它运行完毕
//在join返回后,它将检查任务中是否有异常抛出,如果有的话,则会在调用timedRun2的线程中再次抛出应该异常。
task.rethrow();
}
}
这个实例解决了前面的问题,但由于它依赖于一个限时的join,因此存在这join的不足:无法知道执行控制是因为线程正常退出还是因为join超时而返回。
7.1.5 通过Future 来实现取消
ExecutorService.submit将返回一个Future来描述任务。
Future拥有一个cancel方法,该方法带有一个boolean类型的参数mayInterruptIfRunning,表示取消操作是否成功(这只是表示任务是否能够接收中断,而不是表示任务是否能检测并处理中断)。
如果mayInterruptIfRunning为true并且任务当前正在某个线程中运行,那么这个线程能被中断。如果为false,那么意味着“若任务还没有启动,就不要运行它”,这种方式应该用于那些不处理中断的任务中。
除非你清除线程的中断策略,否则不要中断线程。
执行任务的线程是由标准的Executor创建的,它实现了一种中断策略使得任务可以通过中断被取消,所有如果任务在标准Executor中运行,并通过它们的Future来取消任务,那么可以设置mayInterruptIfRunning。当尝试取消某个任务时,不宜直接中断线程池,因为你并不知道当中断请求时正在运行什么任务—只能通过任务的Future来实现取消。
// 7-10 通过Future来取消任务
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) {
// 接下来任务会被取消
} catch (ExecutionException e) {
// 如果在任务中抛出了异常,那么重新抛出该异常
throw launderThrowable(e.getCause());
} finally {
// 如果任务已经结束,那么执行取消操作也不会有任何影响
task.cancel(true); // 如果任务正在进行,那么将被中断
}
}
}
当Future.get抛出InterruptedException或TimeoutException,如果你不需要知道结果,那么就可以调用Future.cancel来取消任务。
7.1.6 处理不可中断的阻塞
并非所有的可阻塞方法或者阻塞机制都能通过InterruptedException来响应中断请求。
如果一个线程由于执行同步的Socket I/O或的等待获得内置锁而阻塞,那么中断请求只能设置线程的中断状态,除此之外没有任何其他作用。对于那些由于执行不可中断操作而阻塞的线程,可以使用类似与中断的手段来停止这些线程,但这要求我们必须知道线程阻塞的原因。
下面的例子给出了如何封装非标准的取消操作。ReaderThread管理了一个套接字链接,它用同步的方式从该套接字中读取数据,并将接收到的数据传递给processBuffer。为了结束某个用户的连接或者关闭服务器,ReaderThread改写了interrupt方法,使其既能处理标准的中断,也能关闭底层的套接字。因此,无论ReaderThread线程是在read方法中阻塞还是在某个可中断的阻塞方法中阻塞,都可以被中断并停止执行当前的工作。
//7-11 通过改写interrupt方法将非标准的取消操作封装在Thread中
public class ReaderThread extends Thread{
private static final int BUFSZ = 1024;
private final Socket socket;
private final InputStream in;
public ReaderThread(Socket socket) throws IOException{
this.socket=socket;
this.in=socket.getInputStream();
}
//改写了interrupt方法,使其既能处理标准的中断,也能关闭底层的套接字
public void interrupt(){
try{
socket.close();
}catch (IOException ignored) {
}finally{
//调用继承的Thread中的方法处理标准的中断
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); //并将接收到的数据传递给processBuffer
}
}catch (IOException e) {
// 允许线程退出
}
}
public void processBuffer(byte[] buf, int count) {
}
}
7.1.7 采用newTaskFor来封装非标准的取消
我们可以通过newTaskFor来进一步优化ReaderThread 中封装的非标准取消的技术。
newTaskFor是一个工厂方法,它将创建Future来代表任务。newTaskFor还能返回一个RunnableFuture接口,该接口继承(扩展)了Future和Runnable(并由FutureTask实现)。
CancellableTask定义了一个CancellableTask接口,该接口扩展了Callable,并增加了一个cancel方法和newTask方法来构造RunnableFuture。
// 7-12 通过newTaskFor将非标准的取消操作封装在一个任务中
//CancellableTask定义了一个CancellableTask接口,该接口扩展了Callable,并增加了一个cancel方法和newTask方法来构造RunnableFuture。
public interface CancellableTask<T> extends Callable<T> {
void cancel();
RunnableFuture<T> newTask();
}
//CancellingExecutor继承了THReadPoolExecutor,并通过改写newTaskFor使得CancellableTask可以创建自己的Future
@ThreadSafe
class CancellingExecutor extends ThreadPoolExecutor {
protected<T> RunnableFuture<T> newTaskFor(Callable<T> callable) {
if (callable instanceof CancellableTask)
return ((CancellableTask<T>) callable).newTask();
else
return super.newTaskFor(callable);
}
}
//SocketUsingTask实现了CancellableTask
public abstract class SocketUsingTask <T> implements CancellableTask<T> {
private Socket socket;
protected synchronized void setSocket(Socket s) {
socket = s;
}
//定义了Future.cancel来关闭套接字
public synchronized void cancel() {
try {
if (socket != null)
socket.close();
} catch (IOException ignored) {
}
}
//如果SocketUsingTask通过其自己的Future来取消,那么底层的套接字将被关闭并且线程将被中断
public RunnableFuture<T> newTask() {
return new FutureTask<T>(this) { //Creates a FutureTask that will, upon running(正在运行), execute the given Callable.
@SuppressWarnings("finally")
public boolean cancel(boolean mayInterruptIfRunning) {
try {
SocketUsingTask.this.cancel();
} finally { //调用super.cancel
return super.cancel(mayInterruptIfRunning);
}
}
};
}
}
7.2停止基于线程的服务
应用程序通常会创建拥有多个线程的服务,例如线程池,并且这些服务的生命周期通常比创建它们的方法的生命周期更长。如果应用程序准备退出,那么这些服务所拥有的线程也需要结束。由于无法通过抢占式的方法来停止线程,因此它们需要自行结束。
正确的封装原则是:除非拥有某个线程,否则不能对该线程进行操控,例如中断线程或者修改线程的优先级等。线程有一个拥有者,即创建该线程的类,因此线程池是其工作者线程的所有者,如果要中断这些线程,那么应该使用线程池。
线程的所有权是不可传递的:应用程序可以拥有服务,服务也可以拥有工作者线程,但应用程序并不能拥有工作者线程,因此应用程序不能直接停止工作者线程。相反,服务应该提供生命周期方法(Lifecycle Method)来关闭它自己以及它所拥有的线程。这样,当应用程序关闭服务时,服务就可以关闭所有的线程了。ExecutorServi提供了shutdown和shutdownNow等方法。
对于持有线程的服务,只要服务的存在时间大于创建线程的方法的存在时间,那么就应该提供生命周期方法。
7.2.1 示例:日志服务
下面的例子给出了一个简单的日志服务示例,其中日志操作在单独的日志线程中执行。产生日志消息的线程并不会将消息直接写入输出流,而是由LogWriter通过BlockingQueue将消息提交给日志线程,由日志线程写入。这是一种多生产者但消费者(Multiple-Producer,Single-Consumer)的设计方式:每个调用log的操作都相当于一个生成者,而后台的日志线程则相当于消费者。如果消费者的处理速度低于生产者的生成速度,那么BlockingQueue将阻塞生产者,直到日志线程有能力处理新的日志服务。
// 7-13 不支持关闭的生产者-消费者日志服务(不好)
public class LogWriter {
private final BlockingQueue<String> queue;
private final LoggerThread logger; //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);
}
//产生日志消息的线程并不会将消息直接写入输出流,而是由LogWriter通过BlockingQueue将消息提交给日志线程,由日志线程写入。
private class LoggerThread extends Thread{ //日志线程
private final PrintWriter writer; //PrintWriter是线程安全的
public LoggerThread(Writer writer) {
this.writer = new PrintWriter(writer, true); // true参数代表了autoflush
}
public void run(){
try{
while(true)
writer.println(queue.take());
}catch (InterruptedException ignored) {
}finally{
writer.close();
}
}
}
}
要停止日志线程是很容易的,因为它会反复调用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 向LogWriter添加可靠的取消操作
public class LogService {
private final BlockingQueue<String> queue;
private final LoggerThread loggerThread;
private final PrintWriter writer;
private boolean isShutdown;
private int reservations; //保留,设置一个计数器来记录有多少个任务“保持”提交信息的权利
public LogService(Writer writer) {
this.queue = new LinkedBlockingQueue<String>();
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{
//避免检查的时候还未停止,而put的时候停止了,这个时候将会阻塞
synchronized (this) { //通过原子操作来检查关闭请求
if(isShutdown)
throw new IllegalStateException(/*...*/);
++reservations; //递增一个计数器来“保持”提交信息的权利
}
queue.put(msg);
}
private class LoggerThread extends Thread{
public void run(){
try{
while(true){
try{ //用的都是LogService的内置锁
synchronized (LogService.this) { //通过原子操作来检查关闭请求和拥有提交信息权利的任务的数量
if(isShutdown&&reservations==0)
break;
}
String msg=queue.take();
synchronized (LogService.this) {//这是一个复合操作,需要上锁
--reservations;
}
writer.println(msg);
}catch (InterruptedException e) {
// 重试
}
}
}finally{
writer.close();
}
}
}
}
但是感觉通过自身来管理相关的生命周期很麻烦,可以把相关的生命周期封装在更高级别的服务中,比如使用ExecutorService。下面来理解怎么封装在更高级的服务中
7.2.2关闭ExecutorService
ExecutorService提供了两种关闭方法:使用shutdown正常关闭,以及使用shutdownNow强行关闭。在进行强行关闭时,shutdownNow首先关闭当前正在执行的任务,然后返回所有尚未启动的任务清单。
这两种方法的差别性在于各自的安全性和响应性:强行关闭的速度更快,但风险也更大,因为任务很可能在执行到一半的时候被借宿;而正常关闭虽然速度慢,但却更安全,因为ExecutorService会一直等待队列中的所有任务都执行完成后才关闭。
在复杂程序中,通常会将ExecutorService封装在某个更高级的服务中,并且该服务能提供自己的生命周期方法,例如下面LogService的一种,它将管理线程的工作委托给一个ExecutorService,而不是自行管理。通过封装ExecutorService,可以将所有权链(Ownership Chain)从应用程序扩展到服务以及线程,所有权链上的各个成员都将管理它所拥有的服务或线程的生命周期。
//7-16 使用ExecutorService的日志服务
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方法类似)
public class IndexingService {
private static final int CAPACITY = 1000; //capacitry容量
private static final File POSION=new File("");
private final CrawlerThread producer=new CrawlerThread();
private final IndexerThread consumer=new IndexerThread();
private final BlockingQueue<File> queue;
private final FileFilter fileFilter;
private final File root;
public IndexingService(File root, final FileFilter fileFilter) {
this.root = root;
this.queue = new LinkedBlockingQueue<File>(CAPACITY);
this.fileFilter = new FileFilter() {
public boolean accept(File f) { //测试该路径名称该不该被加入到路径名称列表中
return f.isDirectory() || fileFilter.accept(f);
}
};
}
private boolean alreadyIndexed(File f) {
return false;
}
public void start(){
producer.start();
consumer.start();
}
public void stop() { //停止生产
producer.interrupt();
}
public void awaitTermination() throws InterruptedException {
consumer.join(); //等待前面执行的线程消亡后再执行
}
// 7-18 IndexingService的生产者线程
public class CrawlerThread extends Thread{
public void run(){
try{
crawl(root);
}catch (InterruptedException e) {
// 发生异常
}finally{ //提交毒丸对象后,将不再提交任何工作
while(true){
try{
queue.put(POSION);
break;
}catch (InterruptedException e) {
//重新尝试
}
}
}
}
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);
}
}
}
}
// 7-19 IndexingService的消费者线程
public class IndexerThread extends Thread{
public void run(){ //在毒丸对象提交前的所有工作都被被处理
try{
while(true){
File file=queue.take();
if(file==POSION)
break;
else
indexFile(file);
}
}catch (InterruptedException comsumed) {
}
}
public void indexFile(File file) {
/*...*/
};
}
只有在生产者和消费者数量都已知的情况下,才可以使用“毒丸”对象。在IndexingService中采用的解决方案可以扩展到多个生产者:只需要每个生产者都向队列中放入一个“毒丸”对象,并且消费这仅当在接收到Nproduces个“毒丸”对象时才停止。这种方法也可以扩展到多个消费者的情况,只需要生产者将Nconsumers个“毒丸”对象放入队列。然而,当生产者和消费者的数量较大时,这种方法将变得难以使用。只有在无界队列中,“毒丸”对象才能可靠的工作。
7.2.4 示例:只执行一次的服务
如果某个方法需要处理一批任务,并且当所有任务都处理完成后才返回,那么可以通过一个私有的Executor来简化服务的生命周期管理,其中该Executor的生命周期是由这个方法来控制的。(这种情况下,invokeAll和invokeAny作用较大)
public boolean checkMail(Set<String> hosts,long timeout,TimeUnit unit)throws InterruptedException{
ExecutorService exec=Executors.newCachedThreadPool();
//之所以用AtomicBoolean代替volatile,是因为能从内部的Runnable中访问hasNewMail标志,因此它必须是final类型以免修改
final AtomicBoolean hasNewMail=new AtomicBoolean(false);//初始值为false
try{
for(final String host:hosts)
exec.execute(new Runnable(){ //Executes the given command at some time in the future
public void run(){
if(checkMail(host))
hasNewMail.set(true);
}
});
}finally {
exec.shutdown();
exec.awaitTermination(timeout, unit);
}
return hasNewMail.get();
}
上面的例子,checkMail方法能在多台主机上并行地检查新邮件。它创建一个私有的Executor,并向每台主机提交一个任务。然后,当所有邮件检查任务都执行完毕后,关闭Executor并等待结束。
7.2.5 shutdownNow的局限性
当通过shutdownNow来强行关闭ExecutorService时,它会尝试取消正在执行的任务,并返回所有已提交但尚未开始的任务,从而将这些任务写入日志或者保存起来以便之后进行处理。
然而,我们无法在关闭中知道正在执行的任务的状态,除非任务本身会执行某种检查。在关闭的过程中只会返回尚未尚未开始的任务,不会返回正在执行的任务
在下面例子红,TrackingExecutor中给出了如何在关闭过程中判断正在执行的任务。通过封装ExecutorService并使得execute(类似地还有submit)记录那些任务是关闭后取消的,TrackingExecutor可以找出那些任务已经开始但还没有正常完成。在Executor结束后,getCancelledTasks返回被取消的任务清单。
// 7-21 在ExecutorService中跟踪在关闭之后取消的任务
public class TrackingExecutor extends AbstractExecutorService{
private final ExecutorService exec;
private final Set<Runnable> tasksCancelledAtShutdown=
Collections.synchronizedSet(new HashSet<Runnable>()); //创建一个线程安全的Set来记录那些任务是关闭后取消的
public List<Runnable> getCancelledTasks(){
if(!exec.isShutdown())
throw new IllegalStateException();
return new ArrayList<Runnable>(tasksCancelledAtShutdown);
}
public void execute(final Runnable runnable){
exec.execute(new Runnable(){
public void run(){
try{
runnable.run();
}finally{ //如果已经ExecutorService关闭了并且任务被中断(取消),添加到Set中
if(isShutdown()&&Thread.currentThread().isInterrupted())
tasksCancelledAtShutdown.add(runnable);
}
}
});
}
// 将ExecutorService的其他方法委托给exec
public TrackingExecutor(ExecutorService exec) {
this.exec = exec;
}
public void shutdown() {
exec.shutdown();
}
public List<Runnable> 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);
}
}
在WebCrawler中给出了TrackingExecutor的用法。网页爬虫程序的工作通常是无穷尽的,因此当爬虫程序必须关闭时,我们通常希望保存它的状态,以便稍后重新启动。CrawklTask提供了一个getPage方法,该方法能找出正在处理的页面,当爬虫程序关闭时,无论是还没有开始的任务,还是那些被取消的任务,都将记录它们的URL,因此当爬虫程序重新启动时,可以将这些URL的页面抓取任务加入到任务队列中。
// 7-22 使用TrackingExecutorService来保存未完成的任务已备后续执行
public abstract class WebCrawler {
private volatile TrackingExecutor exec;
@GuardedBy("this") private final Set<URL> urlsToCrawl = new HashSet<URL>(); //存放
private final ConcurrentMap<URL, Boolean> seen = new ConcurrentHashMap<URL, Boolean>();
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()); //创建可获得在关闭后被取消的任务的TrackingExecutor
for (URL url : urlsToCrawl) submitCrawlTask(url); //将urlsToCrawl中的url加入执行队列
urlsToCrawl.clear(); //清空
}
public synchronized void stop() throws InterruptedException {
try {
saveUncrawled(exec.shutdownNow()); //shutdownNow首先关闭当前正在执行的任务,然后返回所有尚未启动的任务清单。
if (exec.awaitTermination(TIMEOUT, UNIT))
saveUncrawled(exec.getCancelledTasks()); //返回在关闭后被取消的任务并保存
} finally {
exec = null;
}
}
protected abstract List<URL> processPage(URL url);
private void saveUncrawled(List<Runnable> 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() { //表示该url已经被爬过,putIfAbsent包含了判断的,是原子操作
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); //如果未中断则递归,一直往exec中添加任务
}
}
public URL getPage() { //返回url
return url;
}
}
}
在TrackingExecutor中存在一个不可避免的竞态条件,从而产生“误报”问题:一些被任务已经取消的任务实际上已经完成。
这个问题的原因在于,在任务执行最后一条指令以及线程池将任务记录为“结束”的这两个时刻之间,线程池可能被关闭。如果任务是幂等的(Idempotent,即将任务执行两次与一次会得到同样的结果),那么不存在问题,网页爬虫程序中就是这种情况。否则必须考虑这种风险,并对“误报”问题做好准备。
7.3 处理非正常的线程终止
当单线程的控制台程序由于发生了一个未捕获的异常而终止时,程序将停止运行,并产生与程序正常输出非常不同的栈追踪信息。
然而,如果并发程序中的某个线程发生故障,那么通过不会那么明显。在控制台中可能会输出栈追踪信息,但没有人会观察控制台。此外,当线程发生故障时,应用程序可能看起来仍在工作,所以这个失败很可能被忽略。幸运的是,我们有可以监测并防止在程序中“遗漏”线程的方法。
导致线程提前死亡的最主要原因就是RuntimeException。由于这些异常表现除了某种编程错误或者其他不可修复的错误,因此它们通常不被捕获。它们不会在调用栈中逐层传递,而是默认地在控制台中输出栈追踪信息,并终止线程。
线程非正常退出的后果可能是良性的,也可能是恶心的,这取决与线程在应用程序中的作用。
任何代码都可能抛出一个RuntimeException 。每当调用另一个方法时, 都要对它的行为保持怀疑, 不要盲目地认为它一定会正常返回, 或者一定会抛出在方法原型中声明的某个已检查异常。对调用的代码越不熟悉,就越应该对其代码行为保持怀疑
如果任务抛出了一个为检查异常,那么它将使线程终结。但会首先通知框架该线程已经终结。然后,框架可能会用新的线程来代替这个工作线程,也可能不会,因为线程池正在关闭,或者当前已有足够多的线程能满足需要。
下面的例子中给出了如何在线程池内部构建一个工作者线程
// 典型的线程池工作者线程结构
public void run() {
Throwable thrown = null;
try {
while (!isInterrupted()) //未中断
runTask(getTaskFromWorkQueue());
} catch (Throwable e) { //检查到异常
thrown = e;
} finally { //线程终结
threadExited(this, thrown);
}
}
也可以通过Thread API 中提供的UncaughtExceptionHandler,它能检测出某个线程由于未捕获的异常而终结的情况。这两种方法是互补的,通过将两者结合在一起,就能有效地防止线程泄漏问题
当线程由于未捕获异常而退出时,JVM会吧这个事件报告给应用程序提的UncaughtExceptionHandler异常处理器。如果没有提供任何异常处理器,那么默认的行为是将栈追踪信息输出到System.err。
/ 7-24 UncaughtExceptionHandler接口
public interface UncaughtExceptionHandler {
void uncaughtException(Thread t, Throwable e);
}
异常处理器如何处理未捕获异常,取决于对服务质量需求。最常见的响应方式是将一个错误信息以及相应的栈追踪信息写入应用程序日志中,如7-25。异常处理器还可以采取更直接的响应。例如尝试重新启动线程,关闭应用程序,或者执行其他修复或者诊断等操作。
// 7-25 将异常写入日志的UncaughtExceptionHandler
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中重新抛出。