引:任务和线程的启动很容易,在大多数时候,我们都会让他们运行直到结束,然而,有时候我们希望提前结束任务或线程,但是Java没有提供任何机制来安全地终止线程,只是提供了中断,这是一种协作机制,能够使一个线程终止另一个线程的工作。所以需要我们能很完善地处理失败、关闭和取消等过程。
任务取消
如果外部代码能在某个操作正常完成之前将其置入“完成”状态,那么这个操作就可以称为可取消的。取消这个操作的原因有很多:
- 用户请求取消。用户点击图形界面程序的“取消”按钮。
- 有时间限制的操作。某个程序需要在有限时间内完成搜索任务,当超时时,需要取消搜索任务。
- 错误。当一个爬虫程序发生错误时,那么搜索任务都会取消。
- 关闭。在立即关闭的过程中,当前的任务则可能被取消。
在Java中没有一种安全的抢占式方法来停止线程,因此也就没有安全的抢占式方法来停止任务,只有一些协作式的机制,使请求取消的任务和代码都遵循一种协商好的协议。
其中一种协作机制能设置某个“已请求取消”标志,而任务将定期地查看该标志。如果设置了这个标志,那么任务将提前结束。代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16public class Task implement Runnable {
// 为了使这个过程能可靠得工作,标志cancelled必须为volatile类型
private volatile boolean cancelled;
public void run() {
while(!cancelled) {
}
}
public void cancel() {
cancelled = true;
}
}
中断
如果在上面代码中while里面出现了一个阻塞的方法,那么在调用cancel方法来设置cancelled状态,当却检查不到标志,因为它无法从阻塞的方法恢复过来。如下面的代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24public class BrokenPrimeProducer extends Thread {
private final BlockingQueue<BigInteger> queue;
private volatile boolean cancelled = false;
BrokenPrimeProducer(BlockingQueue<BigInteger> queue) {
this.queue = queue;
}
public void run() {
try {
BigInteger p = BigInteger.ONE;
while (!cancelled) {
// 如果生产者的速度超过消费者的处理速度,队列将被填满,put方法会被阻塞
queue.put(p = p.nextProbablePrime());
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public void cancel() {
cancelled = true;
}
}
所以我们会想到一些线程中断这种协作机制,它利用了特殊的阻塞库用来是实现任务取消,注意:如果在取消之外的其他操作中使用中断,都是不合适的,并且很难支撑起更大的应用。下面是Thread的中断方法:1
2
3
4
5
6
7
8
9
10
11
12// 每个线程都有一个boolean类型的中断状态,当中断线程时,这个线程的中断状态将被设置为true
public class Thread {
// 中断目标线程
public void interrupt() {}
// 清除当前线程的中断状态
public static boolean interrupted() {}
// 返回目标线程的中断状态
public boolean isInterrupted() {}
}
阻塞库的方法,如Thread.sleep和Object.wait等都会检查线程何时中断,并且在发生中断时返回。响应中断执行的操作包括:清除中断状态,抛出InterruptedException。JVM不保证阻塞方法检测到中断的速度,但通常响应速度还是非常快的。
注意:调用interrupt并不意味着立即停止目标线程正在进行的工作,而只是传递了请求中断的消息。然后由线程在下一个合适的时刻中断自己(这些时刻也被称为取消点)。有些方法,例如wait、sleep和join等,将严格处理这种请求,当他们收到中断请求或者在开始执行时发现某个已被设置好的中断状态,将抛出一个异常。示例如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23public class BrokenPrimeProducer extends Thread {
private final BlockingQueue<BigInteger> queue;
BrokenPrimeProducer(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 e) {
e.printStackTrace();
}
}
public void cancel() {
interrupt();
}
}
中断策略
最合理的中断策略是某种形式的线程级取消操作或者服务级取消操作:尽快退出,在必要时清理,通知某个所有者该线程已经退出。此外还可以建立其他的中断策略,例如暂停服务或重新开始服务。
任务不应该对执行该任务的线程的中断策略做出假设。无论任务把中断视为取消,还是其他某个中断响应操作,都应该小心的保存线程的中断状态,如果除了将InterruptException传递给调用者外还需要执行其他操作,那么应该在捕获InterruptException之后恢复中断状态。
线程只能由其所有者中断,所有者可以将线程的中断策略信息封装到某个合适的取消机制中,例如关闭方法中。
响应中断
在调用可中断的阻塞函数时,有两种实用策略可用于处理InterruptException:
- 传递异常:从而使你的方法也称为了可中断的阻塞方法。
- 恢复中断状态:从而使调用栈中的上层代码能够对其进行处理。
只有是实现了线程中断策略的代码才可以屏蔽中断请求。在常规的任务和库代码中都不应该屏蔽中断请求。
通过Future来实现取消
使用ExecuorService.submit方法将返回一个Future来描述任务,Future有一个cancel方法。cancle方法有一个参数mayInterruptIfRunning,如果设置为true,那么就表示取消操作是否成功(这只是表示任务是否能够接受中断,而不是表示任务是否能够检测并处理中断)。如果为false,表示如果任务还没有运行,那么就不要运行它。代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19public 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); // 如果任务正在运行,那么将被中断
}
}
}
处理不可中断的阻塞
在java库中,很多阻塞的方法都是通过提前返回或者是抛出InterruptedException来响应中断请求的,然而并非所有的可阻塞方法或者阻塞机制都能响应中断。
比如一个线程由于执行同步的Socket IO 或者等待获得内置锁而阻塞,那么中断请求只能设置线程的中断状态,除此之外没有其他任何作用,对于那些执行不可中断操作而被阻塞的线程,可以使用类似于中断的手段来停止这些线程,但这要求我们必须知道线程阻塞的原因,然后通过重写非标准的取消操作。
停止基础线程的服务
应用程序通常会创建多个线程的服务,例如线程池。正确地封装原则是:除非拥有某个线程,否则不能对该线程进行操控,线程池是其工作线程的所有者,如果要中断这些线程,那么应该使用线程池。线程的所有权是不可以传递的:应用程序可以拥有服务,服务可以拥有工作者线程,但应用程序并不能拥有工作者线程,因此应用程序不能直接停止工作者线程。相反,服务应用提供生命周期方法来关闭它自己以及它所拥有的线程,在ExecutorService中提供了shutdown和shutdownNow方法。
例子:日志服务
方式:通过调用log方法将日志消息放入某个队列中,并由其他线程来处理;
停止该服务的方式:通过原子方式来检查关闭请求,并且有条件地递增一个计数器来保存提交信息的权利;
关闭ExecutorService
Service封装在某个更高级别的服务中,并且该服务能提供其自己的生命周期方法。
毒丸对象
毒丸是指一个放在队列上的对象,其含义是:当得到这个对象时,立即停止;
限制:只有在生产者和消费者的数量都已知的情况下,才可以使用“毒丸”对象;
当生产者和消费者数目较大时,这种方法变得难以使用。
例子:只执行一次的服务
场景:某个方法需要处理一批任务,并且当所有任务都处理完后才返回,可以通过一个私有的Executor来简化服务的生命周期管理,其中该Executor的生命周期是由这个方法控制的
shutdownNow的局限性
当通过shutdownNow来强行关闭ExecutorService时,尝试取消正在执行的任务,并返回所有已经提交但未开始的任务。但无法在关闭过程中知道正在执行的任务的状态。除非任务本身会执行某种检查。
处理非正常的线程终止
在并发程序中,是无法做到一直观察控制台的, 例如:你的web应用部署到服务器上,难道你要派个人一直去观察控制台?
任何代码都可能抛出一个RuntimeExecption,每当调用另一个方法时,都要对它的行为保持怀疑,不要盲目地认为它一定会抛出在方法原型中声明的某个已检查异常。对调用的代码越不熟悉,就越应该对其代码行为保持怀疑。
典型的线程池工作者线程结构
代码如下:1
2
3
4
5
6
7
8
9
10
11
12public void run(){
Throwable throw = null;
try{
while(!isInterrupted){
runTask(getTaskFromWorkQueue());
}
} catch (Throwable e){
thrown = e;
} finally{
threadExited(this,thrown);
}
}
如果任务抛出了一个未检查的异常,那么它将使线程终结,但会首先通知框架该线程已经终结.然后,框架可能会用新的线程来代替这个工作线程。
将异常写入日志的UncaughtExecptionHandler
代码如下:1
2
3
4
5
6public class Thread.UncaughtExecptionHandler{
public void uncaughtException(Thread t,Throwable e){
Logger logger = Logger.getAnonymousLogger();
Logger.log(Level.SEVERE,"Thread terminated with exception: "+ t.getName(),e);
}
}
在运行时间较长的应用程序中,通常会为所有线程的未捕获异常指定同一个异常处理器,并且该异常处理器至少会将异常信息记录到日志中。
JVM关闭
JVM既可以正常关闭,也可以强行关闭。正常关闭的触发方式有多种,包括:当最后一个“正常(非守护)”线程结束时,或者调用了System.exit时,或者通过其他特定于平台的方法关闭时(例如发送了SIGINT信号或Ctrl-C)。虽然可以通过这些标准方法来正常关闭JVM,但也可以通过调用Runtime.halt或者在操作系统中“杀死”JVM进程来强行关闭JVM。
关闭钩子
在正常关闭中,JVM首先调用所有已注册的关闭钩子,关闭钩子是指通过Runtime.addShutdownHook注册的但尚未开始的线程。JVM不能保证关闭钩子的调用顺序。在关闭应用程序线程时,如果有线程仍然在运行,那么这些线程接下来将与关闭进程并发执行.
关闭钩子应该是线程安全:它们在访问共享数据时,必须使用同步机制,小心避免死锁。
关闭钩子可以用于实现服务或应用程序的清理工作,例如删除临时文件,或者清除无法由操作系统自动清除的资源。
守护线程
有时候,你希望创建一个线程来执行一些辅助工作,但又不希望这个线程阻碍了JVM的关闭,这种情况就需要使用守护线程。
线程分为两种: 普通线程和守护线程,在JVM启动时启动创建的所有线程中,除了主线程以外,其他的线程都是守护线程。例如垃圾回收器,当创建一个新的线程时,它将继承创建它的线程的类型。
我们应该尽可能少地使用守护线程–很少有操作能够在不进行清理的情况下被安全地抛弃,特别是在执行I/O操作的任务,那么将是一种非常危险的行为; 并且守护线程不能用来替代应用程序管理程序中各个服务的生命周期
终结器
当不再需要内存资源时,可以通过垃圾回收器来回收它们,但对于其他一些资源,例如文件句柄或套接字句柄,当不再需要它们时,必须显式交还给操作系统。为了实现这个功能,垃圾回收器对那些定义了finalize方法的对象会进行特殊处理: 在垃圾回收期释放它们后,调用它们的finalize方法,从而保证一些持久化的资源被释放。
由于终结器可以在某个JVM管理的线程中运行,因此终结器访问任何状态都可能被多个线程访问,这样就必须对其访问操作进行同步。终结器并不能保证它们将在何时甚至是否会运行,并且复杂的终结器带来性能上的巨大开销。编写正确的终结器是非常困难的。在大多数情况下,通过使用finally代码块和显式的close方法能够比终结器更好的管理资源。
避免使用终结器
总结
在任务、线程、服务以及应用程序等模块中的生命周期结束问题,可能会增加他们在设计和实现时的复杂性。Java并没有提供某种抢占式的机制来取消操作或者总结线程。相反,它提供了一种协作式的中断机制来实现取消操作,但这要依赖于如何构建取消操作的协议,以及能否始终遵循这些协议。通过使用FutureTask和Executor框架,可以帮助我们构建可取消的任务和服务。
参考
- 《Java并发编程实战》