万字探索线程池:优化并发任务的利器

前言

在Java中,使用线程来开发支持多任务并行的程序是非常方便的。但是,在实际应用中不建议大家直接“new”一个线程去处理任务,因为线程会消耗CPU资源,当在一个进程中创建大量的线程时,不仅不会提升程序的性能,反而会影响任务的执行效率。同时,线程的频繁创建和销毁,会因为分配内存和回收内存而占用CPU资源,从而影响性能。为了解决这些问题,Java引入了线程池技术。线程池实际上运用的是一种池化技术,所谓池化技术就是提前创建好大量的“资源”保存在某个容器中,在需要使用时,可以直接从该容器中获取对应的资源进行处理,用完之后回收以便下次继续使用。

线程池的创建

线程过多会带来额外的开销,其中包括创建销毁线程的开销、调度线程的开销等等,同时也降低了计算机的整体性能。线程池维护多个线程,等待监督管理者分配可并发执行的任务。这种做法,一方面避免了处理任务时创建销毁线程开销的代价,另一方面避免了线程数量膨胀导致的过分调度问题,保证了对内核的充分利用。

Executors创建线程池的方法

  1. newFixedThreadPool(int nThreads): 固定大小线程池,特点是线程数固定,使用无界队列,适用于任务数量不均匀的场景、对内存压力不敏感但系统负载比较敏感的场景;
 
 

java

复制代码

public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()); }

  1. newCachedThreadPool(): Cached 线程池,特点是不限制线程数,适用于要求低延迟的短期任务场景;
 
 

java

复制代码

public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>()); }

  1. newSingleThreadExecutor(): 单线程线程池,就是一个线程的固定线程池,适用于需要异步执行但需要保证任务顺序的场景;
 
 

java

复制代码

public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>())); }

  1. newScheduledThreadPool(int corePoolSize): Scheduled 线程池,适用于定期执行任务场景,支持按固定频率定期执行和按固定延时定期执行两种方式;
 
 

java

复制代码

public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { return new ScheduledThreadPoolExecutor(corePoolSize); } // ScheduledThreadPoolExecutor public ScheduledThreadPoolExecutor(int corePoolSize) { super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue()); }

  1. newWorkStealingPool(): 工作窃取线程池,使用的是 ForkJoinPool,是固定并行度的多任务队列,适合任务执行时长不均匀的场景。
 
 

java

复制代码

public static ExecutorService newWorkStealingPool() { return new ForkJoinPool (Runtime.getRuntime().availableProcessors(), ForkJoinPool.defaultForkJoinWorkerThreadFactory, null, true); }

这些方法的区别在于线程池的类型和行为:

  • 固定线程池、缓存线程池和单线程池都属于ThreadPoolExecutor的不同配置方式,提供了不同的线程池特性和行为。
  • 定时任务线程池是基于ScheduledThreadPoolExecutor实现的(父类也是ThreadPoolExecutor),可以执行定时或周期性任务。

为什么大公司都不推荐Executors创建线程池

在大公司中,通常会避免直接使用 Executors 类中提供的简单工厂方法来创建线程池,而更倾向于使用 ThreadPoolExecutor 类进行手动配置和创建线程池。这是因为 Executors 类提供的工厂方法在某些情况下可能会导致线程池的不合理配置,从而影响系统的性能和稳定性。

以下是一些原因,为什么大公司不直接使用 Executors 创建线程池:

  1. 隐式的配置:Executors 类提供的工厂方法会隐藏线程池的一些关键配置参数,如核心线程数、最大线程数和任务队列类型等。这可能导致使用者无法直接设置这些参数,从而无法针对具体的应用场景进行优化。
  2. 任务队列选择:不同的应用场景可能需要不同类型的任务队列,例如有界队列或无界队列。Executors 类的工厂方法通常使用无界队列,这可能会导致任务积压,造成内存溢出等问题。
  3. 拒绝策略限制:Executors 类提供的工厂方法通常使用默认的拒绝策略,例如抛出异常或直接丢弃任务。在实际的生产环境中,需要根据业务需求选择合适的拒绝策略,如将任务放入队列、执行特定的补偿逻辑等。
  4. 线程池饱和策略:Executors 类提供的工厂方法可能采用一种饱和策略,即当任务队列已满并且线程数达到最大线程数时,会创建新的线程来处理任务。这可能导致系统资源耗尽,造成性能下降或系统崩溃。

通过直接使用 ThreadPoolExecutor 类,开发人员可以手动配置线程池的各项参数,包括核心线程数、最大线程数、任务队列类型、拒绝策略等,以适应具体的业务需求和系统负载情况。这样可以更加精细地控制线程池的行为,提高系统的性能、稳定性和可调试性。

线程池原理

ThreadPoolExecutor的实现

ThreadPoolExecutor实现细节

ThreadPoolExecutor 是 Java 中提供的一个实现了 ExecutorService 接口的线程池实现类,它提供了更丰富的配置选项和灵活性。以下是 ThreadPoolExecutor 的具体实现细节:

 
 

java

复制代码

public class ThreadPoolExecutor extends AbstractExecutorService { // 核心线程数,线程池中一直保持活动的线程数量 private volatile int corePoolSize; // 最大线程数,线程池中允许的最大线程数量 private volatile int maximumPoolSize; // 线程空闲时间,当线程池中的线程数超过核心线程数时,空闲线程的最大存活时间 private volatile long keepAliveTime; // 时间单位,用于指定 keepAliveTime 的单位 private final TimeUnit unit; // 任务队列,用于存储尚未被执行的任务 private final BlockingQueue<Runnable> workQueue; // 线程工厂,用于创建新的线程 private final ThreadFactory threadFactory; // 拒绝策略,用于处理无法提交的任务 private final RejectedExecutionHandler handler; // 线程池中的当前线程数量及状态 private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0)); // 线程池的执行方法 public void execute(Runnable command) { if (command == null) throw new NullPointerException(); int c = ctl.get(); if (workerCountOf(c) < corePoolSize) { // 如果核心线程数未满,则创建新的核心线程执行任务 if (addWorker(command, true)) return; c = ctl.get(); } if (isRunning(c) && workQueue.offer(command)) { // 尝试将任务添加到队列中 int recheck = ctl.get(); if (!isRunning(recheck) && remove(command)) // 如果线程池状态发生变化,则尝试从队列中移除任务 reject(command); else if (workerCountOf(recheck) == 0) addWorker(null, false); } else if (!addWorker(command, false)) // 如果任务无法放入队列,则尝试创建新的非核心线程执行任务 reject(command); } }

ThreadPoolExecutor 类具有核心线程数、最大线程数、任务队列等属性,还包含了用于管理线程池状态和执行任务的方法。它使用 AtomicInteger 对线程池中的线程数量进行原子更新,根据当前线程池状态和任务情况,动态地创建新线程、放入队列或拒绝任务。

任务队列

Java中的线程池框架提供了几种常见的任务队列类型,每种类型都有不同的特点和适用场景。

队列 特点 适用场景
直接提交队列(SynchronousQueue) 这是一个没有存储能力的队列,它要求线程池立即处理任务,如果线程池的线程都在忙碌,那么新任务将会被拒绝 适用于需要立即执行任务的场景,通常用于限制线程池的最大并发数。
无界队列(LinkedBlockingQueue) 这是一个没有固定容量限制的队列,可以无限制地添加新任务。当线程池中的线程都在忙碌时,新任务将会被放入队列等待执行 适用于任务量较大、任务执行时间较长的场景,可以保证尽可能多的任务被接受和处理。
有界队列(ArrayBlockingQueue) 这是一个具有固定容量的队列,可以指定队列的最大容量。当线程池中的线程都在忙碌时,新任务将会被放入队列等待执行。如果队列已满,新任务将会被拒绝 适用于控制线程池的最大并发数和任务队列的容量,可以避免任务提交过快导致内存溢出。
优先级队列(PriorityBlockingQueue) 这是一个基于优先级的队列,可以根据任务的优先级顺序来执行任务。具有较高优先级的任务将会被优先执行 适用于根据任务优先级来调度任务执行的场景,可以实现任务的有序执行。

需要根据任务的特性、并发需求、资源限制等因素进行权衡和选择,以获得最佳的性能和效果。

拒绝策略

线程池框架提供了四种常见的拒绝策略,用于处理当线程池无法接受新任务时的情况。这些拒绝策略分别是:

拒绝策略 特点 适用场景
AbortPolicy(默认) 该策略是默认的拒绝策略。当线程池无法接受新任务时,会直接抛出 RejectedExecutionException 异常,阻止新任务的提交。 适用于在任务提交被拒绝时,立即通知调用者并停止系统的运行。
CallerRunsPolicy 当线程池无法接受新任务时,该策略将使用调用线程来执行被拒绝的任务,即在调用线程中执行任务的 run() 方法 适用于要求任务不被丢弃且可以在调用线程中执行的场景。这可能会降低系统的吞吐量,但可以提供一种适应性,避免任务丢失。
DiscardPolicy 当线程池无法接受新任务时,该策略会默默地丢弃被拒绝的任务,不做任何处理 适用于对任务丢失不敏感的场景,不需要通知或记录被丢弃的任务。
DiscardOldestPolicy 当线程池无法接受新任务时,该策略会丢弃队列中最旧的任务,然后尝试重新提交被拒绝的任务 适用于对最旧任务优先级较低的场景,可以在任务被丢弃的同时,尽量保留较新的任务。

线程池执行流程

向线程提交任务时可以使用 execute 和 submit,区别就是 submit 可以返回一个 future 对象,通过 future 对象可以了解任务执行情况,可以取消任务的执行,还可获取执行结果或执行异常。submit 最终也是通过 execute 执行的。

向线程池提交任务时的执行顺序如下图所示。

  • 向线程池提交任务时,会首先判断线程池中的线程数是否大于设置的核心线程数,如果不大于,就创建一个核心线程来执行任务。

  • 如果大于核心线程数,就会判断缓冲队列是否满了,如果没有满,则放入队列,等待线程空闲时执行任务。

  • 如果队列已经满了,则判断是否达到了线程池设置的最大线程数,如果没有达到,就创建新线程来执行任务。

  • 如果已经达到了最大线程数,则执行指定的拒绝策略。

线程池状态

线程池有5种状态,状态说明如下:

  • RUNNING,运行状态,可以接收新的任务并处理,可以处理阻塞队列中的任务。
  • SHUTDOWN,关闭状态,不接收新的任务,但是可以继续处理阻塞队列中的任务。
  • STOP,停止状态,不接收新的任务,不处理阻塞队列中的任务,同时会中断正在处理的任务。
  • TIDYING,过渡状态,该状态意味着所有的任务都执行完了,并且线程池中已经没有有效的工作线程。该状态下会调用terminated()方法进入TERMINATED状态。
  • TERMINATED,终止状态,terminated()方法调用完成以后的状态。

线程池设置

线程池线程数设置多少比较合适

《Java并发编程实战》一书给了推荐设置:

  • Ncpu表示CPU的数量,可以通过Runtime.getRuntime().availableProcessors()获得。
  • Ucpu表示期望的CPU的使用率。
  • W/C表示等待时间与计算时间的比例。
  • Nthreads表示线程数量的计算公式。

假设CPU利用率是100%,那么Nthreads=Ncpu×(1+W/C),也就意味着W/C的值越大,那么线程数量越多,反之线程数量越少。 我们还要看当前线程池中要执行的任务是属于I/O密集型还是CPU密集型。

  • I/O密集型:就是线程频繁需要和磁盘或者远程网络通信,这种场景中磁盘的耗时和网络通信的耗时较大,意味着线程处于阻塞期间,不会占用CPU资源,所以线程数量设置超过CPU核心数并不会造成问题。
  • CPU密集型:就是对CPU的利用率较高的场景,比如循环、递归、逻辑运算等,这种情况下线程数量设置越少,就越能减少CPU的上下文频繁切换。

有一种建议如下,其中N表示CPU的核心数量。

  • CPU密集型,线程池大小设置为N+1。
  • IO密集型,线程池大小设置为2N+1。

之所以需要+1,是因为这样设置以后,线程在某个时刻发生一个页错误或者因为其他原因暂停时,刚好有一个额外的线程可以确保CPU周期不会中断。

这些设置只是一些建议和参考,并不是绝对的规则。实际的设置应该根据具体的应用场景、任务特性和系统资源来进行调整和优化。通过测试和监控,观察线程池的性能指标,并根据实际情况进行适当的调整,以达到最佳的性能和资源利用效果。

如何动态设置线程池参数

设置线程池线程数

当需要动态调整线程池大小时,可以按照以下顺序来设置最小线程数和最大线程数:

  1. 调大线程池:

    • 首先,增加最大线程数,以便线程池可以容纳更多的线程。
    • 然后,增加最小线程数,以确保在任务到达时能够立即处理。
  2. 调小线程池:

    • 首先,降低最小线程数,以允许空闲线程在一段时间后被回收。
    • 然后,降低最大线程数,以限制线程池的最大容量。
 
 

java

复制代码

// 创建线程池 ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 10, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>()); // 提交任务到线程池中 executor.execute(() -> { // 任务逻辑 }); // 调大线程池 executor.setMaximumPoolSize(15); executor.setCorePoolSize(8); // 调小线程池 executor.setCorePoolSize(5); executor.setMaximumPoolSize(8);

在上述示例中,首先通过 setMaximumPoolSize() 方法增加最大线程数,然后通过 setCorePoolSize() 方法增加最小线程数,以调大线程池。而在调小线程池时,先通过 setCorePoolSize() 方法降低最小线程数,再通过 setMaximumPoolSize() 方法降低最大线程数。

这样的设置顺序可以确保线程池能够根据任务负载的变化进行适当调整,并在需要时及时创建或回收线程,以优化性能和资源利用。

设置队列大小

当遇到请求数过多但是队列设置太小时,想要动态调整队列大小怎么做呢?在ThreadPoolExecutor中并没有提供队列长度修改方法。以LinkedBlockingQueue为例,它的成员变量capacity是被final修饰的,只能在构造方法中初始化,因此也没办法动态设置capacity的大小。 我们可以把LinkedBlockingQueue复制一份,然后提供一个修改setCapacity的方法。

线程池参数传递

在线程池中传递参数,可以通过以下几种方式实现:

使用任务的构造函数或方法

可以在创建任务对象时,将参数作为构造函数或方法的参数传递进去。任务执行时可以直接使用这些参数。例如:

 
 

java

复制代码

public class MyTask implements Runnable { private final int parameter; public MyTask(int parameter) { this.parameter = parameter; } @Override public void run() { // 使用参数执行任务逻辑 // ... } } // 创建线程池 ExecutorService executor = Executors.newFixedThreadPool(5); // 提交任务并传递参数 int parameterValue = 123; executor.execute(new MyTask(parameterValue));

使用 ThreadLocal

ThreadLocal 可以让每个线程都持有一个独立的变量副本。可以将参数设置到 ThreadLocal 中,在任务执行时从 ThreadLocal 中获取参数。这样可以实现线程隔离的参数传递。例如:

 
 

java

复制代码

// 创建线程池 ExecutorService executor = Executors.newFixedThreadPool(5); // 定义 ThreadLocal 对象 ThreadLocal<Integer> parameter = new ThreadLocal<>(); // 设置参数 int parameterValue = 123; parameter.set(parameterValue); // 提交任务并使用 ThreadLocal 传递参数 executor.execute(() -> { // 使用参数执行任务逻辑 // 可以通过 parameter.get() 获取参数值 int value = parameter.get(); // ... });

transmittable-thread-local传递

使用线程池和ThreadLocal共用可能会存在数据不一致的情况。这是因为线程池会复用线程对象,Threadlocal为每个线程维护了独立的变量副本,这就会导致当前线程会取到其他线程设置的ThreadLocal的值。

另外,如果需要在线程池中共享数据,而不会受到线程复用和数据不一致的影响,可以考虑使用 ThreadLocal 的替代方案,如使用 InheritableThreadLocal 或者在任务中显式传递参数来共享数据。

阿里巴巴开源的 TransmittableThreadLocal(TTL)。TransmittableThreadLocal类继承并加强了 JDK 内置的InheritableThreadLocal类,在使用线程池等会池化复用线程的执行组件情况下,提供ThreadLocal值的传递功能,解决异步执行时上下文传递的问题。

作者:scoop
链接:https://juejin.cn/post/7241184271318237245
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

猜你喜欢

转载自blog.csdn.net/BASK2312/article/details/131070785