谈谈线程池的使用经验

1,682 阅读6分钟

线程池的使用场景

一般来说,我使用线程池最多的地方有两点:

  • 对大量耗时任务的并发处理
  • 对频发业务的线程池隔离

通过一年来在工作中对于线程池的使用,也总结了一些还算是比较实用的经验。如果各位小伙伴有更好的经验,欢迎指正和分享!

线程池的创建方式

我相信很多小伙伴都很熟悉一些常用线程池的创建方式,比如说通过Executors来获取JDK封装的那几个常用的线程池,以及通过new ThreadPoolExecutor()的方式来创建自定义线程池。

网上对于这两种创建方式的讲解资料可以说已经烂大街了哈,在这里就不再一一讲解(不熟悉的小伙伴可以先去学习一下线程池的基本使用和阿里开发者手册中对于线程池的使用规范)。

那么在这里就先聊一下我个人对于他们的认识吧:

首先我觉得对于线程池的使用并不需要一直的保持那么苛刻,就像下面这种方式:

// 参数随便举个例子
ExecutorService executor = new ThreadPoolExecutor(
        2, 
        10, 
        1000, 
        TimeUnit.MICROSECONDS, 
        new LinkedBlockingQueue<>(100)
);

其实我觉得还是要去区分一定的业务场景的。我觉得这种场景更加适用于数据量范围较为明确,但某个时间段内的数据量并不均匀的情况。

但除此之外的另一种场景,我觉得更加适用于固定线程数的线程池。就比如说,我一个List中有100条数据,每条数据的处理时间为100ms。那么针对于这种数据量固定且并不是很大的场景来说,我觉得我们只将重点放在用多少线程才能用最短的时间去处理完业务就好了,这个时候就可以直接去使用Executors.newFixedThreadPool(),简单粗暴!

ExecutorService executorService = Executors.newFixedThreadPool(10);

还有另外一种创建线程池的方式,也是我平时最喜欢用的。就是使用Spring为我们封装的ThreadPoolTaskExecutor。 我为什么喜欢它呢?因为他可以更方便的自定义线程前缀,更利于我们后期对于线程池的监控和故障排查。用ThreadPoolExecutor的话还得去手动创建线程工厂,不喜欢那么麻烦!

他的创建方式如下:

ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程数
executor.setCorePoolSize(2);
// 最大线程数
executor.setMaxPoolSize(10);
/**
 * 阻塞队列长度
 * 长度=0时,使用SynchronousQueue
 * 长度>0时,使用LinkedBlockingQueue
 */
executor.setQueueCapacity(100);
// 临时线程在空闲状态下的存活时间(单位为秒)
executor.setKeepAliveSeconds(1);
// 拒绝策略
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
// 线程名前缀
executor.setThreadNamePrefix("executor-");
// 显式调用shutdown时,等待线程池中所有的任务均完成后再销毁线程池,默认为false
executor.setWaitForTasksToCompleteOnShutdown(true);
// 显式调用shutdown时,如果在指定时间内线程池内的线程还没有全部执行完成,就强制销毁线程池(单位为秒)
executor.setAwaitTerminationSeconds(30);

以上是一些常规线程池的使用,对于调度线程池的使用不再本文进行分享,后续会分享一篇关于任务调度的文章。大家敬请期待呀,哈哈!

对大量耗时任务的并发处理

这种业务场景一般来说会有两种处理思路,主要还是根据具体的业务场景来进行选择:

  • 后端线程池异步处理,主线程不进行等待
  • 主线程等待线程池处理完毕后再结束

下面针对于第二种情况进行分享,因为第一种情况比较简单。第二种情况的处理就是在第一种情况的基础上借助CountDownLatch来实现主线程的等待。

// 要处理的数据
List list = new ArrayList();
// 创建固定线程池
ExecutorService executor = new ThreadPoolExecutor(
        list.size(), list.size(), 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue()
);
// 创建计数器,初始数量为要处理的任务数量
CountDownLatch latch = new CountDownLatch(list.size());
// 创建数据列表
for(int i = 0; i < list.size(); i++) {
    executor.execute(() -> {
        try {
            // 要处理的业务逻辑
        } catch (Exception e) {
            // 业务异常时的处理
        } finally {
            // 计数器-1
            latch.countDown();
        }
    });
}
// 继续处理线程池中剩余的任务且不再接收新任务
executor.shutdown();

try {
    // 计数器 > 0时,主线程阻塞等待
    latch.await();
} catch (InterruptedException e) {
    e.printStackTrace();
}

以上这种情况是通过使用固定线程数的线程池进行业务处理的。一般来说在这种情况下,如果List不造成OOM的话、不出现长事务的话,基本上也就不会出现什么问题。

这里需要注意的就是:

  1. List中的数据量要控制好,别搞成OOM了。
  2. 通过多次的压测来确认合适的线程数。
  3. CountDownLatch的初始化容量一定要等于List的长度,否则有你好受的。
  4. 线程池内一定要用try-catch-fianlly,而且latch.countDown()一定要放在finally中,确保无论如何也要保证计数器的自减。
  5. 对于业务的处理一定要把控好总体时间,别搞的长事务了。可以通过指定主线程阻塞的最大时间来作为保底方案,避免长事务的出现。例如:latch.await(10, TimeUnit.SECONDS);

对频发业务的线程池隔离

一般情况下,我们也需要对访问量较大的接口或下游业务使用线程池隔离的方式,避免因为某一项业务将整个系统的线程池资源占用的情况。

对于某个访问量较大的接口进行线程池隔离

这种处理方案就是客户端调用接口后,主线程将任务分配给线程池中的子线程去执行。这种解决方案虽然说在一定程度上保证了系统的可靠性,但是我觉得使用信号量隔离要更好一些(就是我们常说的限流)。

对下游业务进行线程池隔离

比如说现在产品产出业务执行完毕后要通过异步任务去执行对于下游业务接口参数的封装以及接口调用的工作。前面两篇对于异步调用的文章中已经分享了异步的使用和优化。

而我们也都清楚,在使用@Async进行异步任务调用的时候,对于一些请求量较大的下游业务,我们都会给它分配一个线程池进行隔离,避免当前业务频繁调用时拖垮整个系统的线程池。

@Async("pushWMSExecutor")
public void pushWMS() {
    ...
}

@Bean("pushWMSExecutor")
public Executor executorService() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(2);
    executor.setMaxPoolSize(10);
    executor.setQueueCapacity(100);
    executor.setKeepAliveSeconds(1);
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
    executor.setThreadNamePrefix("executor-");
    return executor;
}

但是我们在使用这种方式前要注意一个事情,就是拒绝策略。如果在某个场景下,这个业务调用频率出乎了我们的意料,就很有可能触发拒绝策略。这个拒绝策略我们一定要根据实际的业务场景进行选择好,否则一旦出现事故就是非常严重的。

JDK为我们提供了四种拒绝策略:

拒绝策略描述
AbortPolicy(默认)丢弃当前任务并抛出运行时异常RejectedExecutionException
DiscardPolicy丢弃当前任务,不会有返回任何的提示
DiscardOldestPolicy丢弃最早进入队列且未被消费的那个任务,并将新任务放进队列中
CallerRunsPolicy任务不进入线程池,而是由当前线程执行

但是仔细想一想,在这个业务中,这四种拒绝策略好像并没有合适的。如果选用DiscardPolicy或DiscardOldestPolicy的话,要配合定时任务去扫描未处理的数据进行补偿;如果使用CallerRunsPolicy,但是如果任务过多呢?都用主线程执行吗?显然不太合理的!

这个时候我们可是去自定义一个拒绝策略。看看线程池的源码就知道,要自定义一个拒绝策略无非就是实现它的异常处理器接口,然后重写他的方法就ok了。以上四种也是这么干的。

public class CustomRejectionPolicy implements RejectedExecutionHandler {

    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
        .....
    }
}

在这里我们可以根据自己的需要对触发拒绝策略的任务进行移交备用队列进行延时缓存处理或做一些其他的事情。