线程池从入门到上线通关攻略

760 阅读15分钟

作为一个合格的打工人,我对于线程池肯定是了解,玩过,但是没有什么实际使用经验。但是,当你你接了个需求需要用到线程池这个时候,赶鸭子上架也得上。根据我的一次需求上线经历,总结出这一篇线程池上线通关攻略。

一、线程池的构造方法

构造函数

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.acc = System.getSecurityManager() == null ?
            null :
            AccessController.getContext();
    this.corePoolSize = corePoolSize;
    this.maximumPoolSize = maximumPoolSize;
    this.workQueue = workQueue;
    this.keepAliveTime = unit.toNanos(keepAliveTime);
    this.threadFactory = threadFactory;
    this.handler = handler;
}

参数含义

  • corePoolSize:线程池的核心线程数,即线程池中保持活动的最小线程数。即使这些线程处于空闲状态,除非设置了allowCoreThreadTimeOut,否则它们不会从池中删除。

核心线程数的设置需要谨慎一点,设置太小的话会去额外创建很多线程,可能会导致线程频繁地创建和销毁设置太大的话也会额外占用资源。所以要根据并发量,任务处理效率综合考虑,总之需要多方面考虑,仔细权衡。

allowCoreThreadTimeOut参数决定了核心线程是否会在空闲时间过后超时关闭,相当于对上述设置太大额外占用资源的优化。其默认为false,这就是临时工和核心员工的差距吧。 如果你也想把核心员工变成临时工,开源节流就将其设置为true。

需要注意这个监控明显会增加一个活跃时间监控,具体对性能有没有影响需要深入探究下(好像有影响但是影响极小)

  • maximumPoolSize:池中允许的最大线程数,必须大于等于corePoolSize。

如果核心线程都在满负荷,仍然有任务提交,就会考虑最多增加(maximumPoolSize-corePoolSize)个临时线程一起完成任务。

  • keepAliveTime:当线程数大于corePoolSize时,这是多余空闲线程在终止前等待新任务的最长时间。

其面说了既然是增加的临时工,那肯定有被裁员的时候,服务器资源这么珍贵,当任务完成的差不多的时候,增加的临时线程空闲keepAliveTime时间后就会被销毁。一般设置为30或60,可灵活变通。

  • unit:keepAliveTime参数的时间单位

一般为秒,也就是TimeUnit.SECONDS

  • workQueue:在任务执行之前用于保存任务的队列。此队列将只包含execute方法提交的Runnable任务。

当核心线程满负荷,仍有任务提交,就会先缓存在这个工作队列中的,等待有空闲线程就会从任务队列中取出任务执行。队列有五种可选

  1. ArrayBlockingQueue:有界队列,基于数组的先进先出队列,此队列创建时必须指定大小。一般就是用这种。
  2. LinkedBlockingQueue:无界队列,基于链表的先进先出队列,如果创建时没有指定此队列大 小,则默认为Integer.MAX_VALUE。
  3. SynchronousQueue:一个不存储元素的阻塞队列,即直接提交给线程不保持它们。
  4. PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。(线程有setPriority可以改变优先级)
  5. DelayQueue:一个使用优先级队列实现的无界阻塞队列,只有在延迟期满时才能从中提取元素。(可用于定时任务调度和缓存系统设计)
  • threadFactory:执行器创建新线程时要使用的线程创建Factory类

可不填用默认的threadFactory,也可自定义。自定义好像也就是改一改名字什么的。 image.png

  • handler:当由于达到线程边界和队列容量而阻止执行时要使用的处理程序

可不填,用默认的拒绝策略,也可自定义选择四个拒绝策略之中一个,一般选择用AbortPolicy。 image.png 四个拒绝策略分别为:

  1. CallerRunsPolicy(默认):当任务被拒绝时,这个策略会让调用线程自行执行这个任务。这种情况下,不会抛出异常。
  2. AbortPolicy:这个策略会在任务被拒绝时抛出未检查的RejectedExecutionException这个策略一般用于生产环境,因为抛出异常可以立即让开发者知道有任务被拒绝了,但是它也可能导致应用程序中断,需要做好catch
  3. DiscardPolicy:这个策略会静默地丢弃被拒绝的任务,不会抛出异常。如果任务是Runnable,那么它不会被执行。如果是Callable,则它的call方法不会被调用,也就无法返回结果。这个策略适用于那些被拒绝的任务不重要或者对系统没有副作用的情况。
  4. DiscardOldestPolicy:这个策略会丢弃队列中等待最久的任务,然后抛出RejectedExecutionException。这个策略可以避免队列中的任务一直等待,但是它可能会导致正在执行的任务被突然中止,对系统可能有较大的副作用。

二、任务提交流程

execute方法源码

这是ThreadPoolExecutor的一个公共方法,用于提交一个Runnable任务给线程池执行。

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);
}

逐行代码分析

  1. if (command == null) throw new NullPointerException();: 如果提交的任务是null,那么将抛出一个空指针异常。
  2. int c = ctl.get();ctl是一个内部类,其实是一个整数,用于表示线程池的状态和控制。get()方法用于获取当前值。
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));

这里的线程池本身就是一个多线程应用场景,所以为了线程安全,这里用的是AtomicInteger。

参考原子操作类AtomicInteger详解-CSDN博客

  1. if (workerCountOf(c) < corePoolSize): 这里检查当前线程池中的工作线程数量是否小于核心线程数(即最小线程数)。如果是,则尝试添加新的工作线程。
  2. if (addWorker(command, true)) return;: 在核心线程中执行任务。如果成功添加工作线程,则直接返回。

addWorker(command, true)将任务分配线程,第二参数表示分配的是否是核心线程。例如这个true,就代表将任务分配给核心线程。显而易见该方法会返回执行结果true or false

  1. c = ctl.get();: 如果前面添加工作线程失败(可能由于其他线程已经添加成功),则重新获取ctl的值。
  2. if (isRunning(c) && workQueue.offer(command)): 如果线程池处于运行状态并且任务可以被添加到队列中,那么任务添加到队列中。

workQueue.offer(command)的官方注释:如果可以在不违反容量限制的情况下立即将指定的元素插入此队列,则在成功时返回true;如果当前没有可用空间,则返回false。

当使用容量受限的队列时,此方法通常比add更可取,后者可能仅通过抛出异常而无法插入元素。

  1. int recheck = ctl.get();: 重新获取ctl的值。
  2. if (! isRunning(recheck) && remove(command)) reject(command);: 如果线程池不再运行并且任务可以从队列中移除,那么将任务从队列中移除并且拒绝执行。
  3. else if (workerCountOf(recheck) == 0) addWorker(null, false);: 如果重新检查后发现没有工作线程,则尝试添加一个没有任务的线程,保持线程池处在活跃状态。

此时的背景是线程池或者没在工作或者任务已经移除。(说实话,这个放个空任务到底有没有必要,我觉得逻辑不是非常严谨,可能我还没想明白吧)

  1. else if (!addWorker(command, false)) reject(command);: 如果不能添加工作线程,那么拒绝执行任务。

理一下逻辑:

  1. 如果有空闲的核心线程,就尝试给核心线程分配任务。如果分配成功,则直接流程结束,皆大欢喜。如果分配失败(可能是线程池不再运行了;也可能是有其他的妖艳贱货抢占了所有的核心线程),则继续下面的流程。

  2. 如果线程池还在工作,并且任务队列没有满,则尝试将任务添加到队列中,并在添加后继续仔细检查:

    2.1 再次判断线程池是否工作(可能池在进入此方法后关闭),如果线程池不再运行了并且任务从队列中移除成功,任务直接抛出被拒绝的异常。

    2.2 如果这个时候任务运行没有工作在运行了,放个空任务进去,保持线程池处在活跃状态。

  3. 如果我们不能对任务进行排队,那么我们尝试添加一个新的非核心线程处理任务。如果添加失败了,我们就知道我们已经线程池关闭了或队列饱和了因此拒绝该任务。

需要知道的流程顺序

从读这段代码开始,我就处于一个懵逼的状态,因为我对线程的一些概念本身就不是很了解,读下去对里面的流程的了解也不是非常清晰。

但是我知道了核心的几个顺序。

if 任务提交给核心线程成功
    return
else 任务提交给核心线程失败
    if 任务塞进队列里面成功
        return
    else 任务塞进队列里面失败
        增加非核心线程执行任务

三、线程池在实际用的时候几个注意事项

1. 合理设置线程数:

线程池中的线程数量需要根据实际需求和系统资源进行合理设置。如果线程数过多,会导致系统调度开销增大,反而降低系统效率;如果线程数过少,则可能导致任务不能及时得到执行,降低系统响应能力

例如我们可以先通过CAT等监控平台,了解这个接口的并发量,平均耗时,峰值。

image.png

从上图来看,这个接口的并发量长时间在每分钟1.5万左右,峰值接近三万。平均耗时在15毫秒左右。还有一个需要额外关注的信息,并发峰值时,接口平均耗时明显增加

为了能够这个接口承受住最坏的情况。我们以每分钟3万并发量,平均耗时20毫秒来考虑。

一分钟一个线程可以处理的任务数是:60*1000/20=3000个

因为为了能够应对极端情况,10个线程每分钟处理30000个任务,看起来完全足够,当然我们要考虑冗余设计,取个整数corePoolSize设置为16。

maximumPoolSize一般就设置为corePoolSize的两倍(服务不太稳定),或者与corePoolSize相同(服务较为稳定)。我们这个集群经常抽疯,同时任务又比较重要,所以我的maximumPoolSize设置为32。

maximumPoolSize设置比corePoolSize大的考虑:如果队列积压满了才会轮到maximumPoolSize,但是队列积压满了,几乎就代表你每秒都会有积压累积,这时候maximumPoolSize大于corePoolSize的的部分相当于一种应急措施,这会给我们修复争取一点时间(因为设置了maximumPoolSize大于corePoolSize,所以rejection响应会不是那么及时,记得自己加一个积压队列的监控),但是同时这种操作也会抢占资源,可能会导致其他服务的崩溃

有人习惯性将maximumPoolSize与corePoolSize设置相同大小,这种设计能够在出现问题时及时抛出rejection。同时allowCoreThreadTimeOut设置为true,也不是那么的浪费资源。这种设计也是非常合理的。

2. 使用适当的线程池类型

Java提供了不同类型的线程池,每种类型都有不同的使用场景和优缺点。因此,需要根据实际需求选择合适的线程池类型。例如,固定大小的线程池适用于处理大量并发请求,但当请求量突然增大时,可能会导致任务积压;而可缓存的线程池则可以动态调整线程数量,但任务数量波动剧烈时可能会引起上下文切换的开销

可缓存的线程池(NewCachedThreadPool)是指创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。这种线程池的特点是里面没有核心线程,全是非核心线程,其maximumPoolSize设置为Integer.MAX_VALUE,线程可以无限创建。

当线程池中的线程都处于活动状态的时候,线程池会创建新的线程来处理新任务,否则会用空闲的线程来处理新任务。这类线程池的空闲线程都是有超时机制的,空闲时间一般为60秒,超过60秒的空闲线程就会被回收。当线程池都处于闲置状态时,线程池中的线程都会因为超时而被回收,所以几乎不会占用什么系统资源。任务队列采用的是SynchronousQueue,这个队列是无法插入任务的,一有任务立即执行,所以CachedThreadPool比较适合任务量大但耗时少的任务

考虑上面的任务经常抽疯,所以,虽然可缓存线程池比较适合这个任务,但是我们还是决定保守使用固定大小的线程池。后续增加手动配置更改线程池大小

3. 选择合适队列类型

强烈建议使用有界队列:ArrayBlockingQueue,毕竟这玩意儿配合AbortPolicy的拒绝策略的监控,会让线程池可控,真要使用无界队列如果出问题,任务积压严重能把集群搞崩。

同时那个有界队列的数量也要把握好,我这里设的是10万,各位见仁见智吧,我个人积压队列超过每分钟处理任务的峰值就已经开始说明比较危险了。

4. 异常处理与监控

这里任务很重要的话,最好拒绝策略设置为AbortPolicy,可以加个CAT监控处理失败的任务数量和具体内容。

当然如果任务不重要的话,可以静默丢弃处理。

5. 资源回收与扩展性

在使用线程池时,需要注意资源的回收和扩展性。当线程池不再使用时,需要合理地回收资源并进行垃圾回收;当系统需要扩展时,需要考虑如何扩展线程池的大小和容量,以便适应更大的并发请求。

考虑之前我们做了线程数的冗余设计并且任务并发量在凌晨非常小,所以allowCoreThreadTimeOut设置为true,防止浪费,增大系统开销。

同时,为了方便应急处理,我们增加了apollo监听更改线程池参数的逻辑,方便扩容。

四、线程池应用实例

1. 自定义ThreadFactory

这里其实就是加个自定义线程池名字,其他和父类变化不大

public class CustomThreadFactory implements ThreadFactory {

    final AtomicInteger poolNumber = new AtomicInteger(1);
    final ThreadGroup group;
    final AtomicInteger threadNumber = new AtomicInteger(1);
    final String namePrefix;

    public DoubleWriteThreadFactory(String name) {
        SecurityManager s = System.getSecurityManager();
        group = (s != null) ? s.getThreadGroup() : Thread.currentThread()
                .getThreadGroup();
        namePrefix = name + "-" + poolNumber.getAndIncrement() + "-thread-"; //线程池名字
    }

    @Override
    public Thread newThread(Runnable r) {
        Thread t = new Thread(group, r, namePrefix + threadNumber.getAndIncrement(), 0);
        if (t.isDaemon()) {
            t.setDaemon(false);
        }
        if (t.getPriority() != Thread.NORM_PRIORITY) {
            t.setPriority(Thread.NORM_PRIORITY);
        }
        return t;
    }

}

2. 自定义CustomThreadPoolExecutor

  1. 使用自定义的CustomThreadFactory

  2. 采用直接抛出异常的拒绝策略便于监控

  3. 线程池初始化时,创建所有coresize线程

  4. 核心线程超时开关allowCoreThreadTimeOut打开

  5. 用分布式追踪的api进行包装executorService,保证跨线程分布式追踪,避免内存泄漏

public class CustomThreadPoolExecutor {

    /**
     * 对外提供线程池api的对象
     */
    ExecutorService executorService;

    /**
     * 具体的thread poll
     */
    ThreadPoolExecutor threadPoolExecutor;

    public CustomThreadFactory(int corePoolSize, int maximumPoolSize, int keepAliveTime, int capacity){
        this.threadPoolExecutor = new java.util.concurrent.ThreadPoolExecutor(
                corePoolSize, maximumPoolSize, keepAliveTime
                , TimeUnit.SECONDS,
                new ArrayBlockingQueue<Runnable>(capacity), 
                new CustomThreadFactory("Custom"),
                new java.util.concurrent.ThreadPoolExecutor.AbortPolicy());//采用直接抛出异常的拒绝策略便于监控
        this.threadPoolExecutor.prestartAllCoreThreads(); //线程池初始化时,创建所有coresize线程
        this.threadPoolExecutor.allowCoreThreadTimeOut(true); //  核心线程超时开关
        executorService = TracingSingleton.getTracingcurrentTraceContext().executorService(this.threadPoolExecutor); //用分布式追踪的api进行包装,保证跨线程分布式追踪,避免内存泄漏
    }

    /**
     * 获取thread pool service
     * @return
     */

    public ExecutorService getExecutorService() {
        return executorService;
    }

    public ThreadPoolExecutor getThreadPoolExecutor() {
        return threadPoolExecutor;
    }

}

3. 线程池初始化

其实从apollo获取到这个配置不太合理,因为线程池在一开始加载后就不会在去初始化了,所以这些配置只有第一次会生效。只有重启服务线程池的配置才会更改,所以后面增加了监听apollo更改线程池配置的方法。

// 初始线程数量
private static int corePoolSize = ConfigService.getAppConfig().getIntProperty("CorePoolSize", 16);
// 最大线程数量
private static int maximumPoolSize = ConfigService.getAppConfig().getIntProperty("maximumPoolSize", 32);
// 线程最大idle时间
private static int keepAliveTime = ConfigService.getAppConfig().getIntProperty("keepAliveTime", 30);
// 线程池队列容量
private static int capacity = ConfigService.getAppConfig().getIntProperty("capacity", 100000);

DoubleWriteThreadPoolExecutor doubleWriteThreadPoolExecutor = new DoubleWriteThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime,capacity);

ExecutorService executorService = doubleWriteThreadPoolExecutor.getExecutorService();

ThreadPoolExecutor threadPoolExecutor = doubleWriteThreadPoolExecutor.getThreadPoolExecutor();

4. 提交任务

  1. 如果任务被拒绝,CAT统计监控。正常来说应该一个都不会有,如果出现就需要引起重视

  2. 设置一个动态阈值,超过这个阈值则统计线程池队列积压任务的数量。因为一秒内每毫米的并发也不是平滑的,所以队列中有一些积压是很正常的。这个阈值的设置就是灵活调整的监控标准。

  3. 增加apollo监听,方便调整线程池配置。

try {
    String service = config.getProperty("switch", "1");
    if ("1".equalsIgnoreCase(service)) {
        // 1.提交任务到线程池
        try {
            executorService.submit(() -> bigdataOtherSVC.saveActivity(activity));
        } catch (RejectedExecutionException e) {
            // 2.任务被拒绝,CAT统计监控
            LogUtil.error(ERR,"Task was rejected: " + e.getMessage());
            Cat.logEvent("Rejected", activity.getNum());
        }
        // Cat统计线程池的轮次
        int catNum = ConfigService.getAppConfig().getIntProperty("catNum", 20000);
        // 获取线程池队列积压任务的数量
        int queueSize = doubleWriteThreadPoolExecutor.getThreadPoolExecutor().getQueue().size();
        // 3.设置一个阈值,超过这个阈值则统计线程池队列积压任务的数量
        if (queueSize >= catNum){
            Cat.logEvent("ThreadPoolBacklog", String.valueOf(queueSize));
        }
        // 4. 增加apollo监听,方便调整线程池配置
        config.addChangeListener(changeEvent -> {
            if (changeEvent.isChanged("CorePoolSize")) {
                threadPoolExecutor.setCorePoolSize(config.getIntProperty("CorePoolSize", 16));
            }
            if (changeEvent.isChanged("maximumPoolSize")) {
                threadPoolExecutor.setMaximumPoolSize(config.getIntProperty("maximumPoolSize", 32));
            }
            if (changeEvent.isChanged("keepAliveTime")) {
                threadPoolExecutor.setKeepAliveTime(config.getIntProperty("keepAliveTime", 30),TimeUnit.SECONDS);
            }
        });
    }
} catch (Exception e) {
    Cat.logEvent("ThreadError", activity.getNum());
}