线程池:优化多线程管理的利器

引言

同步和异步想必各位都有了解,同步简单来说就是一件事做完再去做下一件;异步则是不用等一件事做完,就可以去做另一件事,当一件事完成后可以收到对应的通知;异步一般应用于一些耗时较长的操作,比如大型文件的上传、第三方接口的请求等,可以大大提升系统效率,优化用户体验;但是对于执行这些异步任务的线程管理相对复杂,什么时候异步线程执行结束?如何控制线程的创建数目?线程繁忙时最新的任务该如何处理(舍弃 or 暂存)?这些问题就大大增加了异步编程的复杂性,但是线程池可以很方便的解决这些问题。

在多线程编程中,线程的创建和销毁是一项开销较大的任务。为了更有效地利用系统资源、提高程序的性能,线程池应运而生。线程池是一种管理和复用线程的机制,通过预先创建一定数量的线程,它们可以被重复利用来处理多个任务,避免了线程频繁创建和销毁的开销,提升了系统的性能和响应速度。

线程池有以下优点:

  • 提高系统性能:线程池在高并发的场景中能够有效地提高系统的性能。通过限制并发线程的数量,避免系统因过多线程而产生过多的上下文切换,提升整体系统的处理能力。

  • 控制资源并发度:在资源有限的环境下,线程池可以用来控制并发度,确保系统资源不被过度占用。例如,在进行文件下载时,可以通过线程池限制同时下载的文件数量,避免过多的网络连接占用带宽。

  • 提高响应速度:线程池能够在任务到达时立即执行,而不需要等待新线程的创建,从而提高了系统对外响应的速度。这在需要快速响应用户请求的网络服务中尤为重要。

  • 避免线程泄漏:通过线程池,线程的生命周期由线程池进行管理,可以避免线程泄漏问题。当任务执行完毕后,线程池会将线程放回线程池,而不是销毁它,从而减少了资源浪费。

  • 任务排队和管理:线程池提供了对任务的排队和管理机制。可以按照优先级、先进先出等规则对任务进行排队,确保高优先级的任务先得到执行。

  • 避免系统崩溃:在某些情况下,如果系统同时创建大量线程,可能导致系统资源耗尽,甚至引发系统崩溃。线程池通过控制线程的数量,可以防止系统因为线程过多而崩溃的情况发生。

总的来说线程池主要帮我们解决线程的管理和任务的存取功能,实现更方便的管理线程和协调任务的执行;

线程池的实现

java提供的juc包中已经提供了很方便的方法区创建线程池的类:ThreadPoolExecutor,主要介绍一下该方法的参数:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)

ThreadPoolExecutor 是 Java 中用于管理线程池的类,用于执行异步任务。在创建 ThreadPoolExecutor 实例时,可以传递一系列参数来配置线程池的行为

  • corePoolSize: 表示线程池的基本大小,即线程池中保持活动状态的最小线程数。(核心线程数,系统能同时工作的线程数;理解为一个公司的核心成员,不会被开除)

  • maximumPoolSize: 表示线程池的最大大小,即线程池中允许的最大线程数。当工作队列满了,且活动线程数小于最大线程数时,会创建新的线程来处理任务。(最大线程数,理解为一个公司的最多所有成员数目,核心成员外包成员(会被开除),当项目太多的时候(工作队列满),就开始招聘外包成员,但招的外包成员加上核心成员不会超过maximumPoolSize)

  • keepAliveTime: 表示线程空闲时的存活时间。当线程池中的线程数量超过 corePoolSize,且某个线程(非核心线程)空闲时间超过 keepAliveTime,该线程将被终止,直到线程池中的线程数不超过 corePoolSize。(当项目完成后(工作队列空),且等待keepAliveTime这么长时间后,开除外包员工(销毁非核心线程))

  • unit:keepAliveTime 配合使用,表示 keepAliveTime 的时间单位,通常是秒、毫秒等。

  • workQueue: 用于保存等待执行的任务的阻塞队列(工作队列)。可以选择不同的队列实现,如 LinkedBlockingQueueArrayBlockingQueue 等,用于控制任务的排队策略。(工作队列需要设置一个合适的长度,太长了也会占用系统资源)

  • threadFactory: 用于创建新线程的工厂。提供了一种自定义线程创建的机制,可以用来设置线程的名称、优先级等。

  • handler: 表示当线程池中的工作线程达到最大线程数并且阻塞队列已满时的饱和策略。可以选择使用预定义的策略,查看源码可以看到提供有四中策略:
    在这里插入图片描述
    其中默认策略为AbortPolicy:
    image-20240125222517330

    AbortPolicy (默认策略): 默认饱和策略。当任务无法提交给线程池执行时,会抛出 RejectedExecutionException 异常,通知调用者任务无法被接受。

    CallerRunsPolicy: 将任务返回给调用线程来执行(一般是主线程)。这种策略不会抛出异常,而是尝试在调用线程中执行该任务。

    DiscardPolicy: 会默默地放弃无法处理的任务,不提供任何通知或记录。

    DiscardOldestPolicy: 在队列满的情况下,尝试将最早进入队列的任务移除,为新任务腾出空间,然后将新任务添加到队列中,如果失败则按该策略不断重试,不会抛出异常。

    当然也可以自定义策略实现。
    需要根据实际情况合理设置核心线程数、最大线程数和阻塞队列,可以在不同的场景下平衡线程池的性能和资源消耗。


下面画图理解一下:

假设核心线程有2个(corePoolSize),线程池最多能有4个线程(maximumPoolSize),任务队列最多能容纳4个任务(workQueue)。

(2个核心员工,最多再招2两个外包)

初始状态

image-20240125204125140

来了两个任务

当线程池有空闲线程时,有了最新任务不进任务队列,直接分配给空闲线程处理;

image-20240125204920882

任务1和任务2未处理完(无空闲线程),此时又来了四个任务

此时并不会创建非核心线程处理最新任务,而是先把最新任务加入任务队列存储,此时任务队列已满

image-20240125212301991

任务1和任务2未处理完(无空闲线程),并且任务队列已有四个任务已满,此时又来了两个任务

此时线程池就会新增两个非核心线程(线程池最多容纳四个线程),用来处理新来的两个任务,但不是优先处理队列中的任务;

(若此时只来了一个任务,则只创建一个非核心线程处理该任务)

image-20240125212354612

若线程池已满,且无空闲线程,并且任务队列已满,此时若又来了一个任务

这时就会触发饱和策略handler进行处理,比如舍弃该任务,或者主线程介入处理等,默认情况时拒绝处理,会抛出RejectedExecutionException异常;

image-20240125212457519

当线程3和线程4空闲后(此时所有任务已处理完),当空闲时间达到最大空闲时间时,线程3和线程4被释放,线程池只剩下线程1和线程2两个核心线程;


这就是线程池的处理大致流程,一定要记住那几个线程池核心参数,结合图中内容理解。

在实际开发中难点主要是如何确定线程池参数,一定要涉及到多方面的考虑,包括应用的性质、系统资源、任务的性质等。这里提供一些判断的思路;

corePoolSize: 根据应用的负载情况和任务的性质设置。如果任务是计算密集型,并且负载比较高,可以增加 corePoolSize。对于 I/O 密集型任务,可以适度减小 corePoolSize

maximumPoolSize: 根据系统资源和任务性质进行设置。如果任务是 I/O 密集型,并且可能发生阻塞,可以适度增加 maximumPoolSize。但是,不宜过度设置,以免占用过多的系统资源。

keepAliveTime 和 unit: 适度设置,避免线程过早终止或过长存活。如果任务的执行时间相对较短,可以考虑设置较短的 keepAliveTime,一般设置为秒级或者分钟级。

workQueue: 选择适合应用场景的队列类型。对于有界队列(如 ArrayBlockingQueue),可以帮助控制线程数,防止无限制的线程增长,但也可能导致任务被拒绝。无界队列(如 LinkedBlockingQueue)可以避免任务被拒绝,但可能导致线程数无限增长。

threadFactory: 根据需要进行设置,可以用于为线程指定有意义的名称、设置优先级等。

handler: 根据应用的容错需求选择合适的饱和策略。例如,CallerRunsPolicy 可以将任务退回给调用线程,避免任务丢失,但可能导致调用线程也过载。

补充一下IO密集型和计算密集型:

一般情况下,任务可以分为IO密集型和计算密集型(CPU密集型);

  • 对于IO密集型任务,主要由于涉及到大量的读写操作,如文件操作、网络通信、数据库访问等操作,在这种任务类型中,大部分时间线程都在等待 I/O 操作完成,而不是在执行计算操作,即对CPU的利用率不高。由于线程大部分时间都在等待 I/O 操作,可以使用较大的核心线程数和最大线程数,以确保有足够的线程可以处理等待的 I/O 操作,提高任务响应性,但是还是要根据设备的I/O性能来设定。
  • 对于计算密集型任务,其中涉及大量的计算操作,例如数学运算、图形处理等。在这种任务类型中,线程大部分时间都在执行计算操作,而不是等待 I/O 操作。所以可以使用较小的核心线程数和最大线程数,避免占用过多的系统资源和减少线程间的冲突,因为增加线程数可能导致竞争和上下文切换。一般将核心线程数设置为 CPU 的核数加一。这个"加一”可以理解为预留一个额外的线程,或者说一个备用线程,用来处理其他任务。这样做可以充分利用每个CPU 核心,减少线程间的频繁切换,降低开销;在这种情况下,对 maximumPoolSize 的设定没有严格的规则,一般可以设为核心线程数的两倍或三倍。

补充思路:反向压力可以动态控制线程数,简单来说系统压力小时可以多增加几个线程,压力大时减少几个线程,或者可以系统压力大时使用异步,压力小时使用同步,这一块属于大数据内容,后续学习了再补充;

代码实现

下面用一个代码案例模拟一下,以便更直观的理解和应用;

还是用上面的案例:假设核心线程有2个(corePoolSize),线程池最多能有4个线程(maximumPoolSize),任务队列最多能容纳4个任务(workQueue),非核心线程释放时间为100s

先自定义一个线程池的Bean

@Configuration
public class ThreadPoolExecutorConfig {

    @Bean
    public ThreadPoolExecutor threadPoolExecutor() {
        ThreadFactory threadFactory = new ThreadFactory() { // 自定义线程工厂的
            private int count = 1;
            @Override
            public Thread newThread( Runnable r) {
                Thread thread = new Thread(r);
                thread.setName("线程" + count); // 设置线程名称
                count++;
                return thread;
            }
        };

        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(2, 4, 100, TimeUnit.SECONDS, 
                                                                       new ArrayBlockingQueue<>(4), threadFactory);
        return threadPoolExecutor;
    }
}

此时没有设置饱和策略,使用的是默认AbortPolicy策略,即抛出异常;

写一个测试Controller

@RestController
@RequestMapping("/queue")
@Slf4j
public class QueueController {

    @Resource
    private ThreadPoolExecutor threadPoolExecutor;

    // 新增任务
    @GetMapping("/add")
    public void add(String name) {
        CompletableFuture.runAsync(() -> {
            log.info("任务执行中:" + name + ",执行线程:" + Thread.currentThread().getName());
            try {
                Thread.sleep(600000); // sleep模拟执行一个耗时的任务
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, threadPoolExecutor);
    }

    // 获取当前线程池状态
    @GetMapping("/get")
    public String get() {
        Map<String, Object> map = new HashMap<>();
        int size = threadPoolExecutor.getQueue().size();
        map.put("queue size", size);
        long taskCount = threadPoolExecutor.getTaskCount();
        map.put("task count", taskCount);
        long completedTaskCount = threadPoolExecutor.getCompletedTaskCount();
        map.put("complete task", completedTaskCount);
        int activeCount = threadPoolExecutor.getActiveCount();
        map.put("active thread", activeCount);
        return JSONUtil.toJsonStr(map);
    }
}

调用add接口,先新增两个任务(1、2),则两个核心线程(线程1和线程2)被占用:

在这里插入图片描述
在这里插入图片描述

控制台输出:
image-20240125224715891
再增加四个任务(3、4、5、6),此时任务队列已满:
在这里插入图片描述
断点调试get接口可以看到任务队列中等待的任务:
image-20240125225006584

再增加两个任务(7、8),则非核心线程会创建(线程3和线程4)处理这两个任务,此时共4个线程,已达到最大线程数:

在这里插入图片描述
image-20240125225201924
此时如果再增加任务(9),则会触发默认AbortPolicy饱和策略,抛出异常:

在这里插入图片描述

直到任务队列有空位时才可以继续增加任务。

线程1、2、3、4执行完各自的任务后依次执行队列中的任务:

在这里插入图片描述
在这里插入图片描述

最后非核心线程执行完成后100s后空闲自动释放:
在这里插入图片描述

这就是大致的线程池操作了,线程池核心参数和整个任务分配和线程管理的流程是关键。

总结

线程池是优化并发执行的关键工具,通过合理配置参数、选择适当的任务队列和饱和策略,可以充分发挥其优势。在实际应用中,根据任务类型、负载和性能需求进行调整,动态监控线程池的状态,将使得线程池更好地适应不同的应用场景,提高系统的并发性能。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

YXXYX

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值