【线程池】如何正确的配置一个线程池

邓敏 2021年12月17日 108次浏览

我们在创建自己的线程池时,会时常因为不知道给核心线程数或者最大线程数设置多少为好,其实这个时需要看你的线程池的使用场景和服务器CUP的配置,根据这些前置条件,我们再去判断如何去设置合适的线程数,并不是我们想设置多少线程数大小就可以设置多少,这样可能会导致线程发挥不到最大的性能,甚至还有可能会导致服务OOM堆栈溢出的风险。

使用场景

CPU密集型任务

当我们遇到那种需要大量使用CUP的任务时,比如加密、解密、压缩、计算等一系列操作,这种情况理想的线程核心数是CPU核心数的1~2倍,不宜设置的太多。如果设置太多的线程数,当线程任务越来越多的时候,导致CUP的线程的压力会越来越大并且线程的数量也会越来越多,导致CPU去切换线程上下文的时间成本会越来越多,这样不仅不会提升线程的性能,可能还会导致线程的的性能越来越低。

耗时IO型任务

耗时IO其实就是不太耗CUP的资源,只是会在执行上会有一定的等待时间,比如操作数据库,读取大文件,或者请求接口等。当我们从数据库查询一个很大的数据时,需要等待很久的时间,其实这个等待的时间他并不消耗CPU的资源。所以在这种场景上,我们设置的最大线程数其实可以大于核心线程数很多倍。因为他大部分线程都不怎么消耗线程资源。

计算公式

那么场景有了,我该如何去获取我们可以设置的核心线程数的值呢?
可以看到下面这个公式

《Java并发编程实战》的作者 Brain Goetz 推荐的计算方法:

线程数 = CPU 核心数 *(1+平均等待时间/平均工作时间)

可以看到

  • 平均等待时间越长,就代表是耗时IO型任务,这时线程数就会越大。
  • 平均工作时间越长,就代表是CPU密集型任务,这时线程就会越少。

公式有了,但是又缺少线程的时间,执行时间和等待时间我该从哪去获取呢?那么这个就需要我们去借用一些工具了,想要获取到准确的时间,就需要进行压测,然后监控JVM的线程情况以及CPU的负载情况。最后得到他们的时间,然后合理充分的利用资源。

配置参数标准

了解了线程场景后,我们再来看一下,如何通过我们需要的应用场景来设置对于的线程池初始参数。

核心线程数和最大线程数

上面我们说到过,设置合理的线程数,需要看线程的使用场景是CPU密集型任务还是耗时IO型任务。
核心线程数的设置量就按照上面的公式来计算

  • CPU密集型任务:最大线程数为核心线程数的1~2倍
  • 耗时IO型任务:最大线程数为核心线程数的2~5倍(依场景来设定)

那么有没有通用的设置方法,就是可以适用于CPU密集型任务和耗时IO型任务都适用的设置方法。如果想设置这样的线程,核心线程数不变,但是最大线程数可以设置的稍微大一点,一般为核心线程数的3~4倍。这种是常用方法,只有在你没有对线程性能进行压测的情况下进行设置,如果想获取最准确的,还是以压测后的线程状态,依情况来合理设置。

阻塞队列

之前我们有介绍过LinkedBlockingQueue,SynchronousQueue,DelayedWorkQueue这三种队列,具体可以参考
常用的三种阻塞队列这篇文章,这三种队列其实都有一个缺点,那就是不好控制线程的的最大数量,这样可能会导致出现因为队列中的线程任务太多导致内存爆满爆出OOM异常。为了避免这种情况,我们再平时设置自己的线程时,都回去使用ArrayBlockingQueue这个阻塞队列,这个阻塞队列唯一的好处就是,他可以设置队列的大小,并且队列满了之后,他也不会将塞不进来的线程任务抛弃,而是会阻塞着,然后根据线程池设置的拒绝策略来执行,这样也就避免了丢失线程任务的风险。那么根据使用场景,我们该如何去设置队列的容量呢?如果我们使用容量更大的队列和更小的线程数,就可以减少上下文切换带来的开销,但也可能会因此降低整体的吞吐量,如果我们的任务是IO密集型,则可以选择稍小容量的队列和更大的最大线程数,这样整体的线程效率就会高,不过也会带来更大的上下文切换。我们知道了这个东西之后,其实会发现设置多大的队列大小,并没有一个准确的公式,而是慢慢的业务运行的时候慢慢的试错,当发现我们的容量设置的并不理想,则需要依照当时的情况来调整。

线程工厂

线程工厂的作用其实就是创建线程,如果你对线程池的默认创建线程的方式不满意,想要对线程池里面的线程名字修改,或者在创建线程的时候去执行一些前置任务,这个时候你就可以自己创建一个线程工厂,示例代码如下。

public class TestThreadFactory implements ThreadFactory {

    private static AtomicInteger atomicInteger = new AtomicInteger();

    @Override
    public Thread newThread(Runnable r) {
        atomicInteger.addAndGet(1);
        return new Thread(r,"自定义名字-"+atomicInteger.get());
    }
}

拒绝策略

拒绝策略目前有四种,AbortPolicy,DiscardPolicy,DiscardOldestPolicy 或者 CallerRunsPolicy。详细的作用我们就不说了,可以移步到线程池的4种拒绝策略这篇文章中查看,按照自己的需求来使用合适的拒绝策略,当然,如果你不满足这四种线程策略,想要自己去整活,线程池也会给你机会,这时你就可以通过实现RejectedExecutionHandler类来实现自己的拒绝策略,示例代码如下。

public class MyRejectPolicy implements RejectedExecutionHandler {
    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
        System.out.println("被拒绝了");
    }
}

总结

说了这么多,大家可能会发现,其实线程池的创建与配置,并没有一个统一的标准,文章中给出的一些公式,其实仅仅供于在第一次创建线程池的时候的配置,如果到后面发现,当先创建的线程规则不满足于现状,其实这个时候你就可以以自己看到的状况来定,比如线程执行的时间过长,并且很占用时间,但又不消耗CPU资源,这个时候其实你就会觉得我可以吧最大线程池扩大一点,相反遇到线程执行很慢,并且同时执行的线程又很多,这个时候可能就是遇到了CPU密集执行的情况,这时就可以将最大线程数调小一点,线程的队列容量再调大一点。这样下来,可能就会有不一样的效果。