Java 中线程池

82 阅读12分钟

1. 概述

本文章内容主要介绍了Java中的线程池。我们将从标准Java库中的不同实现开始,然后看看Google的Guava库。 线程池就是管理一系列线程的资源池,其提供了一种限制和管理线程资源的方式。每个线程池还维护一些基本统计信息,例如已完成任务的数量。

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
  • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

线程池一般用于执行多个不相关联的耗时任务,没有多线程的情况下,任务顺序执行,使用了线程池的话可让多个不相关联的任务同时执行。

2. 线程池

在 Java 中,线程映射到系统级线程,这些线程是操作系统的资源。如果我们不受控制地创建线程,我们可能会很快耗尽这些资源。

操作系统也会在线程之间进行上下文切换,以模拟并行性。一个简单化的观点是,我们生成的线程越多,每个线程花在实际工作上的时间就越少。

线程池模式有助于在多线程应用程序中节省资源,并将并行性包含在某些预定义的限制中。

当我们使用线程池时,我们以并行任务的形式编写并发代码,并将它们提交到线程池的实例执行。此实例控制用于执行这些任务的多个重用线程。
2016-08-10_10-16-52-1024x572

该模式允许我们控制应用程序创建的线程数及其生命周期。我们还能够安排任务的执行,并将传入的任务保留在队列中。

3. Thread Pools in Java 3. Java 中的线程池

3.1. ExecutorsExecutor and ExecutorService 3.1. 执行者、执行者和执行者服务

Executors 帮助程序类包含多个用于创建预配置线程池实例的方法。这些课程是一个很好的起点。如果我们不需要应用任何自定义微调,我们可以使用它们。

我们使用 Executor 和 ExecutorService 接口来处理 Java 中的不同线程池实现。通常,我们应该将代码与线程池的实际实现分离,并在整个应用程序中使用这些接口。

3.1.1. Executor 3.1.1. 执行者

Executor 接口具有单个执行方法,用于提交 Runnable 实例以供执行。**

让我们看一个快速示例,说明如何使用 Executors API 获取由单线程池和无界队列支持的 Executor 实例,以便按顺序执行任务。

在这里,我们运行一个任务,只需在屏幕上打印“Hello World”即可。我们将任务作为 lambda(Java 8 功能)提交,该功能被推断为可运行的:

Executor executor = Executors.newSingleThreadExecutor();
executor.execute(() -> System.out.println("Hello World"));Copy

3.1.2. ExecutorService 3.1.2. 执行器服务

ExecutorService 接口包含大量用于控制任务进度和管理服务终止的方法。使用此接口,我们可以提交要执行的任务,还可以使用返回的 Future 实例控制它们的执行。

现在,我们将创建一个 ExecutorService,提交一个任务,然后使用返回的 Future 的 get 方法等待提交的任务完成并返回值:

ExecutorService executorService = Executors.newFixedThreadPool(10);
Future<String> future = executorService.submit(() -> "Hello World");
// some operations
String result = future.get();Copy

当然,在现实生活中,我们通常不想立即调用 future.get(),而是推迟调用它,直到我们真正需要计算值。

在这里,我们重载 submit 方法以采用 Runnable 或 Callable。这两者都是函数式接口,我们可以将它们作为 lambda 传递(从 Java 8 开始)。

Runnable 的单个方法不会引发异常,也不会返回值。Callable 接口可能更方便,因为它允许我们抛出异常并返回值。

最后,要让编译器推断 Callable 类型,只需从 lambda 中返回一个值即可。

有关使用 ExecutorService 接口和 future 的更多示例,请查看 Java ExecutorService 指南。

3.2. ThreadPoolExecutor 3.2. ThreadPool执行器

ThreadPoolExecutor 是一个可扩展的线程池实现,具有许多用于微调的参数和钩子。

我们将在这里讨论的主要配置参数是 corePoolSize、maximumPoolSize 和 keepAliveTime。

池由固定数量的核心线程组成,这些线程始终保留在内部。它还包含一些过多的线程,这些线程可能会生成,然后在不再需要它们时终止。

corePoolSize 参数是将实例化并保留在池中的核心线程数。当新任务传入时,如果所有核心线程都处于繁忙状态并且内部队列已满,则允许池增长到 maximumPoolSize。 allowCoreThreadTimeOut(true)* method.
keepAliveTime 参数是允许过多线程(实例化超过 corePoolSize)处于空闲状态的时间间隔。默认情况下,ThreadPoolExecutor 仅考虑要删除的非核心线程。为了将相同的删除策略应用于核心线程,我们可以使用 allowCoreThreadTimeOut(true) 方法。

这些参数涵盖了广泛的用例,但最典型的配置是在 Executor 静态方法中预定义的。

3.2.1. newFixedThreadPool

让我们看一个例子。newFixedThreadPool 方法创建一个 ThreadPoolExecutor,其 corePoolSize 和 maximumPoolSize 参数值相等,keepAliveTime 为零。这意味着此线程池中的线程数始终相同:

ThreadPoolExecutor executor = 
  (ThreadPoolExecutor) Executors.newFixedThreadPool(2);
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});

assertEquals(2, executor.getPoolSize());
assertEquals(1, executor.getQueue().size());Copy

在这里,我们实例化一个固定线程计数为 2 的 ThreadPoolExecutor。这意味着,如果同时运行的任务数始终小于或等于 2,则会立即执行它们。否则,其中一些任务可能会被放入队列中以等待轮到它们。

我们创建了三个 Callable 任务,通过睡眠 1000 毫秒来模仿繁重的工作。前两个任务将同时运行,第三个任务必须在队列中等待。我们可以通过在提交任务后立即调用 getPoolSize() 和 getQueue().size() 方法来验证它。

3.2.2. Executors.newCachedThreadPool()

我们可以使用 Executors.newCachedThreadPool() 方法创建另一个预配置的 ThreadPoolExecutor。此方法根本不接收多个线程。我们将 corePoolSize 设置为 0,并将 maximumPoolSize 设置为 Integer.MAX_VALUE。最后,keepAliveTime 为 60 秒:

ThreadPoolExecutor executor = 
  (ThreadPoolExecutor) Executors.newCachedThreadPool();
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});

assertEquals(3, executor.getPoolSize());
assertEquals(0, executor.getQueue().size());Copy

这些参数值意味着缓存的线程池可以无限制地增长,以容纳任意数量的已提交任务。但是,当不再需要线程时,它们将在 60 秒不活动后被处理掉。一个典型的用例是当我们的应用程序中有很多短期任务时。

队列大小将始终为零,因为在内部使用了 SynchronousQueue 实例。在 SynchronousQueue 中,插入和删除操作对始终同时发生。因此,队列实际上从不包含任何内容。

3.2.3. Executors.newSingleThreadExecutor()

Executors.newSingleThreadExecutor() API 创建包含单个线程的另一种典型形式的 ThreadPoolExecutor。单线程执行器是创建事件循环的理想选择。corePoolSize 和 maximumPoolSize 参数等于 1,keepAliveTime 为 0。

上面示例中的任务将按顺序运行,因此任务完成后的标志值将为 2:

AtomicInteger counter = new AtomicInteger();

ExecutorService executor = Executors.newSingleThreadExecutor();
executor.submit(() -> {
    counter.set(1);
});
executor.submit(() -> {
    counter.compareAndSet(1, 2);
});Copy

此外,此 ThreadPoolExecutor 使用不可变包装器进行修饰,因此在创建后无法重新配置。请注意,这也是我们无法将其强制转换为 ThreadPoolExecutor 的原因。

3.3. ScheduledThreadPoolExecutor

ScheduledThreadPoolExecutor 扩展了 ThreadPoolExecutor 类,还使用几个附加方法实现 ScheduledExecutorService 接口:

  • schedule 方法允许我们在指定的延迟后运行一次任务。
  • scheduleAtFixedRate 方法允许我们在指定的初始延迟后运行任务,然后在一定时间段内重复运行它。period 参数是在任务开始时间之间测量的时间,因此执行速率是固定的。
  • scheduleWithFixedDelay 方法类似于 scheduleAtFixedRate,因为它重复运行给定的任务,但指定的延迟是在上一个任务的结束和下一个任务的开始之间测量的。执行速率可能因运行任何给定任务所需的时间而异。

我们通常使用 Executors.newScheduledThreadPool() 方法创建一个具有给定 corePoolSize、无界 maximumPoolSize 和零 keepAliveTime 的 ScheduledThreadPoolExecutor。

下面介绍如何在 500 毫秒内计划执行任务:

ScheduledExecutorService executor = Executors.newScheduledThreadPool(5);
executor.schedule(() -> {
    System.out.println("Hello World");
}, 500, TimeUnit.MILLISECONDS);Copy

以下代码演示如何在 500 毫秒延迟后运行任务,然后每 100 毫秒重复一次。安排任务后,我们使用 CountDownLatch 锁等待它触发三次。然后我们使用 Future.cancel() 方法取消它:

CountDownLatch lock = new CountDownLatch(3);

ScheduledExecutorService executor = Executors.newScheduledThreadPool(5);
ScheduledFuture<?> future = executor.scheduleAtFixedRate(() -> {
    System.out.println("Hello World");
    lock.countDown();
}, 500, 100, TimeUnit.MILLISECONDS);

lock.await(1000, TimeUnit.MILLISECONDS);
future.cancel(true);Copy

3.4. ForkJoinPool

ForkJoinPool 是 Java 7 中引入的 fork/join 框架的核心部分。它解决了在递归算法中生成多个任务的常见问题。我们将使用简单的 ThreadPoolExecutor 快速用完线程,因为每个任务或子任务都需要自己的线程才能运行。

在 fork/join 框架中,任何任务都可以生成(fork)许多子任务,并使用 join 方法等待它们完成。fork/join 框架的好处是它不会为每个任务或子任务创建一个新线程,而是实现工作窃取算法。这个框架在我们的 Java 分叉/连接框架指南中得到了详细的描述。

让我们看一个简单的例子,它使用 ForkJoinPool 遍历节点树并计算所有叶值的总和。下面是一个树的简单实现,该树由一个节点、一个 int 值和一组子节点组成:

static class TreeNode {

    int value;

    Set<TreeNode> children;

    TreeNode(int value, TreeNode... children) {
        this.value = value;
        this.children = Sets.newHashSet(children);
    }
}

现在,如果我们想并行求和树中的所有值,我们需要实现一个 RecursiveTask 接口。每个任务都接收自己的节点,并将其值添加到其子项的值之和中。为了计算子值的总和,任务实现执行以下操作:

  • streams the children set 流子集
  • maps over this stream, creating a new CountingTask for each element
    映射此流,为每个元素创建一个新的 CountingTask
  • runs each subtask by forking it
    通过分叉来运行每个子任务
  • collects the results by calling the join method on each forked task
    通过对每个分叉任务调用 join 方法来收集结果
  • sums the results using the Collectors.summingInt collector
    使用 Collectors.summingInt 收集器对结果求和
public static class CountingTask extends RecursiveTask<Integer> {

    private final TreeNode node;

    public CountingTask(TreeNode node) {
        this.node = node;
    }

    @Override
    protected Integer compute() {
        return node.value + node.children.stream()
          .map(childNode -> new CountingTask(childNode).fork())
          .collect(Collectors.summingInt(ForkJoinTask::join));
    }
}

在实际树上运行计算的代码非常简单:

TreeNode tree = new TreeNode(5,
  new TreeNode(3), new TreeNode(2,
    new TreeNode(2), new TreeNode(8)));

ForkJoinPool forkJoinPool = ForkJoinPool.commonPool();
int sum = forkJoinPool.invoke(new CountingTask(tree));

4. Thread Pool 在 Guava 中的实现

Guava 是一个流行的 Google 实用程序库。它有许多有用的并发类,包括 ExecutorService 的几个方便的实现。实现类无法用于直接实例化或子类化,因此创建其实例的唯一入口点是 MoreExecutors 帮助程序类。

4.1. 添加 Guava 作为 Maven 依赖项

我们将以下依赖项添加到 Maven pom 文件中,以将 Guava 库包含在我们的项目中。在 Maven Central Repository 中查找最新版本的 Guava 库:

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>32.1.3-jre</version>
</dependency>Copy

4.2. Direct Executor 和 Direct Executor Service

有时,我们希望在当前线程或线程池中运行任务,具体取决于某些条件。我们更愿意使用单个 Executor 接口,然后切换实现。尽管想出一个在当前线程中运行任务的 Executor 或 ExecutorService 的实现并不难,但这仍然需要编写一些样板代码。

下面是一个示例,演示了在同一线程中执行任务。尽管提供的任务休眠 500 毫秒,但它会阻塞当前线程,并且结果在执行调用完成后立即可用:

Executor executor = MoreExecutors.directExecutor();

AtomicBoolean executed = new AtomicBoolean();

executor.execute(() -> {
    try {
        Thread.sleep(500);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    executed.set(true);
});

assertTrue(executed.get());Copy

directExecutor() 方法返回的实例实际上是一个静态单例,因此使用此方法根本不会在对象创建时提供任何开销。

我们应该更喜欢这种方法而不是 MoreExecutors.newDirectExecutorService(),因为该 API 在每次调用时都会创建一个成熟的执行器服务实现。

4.3. Exiting Executor Services

另一个常见问题是在线程池仍在运行其任务时关闭虚拟机。即使有取消机制,也不能保证任务在执行器服务关闭时表现良好并停止工作。这可能会导致 JVM 无限期挂起,而任务继续执行其工作。

为了解决这个问题,Guava 引入了一系列现有的执行器服务。它们基于与 JVM 一起终止的守护进程线程。

这些服务还使用 Runtime.getRuntime().addShutdownHook() 方法添加关闭挂钩,并防止 VM 在放弃挂起任务之前终止配置的时间。

在以下示例中,我们提交的任务包含无限循环,但我们使用配置时间为 100 毫秒的现有执行程序服务来等待 VM 终止时的任务。

ThreadPoolExecutor executor = 
  (ThreadPoolExecutor) Executors.newFixedThreadPool(5);
ExecutorService executorService = 
  MoreExecutors.getExitingExecutorService(executor, 
    100, TimeUnit.MILLISECONDS);

executorService.submit(() -> {
    while (true) {
    }
});

如果没有 exitingExecutorService,此任务将导致 VM 无限期挂起。

4.4. Listening Decorators

侦听装饰器允许我们包装 ExecutorService 并在任务提交时接收 ListenableFuture 实例,而不是简单的 Future 实例。ListenableFuture 接口扩展了 Future,并具有一个附加方法 addListener。此方法允许添加在将来完成时调用的侦听器。

我们很少想直接使用 ListenableFuture.addListener() 方法。但它对于 Futures 实用程序类中的大多数帮助程序方法至关重要。

例如,使用 Futures.allAsList() 方法,我们可以将多个 ListenableFuture 实例组合到一个 ListenableFuture 中,该实例在成功完成所有 futures 组合后完成:

ExecutorService executorService = Executors.newCachedThreadPool();
ListeningExecutorService listeningExecutorService = 
  MoreExecutors.listeningDecorator(executorService);

ListenableFuture<String> future1 = 
  listeningExecutorService.submit(() -> "Hello");
ListenableFuture<String> future2 = 
  listeningExecutorService.submit(() -> "World");

String greeting = Futures.allAsList(future1, future2).get()
  .stream()
  .collect(Collectors.joining(" "));
assertEquals("Hello World", greeting);