1. 概述
Java 中的Executor Framework试图将任务提交与任务执行分离开来。虽然这种方法很好地抽象了任务执行的细节,但有时我们仍然需要对其进行配置,以实现更优化的执行。
在本文中,我们将了解当线程池无法再接受任何任务时会发生什么。然后,我们将学习如何通过适当应用饱和策略来控制这种特殊情况。 Java 线程拒绝策略指的是当线程池中的所有线程都处于繁忙状态,且队列也已满时,如何处理新提交的任务。这是一种重要的机制,用于防止系统因任务堆积而崩溃。
Java 提供了四种内置的拒绝策略,可以通过 ThreadPoolExecutor 的 RejectedExecutionHandler 属性进行设置:
1. AbortPolicy (默认策略)
- 当任务被拒绝时,抛出 RejectedExecutionException 异常。
- 这是默认的拒绝策略,会导致程序直接崩溃。
2. DiscardPolicy
- 当任务被拒绝时,直接丢弃任务,不进行任何处理。
3. DiscardOldestPolicy
- 当任务被拒绝时,移除队列中最旧的任务,并尝试重新提交新任务。
4. CallerRunsPolicy
- 当任务被拒绝时,由提交任务的线程执行该任务。
- 这可以有效地降低任务提交速率,防止系统过载。
5. 自定义拒绝策略
除了内置的拒绝策略,还可以自定义拒绝策略来满足更复杂的需求。
- 实现 RejectedExecutionHandler 接口,并重写 rejectedExecution 方法,在该方法中实现自己的拒绝策略逻辑。
- 例如,可以将被拒绝的任务写入日志文件、保存到数据库,或者使用其他线程池进行处理。
6. 选择合适的拒绝策略
选择合适的拒绝策略取决于具体的应用场景,需要综合考虑以下因素:
- 任务的重要性: 如果任务非常重要,则应该选择 AbortPolicy 或 DiscardOldestPolicy,避免任务丢失。
- 系统负载: 如果系统负载很高,则应该选择 DiscardPolicy 或 CallerRunsPolicy,避免系统过载。
- 任务类型: 如果任务是幂等的,即多次执行不会造成副作用,则可以选择 DiscardPolicy。
2. 重新审视线程池
下图展示了线程执行器服务的内部工作原理:
当我们向执行器提交新任务时会发生以下情况:
- 如果其中一个线程可用,它就会处理该任务。
- 否则,执行器会将新任务添加到其队列中。
- 当线程完成当前任务时,它会从队列中选取另一个任务。
2.1. ThreadPoolExecutor
大多数执行器实现都使用众所周知的ThreadPoolExecutor 作为其基本实现。因此,为了更好地理解任务排队的工作原理,我们应该仔细看看它的构造函数:
java
public ThreadPoolExecutor(
int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
RejectedExecutionHandler handler
)
2.2. 核心池大小
corePoolSize参数 决定线程池的初始大小。通常,执行器会确保线程池至少包含corePoolSize个线程。
但是,如果我们启用allowCoreThreadTimeOut 参数,则可能会有更少的线程。
2.3. 最大池大小
假设所有核心线程都在忙于执行一些任务。因此,执行器将新任务放入队列,直到有机会稍后处理它们。
当此队列已满时,执行器可以向线程池添加更多线程。maximumPoolSize设置 了线程池可能包含的线程数的上限。**
当这些线程空闲一段时间后,执行器可以将它们从池中移除。因此,池大小可以缩小回其核心大小。
2.4. 排队
如前所述,当所有核心线程都处于繁忙状态时,执行器会将新任务添加到队列中。排队有三种不同的方法:
- 无界队列 :队列可以容纳无限数量的任务。由于此队列永远不会填满,因此执行器会忽略最大大小。固定大小和单线程执行器都使用此方法。
- 有界队列 : 顾名思义,队列只能容纳有限数量的任务。因此,当有界队列填满时,线程池就会增长。
- 同步切换 :令人惊讶的是,这个队列不能容纳任何任务!使用这种方法,当且仅当另一侧有另一个线程同时选择同一任务时,我们才能将任务排队 。缓存线程池执行器在内部使用此方法。
让我们假设在使用有界排队或同步切换时的以下场景:
- 所有核心线程都处于繁忙状态
- 内部队列已满
- 线程池增长到其最大可能大小,并且所有这些线程也都很忙
当有新任务进来时会发生什么?
3. 饱和政策
当所有线程都处于繁忙状态并且内部队列已满时,执行器就会饱和。
一旦达到饱和状态,执行器就可以执行预定义的操作。这些操作称为饱和策略。我们可以通过将RejectedExecutionHandler 的实例传递给其构造函数来修改执行器的饱和策略。* 幸运的是,Java 为此类提供了一些内置实现,每个实现都涵盖了特定的用例。在以下部分中,我们将详细评估这些策略。
3.1. 中止政策
默认策略是中止策略。中止策略会导致执行器抛出 RejectedExecutionException:
java
ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 0, MILLISECONDS,
new SynchronousQueue<>(),
new ThreadPoolExecutor.AbortPolicy());
executor.execute(() -> waitFor(250));
assertThatThrownBy(() -> executor.execute(() -> System.out.println("Will be rejected")))
.isInstanceOf(RejectedExecutionException.class);复制
由于第一个任务需要很长时间才能执行,因此执行器拒绝第二个任务。
3.2. 调用者运行策略
此策略不是在另一个线程中异步运行任务,而是让调用者线程执行该任务:
java
ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 0, MILLISECONDS,
new SynchronousQueue<>(),
new ThreadPoolExecutor.CallerRunsPolicy());
executor.execute(() -> waitFor(250));
long startTime = System.currentTimeMillis();
executor.execute(() -> waitFor(500));
long blockedDuration = System.currentTimeMillis() - startTime;
assertThat(blockedDuration).isGreaterThanOrEqualTo(500);
提交第一个任务后,执行器无法再接受任何新任务。因此,调用者线程会阻塞,直到第二个任务返回。
调用者运行策略 可以轻松实现一种简单的节流形式。也就是说,慢速消费者可以减慢快速生产者的速度,以控制任务提交流。
3.3. 丢弃策略
丢弃策略 在提交失败时默认丢弃新任务:
java
ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 0, MILLISECONDS,
new SynchronousQueue<>(),
new ThreadPoolExecutor.DiscardPolicy());
executor.execute(() -> waitFor(100));
BlockingQueue<String> queue = new LinkedBlockingDeque<>();
executor.execute(() -> queue.offer("Discarded Result"));
assertThat(queue.poll(200, MILLISECONDS)).isNull();复制
这里,第二个任务向队列发布一条简单消息。由于它永远没有机会执行,因此即使我们阻塞了一段时间,队列仍然是空的。
3.4. 丢弃最旧策略
丢弃最旧策略 首先从队列头部删除一个任务,然后重新提交新任务:
java
ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 0, MILLISECONDS,
new ArrayBlockingQueue<>(2),
new ThreadPoolExecutor.DiscardOldestPolicy());
executor.execute(() -> waitFor(100));
BlockingQueue<String> queue = new LinkedBlockingDeque<>();
executor.execute(() -> queue.offer("First"));
executor.execute(() -> queue.offer("Second"));
executor.execute(() -> queue.offer("Third"));
waitFor(150);
List<String> results = new ArrayList<>();
queue.drainTo(results);
assertThat(results).containsExactlyInAnyOrder("Second", "Third");
这次,我们使用了只能容纳两个任务的有界队列。提交这四个任务时会发生以下情况:
- 第一个任务占用单线程 100 毫秒
- 执行器成功将第二和第三个任务加入队列
- 当第四个任务到达时,丢弃最旧策略会删除最旧的任务,为新任务腾出空间
丢弃最旧策略和优先级队列不能很好地协同工作。 由于优先级队列的头部具有最高优先级,我们可能会丢失最重要的任务。
3.5. 自定义策略
也可以通过实现*RejectedExecutionHandler*接口来提供自定义饱和策略:
java
class GrowPolicy implements RejectedExecutionHandler {
private final Lock lock = new ReentrantLock();
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
lock.lock();
try {
executor.setMaximumPoolSize(executor.getMaximumPoolSize() + 1);
} finally {
lock.unlock();
}
executor.submit(r);
}
}
在此示例中,当执行器饱和时,我们将最大池大小增加一,然后重新提交相同的任务:
java
ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 0, MILLISECONDS,
new ArrayBlockingQueue<>(2),
new GrowPolicy());
executor.execute(() -> waitFor(100));
BlockingQueue<String> queue = new LinkedBlockingDeque<>();
executor.execute(() -> queue.offer("First"));
executor.execute(() -> queue.offer("Second"));
executor.execute(() -> queue.offer("Third"));
waitFor(150);
List<String> results = new ArrayList<>();
queue.drainTo(results);
assertThat(results).contains("First", "Second", "Third");
正如预期的那样,所有四个任务均已执行。
3.6. 关机
除了过载的执行器外,饱和策略还适用于所有已关闭的执行器:
java
ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 0, MILLISECONDS, new LinkedBlockingQueue<>());
executor.shutdownNow();
assertThatThrownBy(() -> executor.execute(() -> {}))
.isInstanceOf(RejectedExecutionException.class);
对于所有处于关闭状态的执行器来说也是如此:
java
ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 0, MILLISECONDS, new LinkedBlockingQueue<>());
executor.execute(() -> waitFor(100));
executor.shutdown();
assertThatThrownBy(() -> executor.execute(() -> {}))
.isInstanceOf(RejectedExecutionException.class);
4. 结论
在本教程中,我们首先快速回顾了 Java 中的线程池。然后,在介绍了饱和执行器之后,我们学习了如何以及何时应用不同的饱和策略。