线程池提供一种限制和管理资源(包括执行一个任务)。每个线程池还维护一些基本统计信息,例如已完成任务的数量

线程池的好处:

  1. 降低资源的消耗
  2. 提高相应速度
  3. 提高线程的可管理性

线程池在实际项目的使用场景

线程池一般用于执行多个不相关联的耗时任务,没有多线程的情况下,任务顺序执行,使用了线程池可以让多个不想关联的任务同时执行

假如要执行三个不想管的耗时任务。下面三个任务可能是同一件事,也可能是不一样的事
1bc44c67-26ba-42ab-bcb8-4e29e6fd99b9.png

如何使用线程池

ThreadPoolExecutor的构造函数来创建线程池,然后将任务提交给线程池执行

/**
    * 用给定的初始参数创建一个新的ThreadPoolExecutor。
    */
public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量
                            int maximumPoolSize,//线程池的最大线程数
                            long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间
                            TimeUnit unit,//时间单位
                            BlockingQueue<Runnable> workQueue,//任务队列,用来储存等待执行任务的队列
                            ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可
                            RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务
                            ) {
    if (corePoolSize < 0 ||
        maximumPoolSize <= 0 ||
        maximumPoolSize < corePoolSize ||
        keepAliveTime < 0)
        throw new IllegalArgumentException();
    if (workQueue == null || threadFactory == null || handler == null)
        throw new NullPointerException();
    this.corePoolSize = corePoolSize;
    this.maximumPoolSize = maximumPoolSize;
    this.workQueue = workQueue;
    this.keepAliveTime = unit.toNanos(keepAliveTime);
    this.threadFactory = threadFactory;
    this.handler = handler;
}

线程池最佳实践

使用ThreadPoolExecutor构造函数声明线程池

  1. 线程池必须手动通过ThreadPoolExecutor的构造函数声明,避免使用Executors类的newFixedThreadPool和newCachedThreadPool。可能会有OOM风险

    Executors返回线程池对象的弊端:

    • FixedThreadPool 和SingleThreadExecutor:允许请求的队列长度为Integer.MAX_VALUE,可能堆积大量请求,从而导致OOM
    • CachedThreadPool 和 ScheduledThreadPool:允许创建的线程数量为Integer.MAX_VALUE,可能会创建大量线程,从而导致OOM

使用有界队列,控制创建线程数量

除了避免OOM之外,不推荐Executors提供的 两种快捷线程池的原因还有:

  1. 实际使用中需要根据自己机器的性能、业务场景来手动配置线程池的参数,比如核心线程数,使用的任务队列,饱和策略等
  2. 应该显示地给线程池命名,有助于定位问题

监测线程池运行状态

SpringBoot中的Actuator组件可以来检测线程池的运行状态

还可以利用ThreadPoolExecutor的相关API做简陋监控。ThreadPoolExecutor提供获取线程当前线程数和活跃线程数、已执行完的任务数,正在排队中的任务数等等。
ddf22709-bff5-45b4-acb7-a3f2e6798608.png

demo:printThreadPoolStatus():每隔一秒打印出线程池的线程数、活跃线程数、完成的任务数、以及队列中的任务数

/**
* 打印线程池的状态
*/
    public static void printThreadPoolStatus(ThreadPoolExecutor threadPool) {
        ScheduledExecutorService scheduledExecutorService = new ScheduledThreadPoolExecutor(1, createThreadFactory("print-images/thread-pool-status", false));
        scheduledExecutorService.scheduleAtFixedRate(() -> {
            log.info("=========================");
            log.info("ThreadPool Size: [{}]", threadPool.getPoolSize());
            log.info("Active Threads: {}", threadPool.getActiveCount());
            log.info("Number of Tasks : {}", threadPool.getCompletedTaskCount());
            log.info("Number of Tasks in Queue: {}", threadPool.getQueue().size());
            log.info("=========================");
        }, 0, 1, TimeUnit.SECONDS);
    }

不同类别的业务使用不同的线程池

不同的业务使用不同的线程池,配置线程池的时候根据当前业务的情况对当前线程池进行配置,因为不同的业务的并发以及对资源的使用情况都不同,重心优化系统性能瓶颈相关的业务。

线程池运用不当的一次线上事故

5b9b814d-722a-4116-b066-43dc80fc1dc4.png

上面的代码可能会存在死锁情况

极端情况

假设线程池的核心线程数为n,父任务(扣费任务)数量为n,父任务下面有两个子任务(扣费任务下的子任务),其中一个已执行完成,另外一个被放在任务队列中。由于父任务把线程池核心线程资源用完,所以子任务因为无法获取线程资源无法正常执行,一直被阻塞在队列中。父任务等待子任务执行完成,而子任务等待父任务释放线程,造成死锁。
7888fb0d-4699-4d3a-8885-405cb5415617.png

解决方法:新增一个用于执行子任务的线程专门为其服务

线程池命名

初始化线程池时需要显示命名(设置线程池名称前缀),有利于定位问题

默认情况下,创建的线程名类似pool-1-thread-n这样,没有业务含义,不利于定位问题

给线程池的线程命名通常有两种方式

  1. 利用guava的ThreadFactoryBuilder
    ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat(threadNamePrefix + "-%d").setDaemon(true).build();
    ExecutorService threadPool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.MINUTES, workQueue, threadFactory);
  2. 自己实现ThreadFactory
    import java.util.concurrent.Executors;
    import java.util.concurrent.ThreadFactory;
    import java.util.concurrent.atomic.AtomicInteger;
    /**
     * 线程工厂,它设置线程名称,有利于我们定位问题。
     */
    public final class NamingThreadFactory implements ThreadFactory {
    
        private final AtomicInteger threadNum = new AtomicInteger();
        private final ThreadFactory delegate;
        private final String name;
    
        /**
         * 创建一个带名字的线程池生产工厂
         */
        public NamingThreadFactory(ThreadFactory delegate, String name) {
            this.delegate = delegate;
            this.name = name; // TODO consider uniquifying this
        }
    
        @Override 
        public Thread newThread(Runnable r) {
            Thread t = delegate.newThread(r);
            t.setName(name + " [#" + threadNum.incrementAndGet() + "]");
            return t;
        }
    
    }

正确配置线程池参数

上下文切换:任务从保存到再加载的过程就是一次上下文切换。当前任务在执行完CPU时间片切换到另外一个任务之前先保存自己的状态,以便下次再切换回这个任务,可以再加载这个任务的状态。

  • CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
  • I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。

参考阅读

JavaGuide
Java线程池实现原理及其在美团业务中的实践