如何在 Java 中在指定时间后停止执行

更新于 2025-12-29

baeldung 2020-10-06

1. 概述

在本文中,我们将学习如何在运行一段时间后终止一个长时间运行的任务。我们会探讨解决该问题的多种方法,并分析每种方法的潜在缺陷。

2. 使用循环

假设我们在一个循环中处理大量数据项,例如电子商务应用中的商品详情,但并不一定需要处理完所有项目。

实际上,我们希望只处理到某个时间点为止,之后立即停止执行,并返回到目前为止已处理的结果。

来看一个快速示例:

long start = System.currentTimeMillis();
long end = start + 30 * 1000;
while (System.currentTimeMillis() < end) {
    // 对当前项执行一些耗时操作。
}

在这个例子中,如果超过 30 秒的时间限制,循环就会退出。但这种方案存在一些值得注意的问题:

  • 精度较低:循环实际运行时间可能超过设定的时限。这取决于每次迭代所需的时间。例如,如果每次迭代最多需要 7 秒,那么总耗时可能达到 35 秒,比期望的 30 秒多出约 17%。
  • 阻塞主线程:在主线程中进行此类处理并不是好主意,因为它会长时间阻塞主线程。这类操作应与主线程解耦。

在下一节中,我们将讨论基于中断(interrupt)机制的方法,它能有效避免上述限制。

3. 使用中断机制

这里,我们将使用一个独立线程来执行长时间运行的操作。主线程在超时时向工作线程发送中断信号。

如果工作线程仍在运行,它会捕获该信号并停止执行;如果工作线程在超时前已完成,则中断不会产生任何影响。

先看一下工作线程的实现:

class LongRunningTask implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < Long.MAX_VALUE; i++) {
            if (Thread.interrupted()) {
                return;
            }
        }
    }
}

这里的 for 循环通过遍历 Long.MAX_VALUE 来模拟一个长时间运行的操作。实际场景中可以是任意其他操作。关键在于要检查中断标志,因为并非所有操作都是可中断的。对于不可中断的操作,我们必须手动检查中断状态。

提示:应在每次迭代中都检查中断标志,以确保线程最多延迟一个迭代周期就能停止执行。

接下来,我们将介绍三种发送中断信号的机制。

3.1. 使用 Timer

我们可以创建一个 TimerTask,在超时时中断工作线程:

class TimeOutTask extends TimerTask {
    private Thread thread;
    private Timer timer;

    public TimeOutTask(Thread thread, Timer timer) {
        this.thread = thread;
        this.timer = timer;
    }

    @Override
    public void run() {
        if (thread != null && thread.isAlive()) {
            thread.interrupt();
            timer.cancel();
        }
    }
}

这里定义了一个 TimerTask,它在创建时接收一个工作线程。当其 run() 方法被调用时,会中断该工作线程。Timer 将在 3 秒后触发这个 TimerTask

Thread thread = new Thread(new LongRunningTask());
thread.start();

Timer timer = new Timer();
TimeOutTask timeOutTask = new TimeOutTask(thread, timer);
timer.schedule(timeOutTask, 3000);

3.2. 使用 Future#get 方法

我们也可以使用 Futureget 方法替代 Timer

ExecutorService executor = Executors.newSingleThreadExecutor();
Future future = executor.submit(new LongRunningTask());
try {
    future.get(7, TimeUnit.SECONDS);
} catch (TimeoutException e) {
    future.cancel(true);
} catch (Exception e) {
    // 处理其他异常
} finally {
    executor.shutdownNow();
}

这里我们使用 ExecutorService 提交工作线程,返回一个 Future 实例。调用其 get 方法会使主线程阻塞,直到指定时间结束。若超时,则抛出 TimeoutException。在 catch 块中,我们通过调用 Futurecancel(true) 方法来中断工作线程。

相比前一种方法,这种方法的主要优势在于它使用线程池管理线程,而 Timer 仅使用单一线程(无池化)。

3.3. 使用 ScheduledExecutorService

我们还可以使用 ScheduledExecutorService 来中断任务。它是 ExecutorService 的扩展,增加了调度执行的功能,可以在指定延迟后执行给定任务:

ScheduledExecutorService executor = Executors.newScheduledThreadPool(2);
Future future = executor.submit(new LongRunningTask());
Runnable cancelTask = () -> future.cancel(true);

executor.schedule(cancelTask, 3000, TimeUnit.MILLISECONDS);
executor.shutdown();

这里我们通过 newScheduledThreadPool(2) 创建了一个大小为 2 的调度线程池。ScheduledExecutorService#schedule 方法接收一个 Runnable、延迟时间和时间单位。

上述程序会在提交任务后的 3 秒安排一个取消任务,该任务将取消原始的长时间运行任务。

注意:与上一种方法不同,这里没有通过调用 Future#get 阻塞主线程。因此,这是上述方法中最推荐的方式。

4. 是否有保证?

无法保证任务一定会在指定时间后停止。主要原因在于,并非所有阻塞方法都是可中断的。事实上,只有少数明确定义的方法支持中断。

因此,即使线程被中断并设置了中断标志,除非它执行到某个可中断的方法,否则不会有任何反应。

例如,readwrite 方法只有在由 InterruptibleChannel 创建的流上调用时才是可中断的。而 BufferedReader 并非 InterruptibleChannel,所以如果线程因调用其 read 方法而阻塞,此时调用 interrupt() 将毫无效果。

不过,我们可以在每次读取后显式检查中断标志。这能在一定程度上确保线程在稍有延迟后停止,但仍无法严格保证在精确时间点终止,因为我们无法预知一次读操作可能耗时多久。

另一方面,Object 类的 wait 方法是可中断的。因此,当线程因调用 wait 而阻塞时,一旦设置中断标志,会立即抛出 InterruptedException

我们可以通过查看方法签名中是否声明 throws InterruptedException 来识别哪些方法是可中断的。

重要建议:避免使用已废弃的 Thread.stop() 方法。该方法会导致线程释放其持有的所有监视器锁(由于 ThreadDeath 异常沿调用栈传播)。如果这些监视器保护的对象处于不一致状态,其他线程就可能看到这些不一致的对象,从而引发难以检测和推理的任意行为。

5. 为中断设计

上一节强调了使用可中断方法对及时停止执行的重要性。因此,我们的代码应从设计层面考虑这一需求。

假设我们有一个长时间运行的任务,且必须确保其不超过指定时间。同时,该任务可被拆分为多个步骤。

首先,我们为任务步骤创建一个类:

class Step {
    private static int MAX = Integer.MAX_VALUE / 2;
    int number;

    public Step(int number) {
        this.number = number;
    }

    public void perform() throws InterruptedException {
        Random rnd = new Random();
        int target = rnd.nextInt(MAX);
        while (rnd.nextInt(MAX) != target) {
            if (Thread.interrupted()) {
                throw new InterruptedException();
            }
        }
    }
}

Step#perform 方法尝试查找一个目标随机整数,并在每次迭代中检查中断标志。一旦检测到中断,就抛出 InterruptedException

接着,定义执行所有步骤的任务:

public class SteppedTask implements Runnable {
    private List<Step> steps;

    public SteppedTask(List<Step> steps) {
        this.steps = steps;
    }

    @Override
    public void run() {
        for (Step step : steps) {
            try {
                step.perform();
            } catch (InterruptedException e) {
                // 处理中断异常
                return;
            }
        }
    }
}

SteppedTask 包含一个步骤列表。for 循环依次执行每个步骤,并在捕获到 InterruptedException 时立即停止任务。

最后,看一个使用可中断任务的示例:

List<Step> steps = Stream.of(
    new Step(1),
    new Step(2),
    new Step(3),
    new Step(4)
).collect(Collectors.toList());

Thread thread = new Thread(new SteppedTask(steps));
thread.start();

Timer timer = new Timer();
TimeOutTask timeOutTask = new TimeOutTask(thread, timer);
timer.schedule(timeOutTask, 10000);

首先,我们创建一个包含四个步骤的 SteppedTask;其次,通过线程运行该任务;最后,使用 TimerTimeOutTask 在 10 秒后中断该线程。

通过这种设计,我们可以确保长时间运行的任务在执行任意步骤时都能响应中断。如前所述,虽然仍无法保证在精确时间点停止,但相比不可中断的任务已有显著改进。

6. 结论

在本教程中,我们学习了多种在指定时间后停止执行的技术,并分析了各自的优缺点。