2023-深入理解 Java 线程池:线程池参数调优与技巧

深入理解 Java 线程池:线程池参数调优与技巧

在现代编程中,线程池已经成为了不可或缺的一部分。Java 线程池是一个非常重要的组件,可以帮助我们优化并发处理,提高系统的性能和稳定性。然而,要想取得优秀的性能表现,需要对线程池的参数进行调优。本文将深入讲解 Java 线程池的调优方法和技巧,帮你提高编程技能和优化系统性能。

线程池简介

线程池概述

线程池是一种管理和重用线程资源的机制。通常情况下,每个任务都需要一个独立的线程来执行。当任务变得越来越多,每个任务都创建一个新线程就会导致系统负荷过重。这时候线程池的使用就能很好地解决这个问题。

线程池维护了一组空闲线程,当有任务需要执行时,就从线程池中选择一个空闲的线程执行任务,当任务执行完成后,这个线程就会被重新放回线程池,供下一次任务使用,这样可以节省线程创建和销毁的时间成本,提高系统的执行效率和响应速度。

线程池 API 介绍

Java 中的线程池实现在java.util.concurrent包下,主要有以下几个类:

  • Executor:线程池顶层接口,定义了线程池的 execute()方法,提交任务到线程池去执行。
  • ExecutorService:线程池的具体实现接口,通过 submit()方法向线程池提交任务,返回 Future,可以通过 Future 获得任务的执行情况。
  • ScheduledExecutorService:实现类似于 Timer 的计划任务调度功能。
  • ThreadPoolExecutor:是 ExecutorService 的一个实现类,也是 Java 中的线程池实现的核心实现类。通常情况下,我们是使用 ThreadPoolExecutor 来实现线程池功能。

这些 API 提供以下四种类型的线程池:

  • FixedThreadPool - 它包含固定数量的线程启动,线程数设定的越多并发就越高,内部的任务队列 FIFO(先进先出)。
  • CachedThreadPool - 这个池默认规定的池大小(线程数)是没有限制的,比较适合执行线程简小且 IO 密集型或计算密集型 task,避免等待 I/O 浪费时间(wait)来提供更大的利用率调用超过线程数的 task 到 thread 中。
  • SingleThreadExecutor - SingleThreadExecutor 是指有个线程池保持单线程工作。它用于背后处理活动在一个 job queue 中不大并且涉及器不借此启动新线程,避免上下文切换(set Context_Switches)的操作开销。
  • ScheduledThreadPool - 允许用户执行时间的安排或周期性任务在 future 类型值上的结果。以可调类型提供相对 延迟(取代 sleep)和 fixed-delay 并符合 UTC 执行时间的时间单元作为其参数(一些 days、hours、 minutes、seconds 等等)

线程池的用法

Java 中线程池的创建步骤一般为以下三步:

  1. 创建线程池执行器:可以通过 Executor 框架提供的工厂方法或通过 ThreadPoolExecutor 构造函数传入参数的方式来进行创建。
  2. 提交任务:通过使用 submit()方法将多个任务提交到线程池中等待执行。
  3. 关闭线程池:可以手动关闭线程池、也可以使用 shutdown()或 shutdownNow()方法关闭线程池。注意关闭线程池需要注意已经提交到线程池中的任务是否全部完成。

线程池常用参数含义

  1. corePoolSize (核心线程池大小)

该参数指定核心线程池中线程的数量。当提交一个新任务时,如果当前线程池中的线程数少于 corePoolSize,那么就会创建新的线程。即使其他空闲的非核心线程可以处理新任务,也会继续创建线程,达到核心线程池大小。如果设置为 0,则任务会不断地加入队列,并在工作线程可用时立即执行。

  1. maximumPoolSize(最大线程池大小)

该参数指定总线程池大小,包括核心线程池和非核心线程池。在任务队列满了的情况下,可以创建的最大线程数。如果此时运行的线程数已经等于了 maximumPoolSize,则提交的任务会根据选择的拒绝策略进行处理。

  1. keepAliveTime(线程存活时间)

当线程池中的数量大于 corePoolSize 时,这是多余的空闲线程的最长存活时间。直到线程数等于 corePoolSize,超过这个时间,空闲线程就会被回收。

  1. workQueue(任务队列)

任务队列是存储被提交但尚未被执行的任务的阻塞队列。常用的任务队列有如下几类:

  • ArrayBlockingQueue:基于数组的有限队列,可以指定容量。
  • LinkedBlockingQueue:基于链表的无限队列,可以无限扩展。
  • PriorityBlockingQueue:优先级队列,可以自定义排列顺序。
  • SynchronousQueue:同步队列,不存储数据,只在提交和取出数据时传递数据。
  1. RejectedExecutionHandler(拒绝策略)

拒绝策略是当任务队列满了需要执行拒绝策略来处理新提交的任务。提供了几种预定义的拒绝策略:

  • AbortPolicy:直接抛出异常,默认策略。
  • CallerRunsPolicy:主线程执行该任务。
  • DiscardOldestPolicy:丢弃队列中最老的任务,然后重新尝试执行当前任务。
  • DiscardPolicy:默默丢弃提交的任务,没有异常。

线程池调优

线程池的合理性评估

在开始调整线程池之前,需要对当前的系统进行全面的评估。可以通过使用系统监控软件,来确定系统的各项指标如 CPU 使用率、内存使用率、磁盘 I/O 等等,以及确定系统瓶颈。此外还要考虑到系统的上下文切换的开销。

如何选择合适的参数值

  1. 核心线程池大小

核心线程池大小应该根据需要处理的并发任务数以及 CPU 核心数来确定。通常来说,核心线程数可以设为 CPU 核心数 + 1。

  1. 最大线程池大小

最大线程池大小应该根据系统应对的最高并发数来确定。如果最大线程池大小比较大,会导致系统资源的浪费;如果比较小,会导致请求被拒绝。建议根据硬件资源、负载、并发量等实际情况来确定。

  1. 存活时间

存活时间通常设置为 60s 左右,即当线程池中的线程空闲时间超过了 60 秒,那么这个线程就会被回收。同时,当线程池中的线程数量小于等于核心线程池大小时,存活时间将不起作用。

  1. 队列选择

常用的阻塞队列有 ArrayBlockingQueue 和 LinkedBlockingQueue。

  • 如果任务数目大于队列长度,那么将会创建新线程去处理任务。因此队列应该尽量设置为有限阻塞队列,避免无限制的任务添加。
  • 若系统负载较大或并发量较大时,可以使用 LinkedBlockingQueue,因为 LinkedBlockingQueue 可以无限制存储任务,防止任务丢失。
  • 若任务量较小,建议使用 ArrayBlockingQueue,因为存储数组有限,有利于反馈当前任务的处理情况。
  1. 拒绝策略

拒绝策略有四种:AbortPolicy、CallerRunsPolicy、DiscardOldestPolicy、DiscardPolicy。其中,DiscardOldestPolicy 可以尽量取出能够处理的任务,而不是将任务全部丢弃。如果需要响应时间更好,可以使用 CallerRunsPolicy。若任务量极高,建议使用 DiscardPolicy 策略。

线程池及线程池大小调优技巧

  1. 核心线程池数量调优

核心线程池数量不宜过多,因为每个线程都需要占用内存和 CPU 资源,过多的核心线程池数量会导致系统资源的浪费,从而降低系统性能。但也不应过少,否则会降低系统吞吐量。

  1. 最大线程池数量调优

最大线程池数量一般设置:最大线程池数量 = CPU 核心数 + 网络连接数 + 其他 IO 等待时间的线程数量

  1. 存活时间调优

设定一个适当的线程存活时间,可以有效地减少线程的创建和销毁带来的性能开销。在存活时间到达之后,多余的线程会被回收,从而释放系统资源。

  1. 队列调优

任务队列是存储被提交但尚未被执行的任务的阻塞队列。在选择队列类型时,应考虑任务数量和任务类型,以及需要处理的并发请求数。常用的阻塞队列有 ArrayBlockingQueue 和 LinkedBlockingQueue。

  1. 拒绝策略调优

拒绝策略通常分为四种:AbortPolicy、CallerRunsPolicy、DiscardOldestPolicy 和 DiscardPolicy。根据业务需求和系统负载情况,选择合适的拒绝策略。

应用场景

  1. CPU 密集型应用线程池设置:

在 CPU 密集型应用中,任务主要是 CPU 计算,线程池的大小应该根据 CPU 核心数来设置,以充分利用 CPU 资源,并避免过多线程间的竞争和上下文切换。通常情况下,将核心线程池大小设置为 CPU 核心数,将最大线程池大小设置为 CPU 核心数 * 2。比如,当前服务器有 8 核 CPU,那么推荐设置核心线程池大小为 8,最大线程池大小为 16。

  1. IO 密集型应用线程池设置:

在 IO 密集型应用中,任务主要是从事 IO 等待,而线程的 CPU 计算能力却很小,此时线程数量适当多一点,可以让 CPU 等待 IO 的数目更多,以充分利用计算机的硬件资源。通常情况下,核心线程池大小可以设置为 CPU 核心数 +1,最大线程池大小可以设置为 CPU 核心数 * 2。同时,建议使用无界的 LinkedBlockingQueue 阻塞队列,以避免丢失任务。

  1. 其它应用场景:

对于各种不同的场景,应该根据实际情况进行参数的设置。比如生产者-消费者问题,可以使用 FixedThreadPool,保证消费者线程数量少于核心线程池大小,以确保消费者线程能够及时执行。同时,阻塞队列也可以根据实际情况选择不同的存储方式。对于需要开多个线程处理的应用,可以使用 ScheduledThreadPoolExecutor,定时执行任务。总之,根据实际需求来设置线程池的参数是最重要的。

总结

  1. 线程池参数设置的思考

线程池对于提高系统性能和可管理性非常重要,线程池的性能和效率很大程度上取决于参数的设置。对于不同的业务和场景,需要根据实际情况来设置线程池的参数。

  • 核心线程池大小和最大线程池大小应该根据实际需求来设置,一般建议把核心线程池大小设置成 CPU 核心数加 1,而最大线程池大小可以设置为 CPU 核心数乘 2。
  • 阻塞队列的大小和类型应该根据实际情况来设置,不同类型的阻塞队列对于不同的场景都有其优缺点,比如 ArrayBlockingQueue 适用于任务执行比较平缓的场景,而 LinkedBlockingQueue 则适用于任务执行频繁的场景。
  • 线程存活时间应该根据实际情况来设置,一般建议设置为 60s 左右。
  • 拒绝策略应该根据实际情况来设置,比如当线程池中的队列已满或达到了最大线程数时,应该使用合适的拒绝策略。
  1. 注意事项:
  • 线程池中的线程数量不是越多越好,过多的线程数量会增加线程上下文切换的开销,同时也会占用过多的系统资源。
  • 线程池的大小应该根据实际情况来设置,不同的场景和业务具有不同的特点,需要根据实际情况做出合理的选择。
  • 阻塞队列的大小和类型应该根据实际情况做出选择,为了防止任务丢失,应该将其设置成有界队列,同时也需要考虑队列的容量大小和类型。
  • 线程池的拒绝策略应该根据实际情况来选择,不同的业务场景具有不同的特点,需要做出合理的选择。