MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java 定时及周期执行线程池的机制

2023-09-306.1k 阅读

Java 定时及周期执行线程池的机制

线程池基础知识回顾

在深入探讨 Java 定时及周期执行线程池机制之前,我们先来回顾一下线程池的基本概念。线程池是一种基于池化思想管理线程的工具,它的主要目的是减少在创建和销毁线程上所花费的时间以及系统资源的开销,提高应用程序的性能。

在 Java 中,线程池的核心实现类是 ThreadPoolExecutor,通过 Executors 工具类可以方便地创建不同类型的线程池。例如,Executors.newFixedThreadPool(int nThreads) 创建一个固定大小的线程池,Executors.newCachedThreadPool() 创建一个可缓存的线程池等。

线程池内部维护了多个线程,这些线程可以重复执行提交给线程池的任务。任务以 RunnableCallable 对象的形式提交给线程池。当一个任务提交到线程池时,线程池会根据自身的状态和配置来决定如何处理这个任务:

  1. 核心线程:如果当前运行的线程数少于核心线程数,线程池会创建新的线程来执行任务。
  2. 工作队列:如果当前运行的线程数达到核心线程数,任务会被放入工作队列中等待执行。
  3. 最大线程数:如果工作队列已满,且当前运行的线程数少于最大线程数,线程池会创建新的线程来执行任务。
  4. 拒绝策略:如果工作队列已满,且当前运行的线程数达到最大线程数,线程池会根据设定的拒绝策略来处理新提交的任务。常见的拒绝策略有 AbortPolicy(抛出异常)、CallerRunsPolicy(由提交任务的线程执行任务)、DiscardPolicy(丢弃任务)和 DiscardOldestPolicy(丢弃队列中最老的任务)。

定时任务执行机制概述

Java 提供了多种方式来实现定时任务的执行,而基于线程池的定时任务执行是其中一种高效且灵活的方式。在 Java 中,主要通过 ScheduledExecutorService 接口及其实现类 ScheduledThreadPoolExecutor 来实现定时及周期执行任务。

ScheduledExecutorService 继承自 ExecutorService,它扩展了 ExecutorService 的功能,允许任务在指定的延迟后执行,或者以固定的时间间隔周期性地执行。

ScheduledExecutorService 接口

ScheduledExecutorService 接口定义了以下几个重要的方法来实现定时任务:

  1. ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit):该方法用于在指定的延迟时间 delay 后执行给定的 Runnable 任务。返回的 ScheduledFuture 可以用于取消任务或获取任务执行的结果(由于 Runnable 没有返回值,这里获取的结果为 null)。
  2. <V> ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit):此方法与上一个类似,但是它用于执行 Callable 任务,Callable 任务有返回值,ScheduledFuture 可以获取到这个返回值。同样,任务会在指定的延迟时间 delay 后执行。
  3. ScheduledFuture<?> scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit):该方法用于以固定的速率周期性地执行任务。任务会在延迟 initialDelay 后开始执行,之后每隔 period 时间就会执行一次。如果任务执行的时间超过了 period,下一次执行会在任务执行完成后立即开始,而不会等待到 period 时间结束。
  4. ScheduledFuture<?> scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit):这个方法也是用于周期性地执行任务。任务在延迟 initialDelay 后开始执行,之后每次执行完成后,会再延迟 delay 时间才开始下一次执行。

ScheduledThreadPoolExecutor 类

ScheduledThreadPoolExecutorScheduledExecutorService 接口的实现类,它继承自 ThreadPoolExecutor,因此具备线程池的所有特性。ScheduledThreadPoolExecutor 使用了一个延迟队列(DelayedWorkQueue)来管理定时任务。

ScheduledThreadPoolExecutor 的构造函数允许我们指定核心线程数,例如:

ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(5);

这里创建了一个核心线程数为 5 的 ScheduledThreadPoolExecutor

定时任务执行原理

ScheduledThreadPoolExecutor 内部的延迟队列 DelayedWorkQueue 按照任务的执行时间进行排序。当一个定时任务被提交到 ScheduledThreadPoolExecutor 时,它会被封装成 ScheduledFutureTask 并放入延迟队列中。

ScheduledThreadPoolExecutor 的工作线程会不断地从延迟队列中获取任务。如果获取到的任务的执行时间还未到达,工作线程会等待。当任务的执行时间到达时,工作线程会取出任务并执行。

对于周期性任务,执行完成后,会根据任务的执行模式(固定速率或固定延迟)重新计算下一次的执行时间,并再次放入延迟队列中。

代码示例 - 简单定时任务

下面是一个使用 ScheduledExecutorService 执行简单定时任务的示例:

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class SimpleScheduledTask {
    public static void main(String[] args) {
        ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);

        Runnable task = () -> System.out.println("Task executed at " + System.currentTimeMillis());

        // 在 2 秒后执行任务
        executor.schedule(task, 2, TimeUnit.SECONDS);

        // 关闭线程池
        executor.shutdown();
    }
}

在这个示例中,我们创建了一个单线程的 ScheduledExecutorService,并提交了一个 Runnable 任务,该任务会在延迟 2 秒后执行,输出当前的时间戳。

代码示例 - 周期性任务(固定速率)

以下是使用 ScheduledExecutorService 执行周期性任务(固定速率)的示例:

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class FixedRateScheduledTask {
    public static void main(String[] args) {
        ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);

        Runnable task = () -> {
            long startTime = System.currentTimeMillis();
            System.out.println("Task started at " + startTime);
            // 模拟任务执行时间
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            long endTime = System.currentTimeMillis();
            System.out.println("Task ended at " + endTime + ", execution time: " + (endTime - startTime) + " ms");
        };

        // 延迟 1 秒后开始执行,之后每隔 3 秒执行一次
        executor.scheduleAtFixedRate(task, 1, 3, TimeUnit.SECONDS);
    }
}

在这个示例中,任务会在延迟 1 秒后开始执行,之后每隔 3 秒执行一次。任务执行过程中模拟了 2 秒的执行时间,通过输出任务开始和结束的时间以及执行时间,可以观察到固定速率执行的特点。如果任务执行时间超过了设定的周期时间(这里是 3 秒),下一次执行会在任务执行完成后立即开始。

代码示例 - 周期性任务(固定延迟)

下面是一个使用 ScheduledExecutorService 执行周期性任务(固定延迟)的示例:

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class FixedDelayScheduledTask {
    public static void main(String[] args) {
        ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);

        Runnable task = () -> {
            long startTime = System.currentTimeMillis();
            System.out.println("Task started at " + startTime);
            // 模拟任务执行时间
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            long endTime = System.currentTimeMillis();
            System.out.println("Task ended at " + endTime + ", execution time: " + (endTime - startTime) + " ms");
        };

        // 延迟 1 秒后开始执行,每次执行完成后延迟 3 秒再执行下一次
        executor.scheduleWithFixedDelay(task, 1, 3, TimeUnit.SECONDS);
    }
}

在这个示例中,任务同样延迟 1 秒后开始执行,但不同的是,每次任务执行完成后,会延迟 3 秒再开始下一次执行。通过观察任务的开始和结束时间以及执行时间,可以清晰地看到固定延迟执行的特点。

定时任务的取消

ScheduledFuture 接口继承自 Future 接口,它提供了取消任务的方法。例如,对于通过 schedule 方法提交的任务,可以通过返回的 ScheduledFuture 来取消任务:

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;

public class CancelScheduledTask {
    public static void main(String[] args) {
        ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);

        Runnable task = () -> System.out.println("Task executed");

        ScheduledFuture<?> future = executor.schedule(task, 5, TimeUnit.SECONDS);

        // 在 3 秒后尝试取消任务
        try {
            Thread.sleep(3000);
            boolean cancelled = future.cancel(true);
            if (cancelled) {
                System.out.println("Task cancelled");
            } else {
                System.out.println("Task could not be cancelled");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        executor.shutdown();
    }
}

在这个示例中,我们提交了一个延迟 5 秒执行的任务,然后在 3 秒后尝试取消任务。cancel 方法的参数 mayInterruptIfRunning 如果为 true,表示如果任务正在执行,会尝试中断任务的执行。

线程池大小与定时任务的关系

在使用 ScheduledThreadPoolExecutor 时,合理设置线程池的大小非常重要。核心线程数决定了线程池在正常情况下可以同时执行的任务数量。

如果核心线程数设置过小,当有大量定时任务同时需要执行时,可能会导致任务在队列中等待过长时间,影响任务的执行及时性。例如,在一个需要处理多个定时任务的系统中,如果核心线程数设置为 1,而同时有多个任务需要执行,这些任务只能依次排队执行,可能会造成任务的延迟。

另一方面,如果核心线程数设置过大,会占用过多的系统资源,增加系统的开销。因为每个线程都需要占用一定的内存空间,并且线程的上下文切换也会消耗系统资源。

对于周期性任务,还需要考虑任务的执行时间和周期时间。如果任务执行时间较长,而周期时间较短,并且核心线程数不足,可能会导致任务堆积在队列中,甚至触发拒绝策略。

例如,假设有一个系统需要执行多个周期性任务,每个任务执行时间大约为 10 秒,周期时间为 15 秒。如果核心线程数设置为 1,那么第一个任务执行完 10 秒后,下一个任务要等待 5 秒才能开始执行,而在这期间如果又有新的任务到达,就会进入队列等待。如果同时到达的任务过多,队列可能会满,从而触发拒绝策略。

因此,在设置 ScheduledThreadPoolExecutor 的核心线程数时,需要综合考虑任务的执行时间、周期时间以及系统的负载情况,以确保定时任务能够高效、稳定地执行。

异常处理

在定时任务执行过程中,可能会出现各种异常。对于 Runnable 任务,如果在执行过程中抛出异常,默认情况下,异常不会被捕获并处理,这可能导致任务中断,并且线程池不会再自动重新执行该任务。

为了处理这种情况,可以在 Runnable 任务内部进行异常捕获和处理。例如:

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class ExceptionHandlingInTask {
    public static void main(String[] args) {
        ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);

        Runnable task = () -> {
            try {
                // 模拟可能抛出异常的操作
                int result = 10 / 0;
                System.out.println("Task executed successfully: " + result);
            } catch (ArithmeticException e) {
                System.out.println("Exception occurred: " + e.getMessage());
            }
        };

        executor.scheduleAtFixedRate(task, 0, 1, TimeUnit.SECONDS);
    }
}

在这个示例中,任务内部捕获了 ArithmeticException 异常,并进行了相应的处理,这样即使任务执行过程中出现异常,也不会导致任务终止,线程池会继续按照设定的周期执行任务。

对于 Callable 任务,异常可以通过 Futureget 方法获取。例如:

import java.util.concurrent.*;

public class ExceptionHandlingInCallableTask {
    public static void main(String[] args) {
        ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);

        Callable<Integer> task = () -> {
            // 模拟可能抛出异常的操作
            return 10 / 0;
        };

        ScheduledFuture<Integer> future = executor.schedule(task, 1, TimeUnit.SECONDS);

        try {
            Integer result = future.get();
            System.out.println("Task executed successfully: " + result);
        } catch (InterruptedException | ExecutionException e) {
            System.out.println("Exception occurred: " + e.getMessage());
        }

        executor.shutdown();
    }
}

在这个示例中,通过 future.get() 获取任务执行结果时,如果任务执行过程中抛出异常,get 方法会抛出 ExecutionException,我们可以在捕获该异常时进行相应的处理。

与 Timer 和 TimerTask 的比较

在 Java 中,除了使用 ScheduledExecutorService 来实现定时任务,还可以使用 TimerTimerTaskTimer 是一个简单的定时器类,TimerTask 是一个抽象类,需要继承它来定义具体的任务。

单线程执行

Timer 内部使用一个单线程来执行任务。这意味着如果一个任务执行时间过长,会影响后续任务的执行。例如,如果有两个任务,第一个任务执行时间为 10 秒,第二个任务延迟 5 秒执行,那么第二个任务会在第一个任务执行完成后才开始执行,而不是在延迟 5 秒后就开始执行。

ScheduledThreadPoolExecutor 可以通过设置核心线程数来并行执行多个任务,提高任务执行的效率。

异常处理

Timer 在任务执行过程中抛出异常时,会导致 Timer 线程终止,并且不会自动重新执行任务。而且,Timer 没有提供方便的机制来捕获和处理任务执行过程中的异常。

相比之下,ScheduledExecutorService 可以通过在任务内部捕获异常或者通过 Futureget 方法获取异常,更好地处理任务执行过程中的异常情况。

任务调度准确性

ScheduledExecutorService 使用延迟队列来管理任务,任务的调度更加准确。而 Timer 在任务执行时间较长或者系统负载较高时,可能会出现任务调度不准确的情况。

综上所述,ScheduledExecutorService 在功能和性能上都优于 TimerTimerTask,在实际开发中,推荐使用 ScheduledExecutorService 来实现定时及周期执行任务。

实际应用场景

  1. 数据备份:定期将数据库中的数据备份到文件或者其他存储介质中。可以使用 ScheduledExecutorService 设置每天凌晨 2 点执行备份任务,以避免影响系统的正常使用。
  2. 缓存刷新:对于一些缓存数据,需要定期刷新以保证数据的实时性。例如,缓存了一些热门商品的信息,每隔 10 分钟刷新一次缓存,确保用户看到的是最新的商品信息。
  3. 日志清理:定期清理系统日志文件,避免日志文件过大占用过多的磁盘空间。可以设置每周日凌晨 3 点删除一周前的日志文件。

总结

通过深入了解 Java 定时及周期执行线程池的机制,我们学习了 ScheduledExecutorService 接口及其实现类 ScheduledThreadPoolExecutor 的使用方法,包括定时任务和周期性任务的执行、任务的取消、线程池大小的设置、异常处理等方面。同时,对比了 ScheduledExecutorServiceTimerTimerTask 的优缺点,明确了在实际应用中推荐使用 ScheduledExecutorService 的原因。

在实际开发中,根据具体的业务需求,合理地使用定时及周期执行线程池机制,可以提高系统的稳定性和效率,为应用程序的开发提供强大的支持。希望通过本文的介绍,读者能够更好地掌握和运用这一重要的 Java 特性。