充分利用Java线程池

895 阅读12分钟

@wikpedia

在计算机程序设计中,线程池是一种软件设计模式,用于在计算机编程中实现执行的并发性。线程池维护多个线程,这些线程等待分配任务,由监控程序并发执行。通过维护线程池,该模型提高了性能,并避免了由于频繁地为短期任务创建和销毁线程而导致的执行延迟。可用线程的数量调优为程序可用的计算资源,例如执行完成后的并行任务队列。


深入了解Java中线程池的内部工作方式、你可以使用的各种线程池,以及如何提高程序性能的技巧。

线程池是多线程编程中的核心概念,简而言之,就是表示可用于执行任务的空闲线程集合。

首先,让我们概述一个多线程的参考框架,以及为什么我们可能需要使用线程池。

线程是一个执行的上下文,它可以在一个进程中运行一组指令(又叫运行程序),多线程编程是指使用线程并发地执行多个任务,当然JVM也支持这种模型。 虽然这带来了一些优势,主要体现在程序的性能上,但是多线程编程也有一些弊端——比如增加了代码的复杂性、并发的问题,意外的结果,以及增加了线程创建的开销。 在本文中,我们将深入探讨如何使用JAVA线程池来解决上述的一系列弊端。

创建和启动一个线程可能是一个昂贵的过程。在每次需要执行任务时重复这个过程,我们会产生显著的性能成本——这正是我们试图通过使用线程池来改进的地方。

为了更好地理解创建和启动一个线程的成本,让我们看看JVM在背后做了什么:

  • 它为一个线程堆栈分配内存,该堆栈为每个线程方法调用保存一个帧。
  • 每个帧由一个局部变量数组、返回值、操作数堆栈和常量池组成。
  • 一些支持native的JVM也会分配一个native堆栈
  • 每个线程都有一个程序计数器,告诉它处理器当前执行的指令是什么。
  • 系统创建一个与Java线程对应的本机线程。
  • 与线程相关的描述符被添加到JVM内部数据结构中。
  • 线程共享堆和方法区域。

当然,所有这些的细节将取决于JMV和操作系统。

此外,更多的线程意味着系统调度器需要做更多的工作来决定接下来哪个线程可以访问资源。

线程池通过减少所需线程的数量并且管理它们的生命周期来缓解多线程带来的性能问题。

本质上,线程被保存在线程池中,直到需要它们时才执行任务并返回池中供以后重用。这种机制在执行大量小任务的系统中特别有用。

Java线程池

Java提供了自己的线程池模式实现,通过调用executors对象。它们通过executor接口或者直接通过线程池(它允许更细粒度的控制)实现使用。

java.util.concurrent包含了如下接口:

  • Executor 一个执行任务的简单接口
  • ExecutorService 更复杂的接口,其中包含用于管理任务和执行程序本身的其他方法
  • ScheduledExecutorService 继承了ExecutorService的方法,用于调度任务执行。

除了这些接口,该包还提供了Executor辅助类,用于获取Executor实例,以及这些接口的实现。

通常,Java线程池由以下部分组成:

  • 工作线程池,负责管理线程。
  • 线程工厂,负责创建新线程。
  • 等待执行的任务队列。

在接下来的部分中,让我们更详细地了解为线程池提供支持的Java类和接口是如何工作的。

Executors类和Executor接口

Executors类包含了用于创建不同线程池的工厂方法,Executor是最简单的线程池接口,只有一个execute()方法。

让我们用一个例子,结合这两个类,来创建一个单线程池,使用它来执行一些简单的语句:

Executor executor = Executors.newSingleThreadExecutor();
executor.execute(() -> System.out.println("Single thread pool test"));

注意该语句如何用一个lambda表达式形式编写——可以推断为Runnable类型。

execute()方法在工作线程可用时或者将Runnable任务放置队列中等待线程可用时运行该语句。 基本上,excutor代替线程的显示创建和管理。

Executors类中的工厂方法可以创建以下几种类型的线程池:

  • newSingleThreadExecutor() 一个只有一个线程且无限制队列的线程池,它一次只执行一个任务
  • newFixedThreadPool() 具有固定数量线程的线程池,它共享无限制的队列;当一个任务提交时,如果所有的线程都是活动的,它们会等待队列直到有线程可用
  • newCachedThreadPool() 一个线程池,可根据需要创建新线程。默认60秒没有线程可用,将会从缓存中移除。
  • newWorkStealingThread 基于“工作窃取”算法(在并行计算中,工作窃取是多线程计算机程序的一种调度策略。)的线程池,这将在后面的部分中详细介绍。

接下来,让我们看一下ExecutorService接口的其他功能。

ExecutorService

创建ExecutorService的一种方式是使用Executors类的工厂方法:

ExecutorService executor = Executors.newFixedThreadPool(10);

除了execute()方法之外,这个接口还定义了一个类似于submit()的方法,它可以返回一个Future对象:

Callable<Double> callableTask = () -> {
    return employeeService.calculateBonus(employee);
};
Future<Double> future = executor.submit(callableTask);
// execute other operations
try {
    if (future.isDone()) {
        double result = future.get();
    }
} catch (InterruptedException | ExecutionException e) {
    e.printStackTrace();
}

正如你在上述例子中看到的,Future接口可以返回Callable对象的任务结果,并且还可以显示任务执行的状态。

当没有任务等待执行时ExecutorService接口不会自动销毁,因此你可以使用shutdown()或者shutdownNow()APIs显式的关闭它:

executor.shutdown();
ScheduledExecutorService

这是ExecutorService的子接口,它添加了用于调度任务的方法:

ScheduledExecutorService executor = Executors.newScheduledThreadPool(10);

schedule()方法指定执行的任务、延迟值和该值的时间单位:

Future<Double> future = executor.schedule(callableTask, 2, TimeUnit.MILLISECONDS);

此外,这个接口还定义了两个额外的方法

executor.scheduleAtFixedRate(
  () -> System.out.println("Fixed Rate Scheduled"), 2, 2000, TimeUnit.MILLISECONDS);
executor.scheduleWithFixedDelay(
  () -> System.out.println("Fixed Delay Scheduled"), 2, 2000, TimeUnit.MILLISECONDS);

scheduleAtFixedRate()方法延迟2毫秒后执行,然后每2秒重复一次。同理,scheduleWithFixedDelay()方法2毫秒之后开始执行,然后在前一次执行结束后2秒内重复该任务.

在下面的部分中,我们还将讨论ExecutorService接口的两个实现:ThreadPoolExecutorForkJoinPool

ThreadPoolExecutor

这个线程池实现增加了配置参数的能力,以及可扩展性钩子。通过使用Executors工厂方法可以轻松创建ThreadPoolExecutor对象:

ThreadPoolExecutor executor = (ThreadPoolExecutor) Executors.newFixedThreadPool(10);

通过这种方式,可以为最常见的情况预先配置线程池。线程池的数量可以通过设置参数控制:

  • corePoolSize and maximumPoolSize 它们表示了线程绑定的数量。
  • keepAliveTime 它决定了保持额外线程存活的时间

再深入一点,这里是这些参数的使用方式。

如果提交了一个任务,并且执行的线程小于corePoolSize,那么就会创建一个新线程。如果运行的线程数大于[corePoolSize]但小于[maximumPoolSize],并且任务队列已满,则会发生相同的情况,如果有多个[corePoolSize]线程,它们已经闲置超过了[keepAliveTime]规定的时长,它们将会被停止。

在上面的例子中, newFixedThreadPool()创建了一个corePoolSize = maximumPoolSize = 10 的线程池,并且keepAliveTime为0秒。

如果改用newCachedThreadPool()方法,则会创建一个线程池,其最大池大小为Integer.MAX_VALUEkeepAliveTime为60秒:

ThreadPoolExecutor cachedPoolExecutor  = (ThreadPoolExecutor) Executors.newCachedThreadPool();

参数也可以通过构造函数或者setter方法设置:

ThreadPoolExecutor executor = new ThreadPoolExecutor(4, 6, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>());
executor.setMaximumPoolSize(8);

ThreadPoolExecutor的子类是ScheduledThreadPoolExecutor,它实现了ScheduledExecutorService接口。你可以使用newScheduledPool()工厂方法创建该类型的线程池:

ScheduledThreadPoolExecutor executor = (ScheduledThreadPoolExecutor) Executors.newScheduledThreadPool(5);

创建了一个corePoolSize为5,未绑定maximumPoolSize,并且keepAliveTime为0的线程池。

ForkJoinPool

线程池的另一种实现时ForkJoinPool类。它实现了ExecutorService接口,并声明了Java 7中引入的fork / join框架的中心组件。

fork/join 框架基于“工作偷窃算法”,简单来说,这意味着用尽任务的线程可以“窃取”其他繁忙线程的工作。

ForkJoinPool非常适合大多数任务创建其他子任务或者从外部客户端向池中添加许多小任务时的情况。 使用此线程池的工作流程通常如下所示:

  • 创建ForkJoinTask子类
  • 根据条件将任务拆分为子任务
  • 调用任务
  • 合并每个任务的结果
  • 创建该类的实例并将其添加到池中

要创建ForkJoinTask,如果需要返回结果,则可以选择其更常用的子类之一RecursiveAction或RecursiveTask。

让我们实现一个类的例子,该类继承了RecursiveTask并通过根据THRESHOLD值将其分为多个子任务来计算数字的阶乘。

public class FactorialTask extends RecursiveTask<BigInteger> {
    private int start = 1;
    private int n;
    private static final int THRESHOLD = 20;
    // standard constructors
    @Override
    protected BigInteger compute() {
        if ((n - start) >= THRESHOLD) {
            return ForkJoinTask.invokeAll(createSubtasks())
              .stream()
              .map(ForkJoinTask::join)
              .reduce(BigInteger.ONE, BigInteger::multiply);
        } else {
            return calculate(start, n);
        }
    }
}

该类需要实现的主要方法是重写compute() 方法,该方法将每个子任务的结果合并到一起。

实际的拆分是在createSubtasks()方法中完成的:

private Collection<FactorialTask> createSubtasks() {
    List<FactorialTask> dividedTasks = new ArrayList<>();
    int mid = (start + n) / 2;
    dividedTasks.add(new FactorialTask(start, mid));
    dividedTasks.add(new FactorialTask(mid + 1, n));
    return dividedTasks;
}

最后,calculate()方法包含范围内值的乘法:

private BigInteger calculate(int start, int n) {
    return IntStream.rangeClosed(start, n)
      .mapToObj(BigInteger::valueOf)
      .reduce(BigInteger.ONE, BigInteger::multiply);
}

接下来可以将任务添加到线程池:

ForkJoinPool pool = ForkJoinPool.commonPool();
BigInteger result = pool.invoke(new FactorialTask(100));
ThreadPoolExecutor vs ForkJoinPool

乍一看,似乎fork / join框架带来了改进的性能。但是,根据需要解决的问题类型,情况可能并非总是如此。

当选择线程池的时候,还要记住,创建和管理线程以及线程上下文的切换会导致开销。

ThreadPoolExecutor提供对线程数和每个线程执行的任务的更多控制。 这使它更适合于在其自己的线程上执行少量的较大任务的情况。

相比之下,ForkJoinPool基于线程从其他线程“窃取”任务。 因此,在将任务分解为较小的任务时,最好使用它来加快工作速度。

fork/join框架使用两种队列类型来实现“工作窃取”算法。

  • 用于所有任务的中心队列
  • 用于每个线程的任务队列

当线程用完自己队列中的任务时,它们尝试从其他队列接收任务。为了让这个过程更加高效,线程队列使用双端队列数据结构,线程被添加到一端,而另一端被“窃取”。

以下是对这一过程的详尽描述的可视化表示: enter description here

与该模型相比,ThreadPoolExecutor仅使用一个中央队列。

最后要记住的一点是,仅当任务创建子任务时,选择ForkJoinPool才有用。否则,它的功能将与ThreadPoolExecutor相同,而且会产生额外的开销。

跟踪线程池执行

现在,我们对Java线程池生态系统有了很好的基础了解,下面让我们仔细研究一下使用线程池的应用程序执行期间发生的情况。

在FactorialTask构造函数和calculate()方法中添加一些日志声明,你可以关注如下的调用序列:

13:07:33.123 [main] INFO ROOT - New FactorialTask Created
13:07:33.123 [main] INFO ROOT - New FactorialTask Created
13:07:33.123 [main] INFO ROOT - New FactorialTask Created
13:07:33.123 [main] INFO ROOT - New FactorialTask Created
13:07:33.123 [ForkJoinPool.commonPool-worker-1] INFO ROOT - New FactorialTask Created
13:07:33.123 [ForkJoinPool.commonPool-worker-1] INFO ROOT - New FactorialTask Created
13:07:33.123 [main] INFO ROOT - New FactorialTask Created
13:07:33.123 [main] INFO ROOT - New FactorialTask Created
13:07:33.123 [main] INFO ROOT - Calculate factorial from 1 to 13
13:07:33.123 [ForkJoinPool.commonPool-worker-1] INFO ROOT - New FactorialTask Created
13:07:33.123 [ForkJoinPool.commonPool-worker-2] INFO ROOT - New FactorialTask Created
13:07:33.123 [ForkJoinPool.commonPool-worker-1] INFO ROOT - New FactorialTask Created
13:07:33.123 [ForkJoinPool.commonPool-worker-2] INFO ROOT - New FactorialTask Created
13:07:33.123 [ForkJoinPool.commonPool-worker-1] INFO ROOT - Calculate factorial from 51 to 63
13:07:33.123 [ForkJoinPool.commonPool-worker-2] INFO ROOT - Calculate factorial from 76 to 88
13:07:33.123 [ForkJoinPool.commonPool-worker-3] INFO ROOT - Calculate factorial from 64 to 75
13:07:33.163 [ForkJoinPool.commonPool-worker-3] INFO ROOT - New FactorialTask Created
13:07:33.163 [main] INFO ROOT - Calculate factorial from 14 to 25
13:07:33.163 [ForkJoinPool.commonPool-worker-3] INFO ROOT - New FactorialTask Created
13:07:33.163 [ForkJoinPool.commonPool-worker-2] INFO ROOT - Calculate factorial from 89 to 100
13:07:33.163 [ForkJoinPool.commonPool-worker-3] INFO ROOT - Calculate factorial from 26 to 38
13:07:33.163 [ForkJoinPool.commonPool-worker-3] INFO ROOT - Calculate factorial from 39 to 50

在这里,您可以看到创建了多个任务,但是只有三个工作线程–因此这些任务将占用池中的可用线程。

还要注意的一点是,在将对象本身传递到池中执行之前,它们是如何在主线程中实际创建的。

使用线程池的潜在风险

尽管线程池具有明显的优势,但是在使用线程池时,你将会遇到一些问题,例如:

  • 使用太大或太小的线程池 如果线程池拥有大量的线程,这会严重影响应用程序的性能,另一方面,线程池太小可能不会带来你所期望的性能提升。
  • 就像其他任何多线程情况一样,死锁也可能发生。 例如,一个任务可能正在等待另一任务完成,而没有可用线程来执行该任务;这就是为什么避免任务之间相互依赖是一个好主意。
  • 排队很长的任务 为避免阻塞线程太长时间,你可以指定最大等待时间,在此之后等待任务被拒绝或重新添加到队列中。
总结

简单来说,线程池通过将任务的执行与线程的创建和管理分开,以此提供了显著的性能。此外,如果使用得当,它们可以极大地提高应用程序的性能。

而且,Java生态系统的伟大之处在于,如果你学会正确并充分地利用它们,则可以使用其中一些最成熟且经过考验的线程池实现。

英文原文点此查看