如何正确地取消Java任务或服务?

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

引言

取消正在执行的任务、线程、服务,并不是那么容易。对于某个任务而言,为了支持取消操作,你不得不增加取消的代码,而且不一定支持阻塞;对于服务而言更是如此,你不清楚服务内部是依赖什么实现,所以就无法正常取消。(例如,内部可能是阻塞队列,可能是多个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延迟执行,取消任务。
这里的问题是:

  1. 违背原则,不知道线程的中断策略,就不能操控它。你不知道它是否响应中断,是否继续执行
  2. 如果任务执行异常,无法捕获。你也不知道它究竟是执行异常还是超时异常,还是中断
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来实现取消操作的,因此,你必须完成两件事:

  1. 拓展FutureTask,实现自定义的取消策略
  2. 拓展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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值