引言
取消正在执行的任务、线程、服务,并不是那么容易。对于某个任务而言,为了支持取消操作,你不得不增加取消的代码,而且不一定支持阻塞;对于服务而言更是如此,你不清楚服务内部是依赖什么实现,所以就无法正常取消。(例如,内部可能是阻塞队列,可能是多个Task)
任务取消
取消一个任务,或一段程序,最容易想到的操作是增加判断标志,每次执行的时候判断,否则退出程序。但自定义的变量并不能解决阻塞问题。例如下面代码,如果在循环中阻塞了,那么它永远无法判断取消标志。
while(!cancelled) {
do something // block
}
所以Java内部提供了Interrupt机制,它本质上就是一个boolean变量,只是底层(例如阻塞)对该标志有相应的支持,所以中断是实现取消的最合理的方式。
线程与任务
原始的API中,任务和线程通常是耦合在一起的。当我们说起取消任务的时候,实际上是中断运行任务的线程,或者利用中断标志给线程添加取消策略。
这里有一个原则:
除非你知道线程的中断策略,否则不要中断它
原因在于,中断机制本质上是一个协议,它除了设置中断标志之外,并没有做特别的处理,所以每个线程都有自己的中断策略(可能是无策略)。
延迟中断
我们知道Interrupt机制一般需要手动恢复中断,但是在以下情形恢复中断会造成死循环,所以必须采用延迟恢复的技术。
final AtomicBoolean interrupted = new AtomicBoolean(false);
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
BlockingQueue<Integer> queue = new ArrayBlockingQueue<Integer>(2);
try {
while (true) {
try {
Integer r = queue.take();
return;
} catch (InterruptedException e) {
// 延迟中断标志
interrupted.set(true);
System.out.println("catch");
e.printStackTrace();
}
}
} finally {
// 恢复中断
if (interrupted.get()){
Thread.currentThread().interrupt();
}
}
}
});
利用Future取消任务,定时取消任务
当我们使用Executor框架的时候,线程是由Executor创建的,我们不能直接去中断线程,因为我们不知道Executor正在运行什么任务。Executor使用一种抽象的机制来管理任务的生命周期,包括获得结果和取消任务。所以,一种更好的方式,就是利用Future的cancel方法来取消任务,这是非常方便的。
由于任务执行时间是不确定的,设定期望执行时间,应该是一种常见的操作,以下提供一种最佳实践。
public void futureTimedRun(Runnable runnable, long timeout, TimeUnit timeUnit) {
Future<?> future = executor.submit(runnable);
try {
future.get(timeout, timeUnit);
} catch (InterruptedException e) {
// 处理中断
e.printStackTrace();
} catch (ExecutionException e) {
// 处理执行时异常
e.printStackTrace();
} catch (TimeoutException e) {
// 超时处理
e.printStackTrace();
} finally {
// 取消不再需要的任务
future.cancel(true);
}
}
作为对比,这里也给出一种不好的做法——利用ScheduledExecutor延迟执行,取消任务。
这里的问题是:
- 违背原则,不知道线程的中断策略,就不能操控它。你不知道它是否响应中断,是否继续执行
- 如果任务执行异常,无法捕获。你也不知道它究竟是执行异常还是超时异常,还是中断
public void timedRun(Runnable runnable, long timeout, TimeUnit timeUnit) {
// 启动线程
final Thread thread = new Thread(runnable);
thread.start();
// 定时取消任务
executor.schedule(new Runnable() {
@Override
public void run() {
thread.interrupt();
}
}, timeout, timeUnit);
}
取消不可中断的阻塞
并不是所有阻塞都会响应interrupt,例如各种IO操作,例如Lock的lock方法。
这个时候,我们就需要了解它们各自的中断机制,编写整体任务的中断策略。
例如:
- Socket IO,中断可以通过关掉socket对象实现
- Lock,实际上提供了可中断的加锁方法lockInterruptibly
以Socket为例,编写自定义的中断策略。
public class ReaderThread extends Thread {
//...
/**
* 其实就是 socket的中断方法 + 线程的中断方法
*/
public void interrupt() {
try {
socket.close();
} catch (IOException ignored) {
} finally {
super.interrupt();
}
}
}
如果是以Executor的形式实现,那自定义中断策略就比较复杂了。Executor是通过FutureTask来实现取消操作的,因此,你必须完成两件事:
- 拓展FutureTask,实现自定义的取消策略
- 拓展Executor,新建自定义的FutureTask
这部分比较复杂,请参考源码(Chapter07,taskcancel.noninterruptable.CancellableTask)。
服务取消
服务通常会基于线程编写,不同的实现方式需要不同的中断策略。
Executor
基于Executor管理线程应该是最常用的,幸运的是,如果不用考虑任务的中断恢复的话,Executor机制本身已经提供了非常方便的中断方式——ExecutorService的生命周期。
/**
* 直接停止,并且阻塞等待,固定时间
*/
public void stop() throws InterruptedException {
try {
exec.shutdown();
exec.awaitTermination(3000, TimeUnit.MILLISECONDS);
} finally {
/* 关闭其他资源,例如io */
}
}
此外,如果是一次执行的任务,那么记得手动关闭Executor。
/**
* 如果ExecutorService只执行一次,那么可以直接在finally关闭
* <p>
* 案例:并发检查多个地址的邮件是否更新,有则设置更新标志
*/
boolean checkMail(Set<String> 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);
}
private boolean checkMail(String host) {
/* 检查邮件 */
return false;
}
});
} finally {
exec.shutdown();
exec.awaitTermination(timeout, unit);
}
return hasNewMail.get();
}
生产者-消费者模型
这是一种管理有限资源的常用模型,通常使用阻塞队列编写。
自定义标志
基本理念:中断标记之后,停止生产,消费完成允许中断**。**需要使用同步保证变量访问的原子性。
/**
* 中断基于线程的服务-中断生产者消费者服务
* <p>
* 案例:
* 日志记录服务,拥有固定的消费线程,处理日志。多个调用者线程生产日志(生产者)
* <p>
* 需要小心编写写中断服务。
* 有时候不仅是中断线程,正如本例,中断生产者-消费者模型,这是一种方式。
* 基本理念:中断标记之后,停止生产,消费完成允许中断
*/
public class LogWriter {
...
/**
* 1. 中断消费线程。
* 但是生产效率大于消费效率,会阻塞在队列上导致无法中断。
*/
public void stop() {
synchronized (this) {
shutdown = true;
}
logger.interrupt();
}
/**
* 2. 增加shutdown标志,关闭后不再生产,避免生产者阻塞。
* 但是shutdown的判断修改为竞态条件,需要同步
*
*/
public void log(String msg) throws InterruptedException {
synchronized (this) {
if (shutdown) {
System.out.println("has shutdown");
return;
}
}
queue.put(msg);
}
private class LoggerThread extends Thread {
/**
* 3. 判断关闭且队列消费完毕,退出。同样注意竞态条件
*/
public void run() {
while (true) {
synchronized (LogWriter.this) {
if (shutdown && queue.size() == 0) {
break;
}
}
consume();
}
}
private void consume() {
try {
System.out.println(queue.take());
Thread.sleep(100);
} catch (InterruptedException e) {
// 允许忽略中断标志,上一层已经有中断策略
}
}
}
}
“毒药”法
POISON法: 理念是生产者被中断添加POISON标志,并且不会再生产;等待消费者消费,消费者看到POISON直接退出。 注意,在n个生产者需要添加n个POISON标志。
/**
* 中断基于线程的服务-中断生产者消费者服务
* <p>
* 案例:
* 索引建立服务,生产线程不断添加文件路径,消费线程不断对路径建立索引
* <p>
* POISON法:
* 理念是生产者被中断添加POISON标志,并且不会在生产;等待消费者消费完成,消费者看到POISON直接退出。
* 注意,在n个生产者需要添加ngePOISON标志
*/
public class IndexingService {
...
/**
* 生产线程
*/
class CrawlerThread extends Thread {
public void run() {
try {
crawl(root);
} catch (InterruptedException e) { /* fall through */ } finally {
// 2. 生产者被中断之后,添加POISON
// 注意,如果有n个生产者,每个生产者应该各自添加POISON
while (true) {
try {
System.out.println("中断,添加POISON");
queue.put(POISON);
break;
} catch (InterruptedException e1) { /* retry */ }
}
}
}
private void crawl(File root) throws InterruptedException {
/* 添加路径到队列等待建立索引*/
for (int i = 0; i < 1000; i++) {
queue.put(new File(i + ""));
Thread.sleep(100);
}
}
}
/**
* 消费者线程
*/
class IndexerThread extends Thread {
public void run() {
try {
// 3. 消费者如果看到POISON则立即退出
while (true) {
Thread.sleep(150);
File file = queue.take();
if (file == POISON)
break;
else
indexFile(file);
}
} catch (InterruptedException consumed) {
// 允许中断
}
}
private void indexFile(File file) {
/* 建立索引 */
System.out.println("indexing " + file.getAbsolutePath());
}
}
public void start() {
producer.start();
consumer.start();
}
/**
* 1. 中断服务,中断生产者
*/
public void stop() {
producer.interrupt();
}
/**
* 等待的同步阻塞
*/
public void awaitTermination() throws InterruptedException {
consumer.join();
}
}
任务断点恢复
上文提到Executor的shutdown方法,该方法调用之后会等待任务执行完成,然后关闭。如果任务一直没执行完,能否强制关闭呢?答案是肯定的,Executor提供了shutdownNow方法,该方法调用之后,会立即尝试结束正在运行线程,并返回已经提交未执行完的任务。
但这样一来,其实无法恢复程序状态——因为正在运行的任务被取消了,我们并不知道是哪些。
这种断点恢复是很有用的,例如爬虫程序,下载断点恢复等等。这个时候就需要拓展Executor,收集执行中被中断的任务。
/**
* 中断基于线程的服务-支持断点恢复的服务的实现
*
*/
public class TrackingExecutor extends AbstractExecutorService {
private final ExecutorService exec;
private final Set<Runnable> tasksCancelledAtShutdown =
Collections.synchronizedSet(new HashSet<Runnable>());
public List<Runnable> getCancelledTasks() {
if (!exec.isTerminated())
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 {
if (isShutdown()
&& Thread.currentThread().isInterrupted())
tasksCancelledAtShutdown.add(runnable);
}
}
});
}
/*
* 将其他方法委托给exec
* */
}
该Executor的使用如下。完整代码请参考github。
/**
* 案例:爬虫程序断点恢复
*/
public class WebCrawler {
...
/**
* 演示了如何使用支持断点恢复的Executor
* <p>
* 1. 调用{@code exec.shutdownNow()},保存已经提交未开始的任务
* <p>
* 2. 调用{@code exec.getCancelledTasks()},保存正在执行被取消的任务
*/
public synchronized void stop() throws InterruptedException {
try {
saveUncrawled(exec.shutdownNow());
// 等待5秒,如果exec正常结束则返回true,否则返回false
if (exec.awaitTermination(5000, TimeUnit.MILLISECONDS))
saveUncrawled(exec.getCancelledTasks());
} finally {
exec = null;
}
}
}
线程异常退出,线程泄漏
线程泄漏是指线程执行过程中遇到未捕获异常没有处理,最终导致程序卡住或异常。
那么,如何处理未捕获异常呢?两种方式。
try-catch
public void run() {
Throwable thrown = null;
try {
while (!isInterrupted())
runTask(getTaskFromWorkQueue());
} catch (Throwable e) {
thrown = e;
} finally {
// 处理未捕获异常
threadExited(this, thrown);
}
}
UncaughtExceptionHandler
Thread API提供了UncaughtExceptionHandler异常处理器,当一个线程由于未捕获异常而退出时,JVM会把这件事报告给应用程序提供的UncaughtExceptionHandler异常处理器。
在运行较长的应用程序中,通常会给所有线程设置一个异常处理器,例如将异常记录到日志中。
public interface UncaughtExceptionHandler {
void uncaughtException(Thread t, Throwable e);
}
JVM关闭
JVM可以正常关闭和强制关闭(例如外部kill),那么我们需要了解哪些和JVM关闭有关的知识呢?
守护线程
守护线程是什么
Java程序入口就是由JVM启动main线程,main线程又可以启动其他线程。当所有线程都运行结束时,JVM退出,进程结束。
如果有一个线程没有退出,JVM进程就不会退出。所以,必须保证所有线程都能及时结束。
但是有一种线程的目的就是无限循环,例如,一个定时触发任务的线程:
class TimerThread extends Thread {
@Override
public void run() {
while (true) {
System.out.println(LocalTime.now());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
break;
}
}
}
}
// 使用
Thread t = new MyThread();
t.setDaemon(true);
t.start();
如果这个线程不结束,JVM进程就无法结束。问题是,由谁负责结束这个线程?
然而这类线程经常没有负责人来负责结束它们。但是,当其他线程结束时,JVM进程又必须要结束,怎么办?
答案是使用守护线程(Daemon Thread)。
守护线程是指为其他线程服务的线程。在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。
hef: https://www.liaoxuefeng.com/wiki/1252599548343744/1306580788183074
最佳实践
JVM退出的时候,如果发现只剩下守护线程,那么它就会正常退出,所有存在的守护线程会被抛弃——不会执行finally,回收等,直接结束。
正因为如此,我们要尽可能地少用守护线程,因为很少有线程能够在完全不清理的情况下直接抛弃。守护线程最好只用于内部任务。
finalize
Java提供了finalization的机制——一个对象被GC之前,会调用finalize()方法。它很像c++的析构方法,但由于Java是由虚拟机管理GC,使得它在功能上和析构方法完全不同。
- finalize的执行时机完全无法保证,它只和GC的时机有关
- finalize方法之后对象不一定回收,它可以复活某个对象
- 糟糕的finalize实现会极大地影响GC效率
所以:
不要使用finalize!
除了用于回收native资源。
关闭钩子
关闭钩子是JVM的一种机制,在正常结束前会调用所有已经注册的关闭钩子(通常是一个线程)。它的注册非常简单:
public void start() {
Runtime.getRuntime().addShutdownHook(new Thread() {
public void run() {
try { LogService.this.stop(); }
catch (InterruptedException ignored) {}
}
});
}
注意,所有关闭钩子(线程)都是并发执行的,要注意清理资源时的并发安全问题。如果多种资源有依赖(例如某些资源关闭可能需要依赖日志服务,如果日志服务被关闭钩子关闭,那就会出问题),那么可以使用单一关闭钩子的技术——所有服务都使用同一个关闭钩子进行资源清理,这个线程可以串行地关闭多个资源,避免依赖问题。
源码
https://github.com/KDL-in/JavaConcurrentDemo/tree/master/Chapter07/src/main/java

本文详细探讨了如何在Java中正确取消任务和服务。从任务取消的常见方法,如利用Interrupt机制、Future的cancel方法,到服务取消,如ExecutorService的生命周期管理,以及在生产者-消费者模型中采用自定义标志和‘毒药’法。同时,讨论了线程异常退出、JVM关闭以及守护线程的角色,提供了最佳实践和注意事项。
1702

被折叠的 条评论
为什么被折叠?



